Skip to content

Component Patterns

The standard pattern for edit forms with change detection:

import { calculateChanges, hasNoChanges } from '$lib/services/entity-operations.svelte'
let entity = $state<Entity>({} as Entity)
let editEntity = $state<Entity>({} as Entity)
let saving = $state(false)
// Calculate changes for button state
let changes = $derived(
calculateChanges(entity, editEntity, {
ignore: ['/NavigationProps', '/Collections']
})
)
<DetailView
checkExit={() => hasNoChanges($state.snapshot(entity), $state.snapshot(editEntity))}>
{#snippet editFooter(closeEdit)}
<button
disabled={saving || changes.length === 0}
onclick={() => save(closeEdit)}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
{/snippet}
</DetailView>

Key principles:

  • Use calculateChanges() in $derived to enable/disable the save button
  • Use hasNoChanges() for checkExit to prevent accidental close with unsaved changes
  • Disable button when saving || changes.length === 0
  • Always pass ignore option to exclude navigation properties from change detection
TypeConventionExample
TypeScript/JavaScriptkebab-casedevelopment-operations.svelte.ts
Svelte componentsPascalCaseSchemeDetail.svelte
Folderskebab-casesrc/lib/odata/

All areas, unit labels, and rent values go through the display components at src/lib/components/displays/ — never hand-format sqm/sqft/psf/psm, never inline net/gross labels. The components pick the user’s preferences (measurement system, area calculation, currency, time period) and react live to preference changes.

<!-- Column headers -->
<th>Space taken <AreaLabel /></th>
<th><AreaLabel prefix="Total Size" /></th>
<!-- Table cells — hideUnit because the column header carries it -->
<td><AreaValue entity={use} property="DevelopmentUse" hideUnit /></td>
<!-- Standalone values in detail panels -->
<dd><AreaValue entity={scheme} property="SchemeSize" /></dd>
<dd><RentValue event={occupier} /></dd>

In TS (OData configs, anywhere get text() must return a string), use the matching helpers from $lib/units:

import { getAreaLabel, getRentLabel } from '$lib/units'
text: getAreaLabel('Total Size') // "Total Size (sqm net)"
text: getRentLabel('Rent', getCurrencySymbol('GBP')) // "Rent (£ psqm pa)"

The primitives (convertUnitToPreference, getValueBasisPreference, formatValueWithUnit, …) still live in units.ts and are re-used internally. New code shouldn’t call them directly unless the display component can’t express what’s needed — prefer the components.

Schemes displayed under an Address have no intrinsic ordering field — there is no SchemeOrdinal, Order, or SortIndex on Scheme. Display order is derived from each scheme’s primary development (the one with DevelopmentOrdinal === 1), which is why the frontend has to hydrate the right nested fields before sorting.

orderSchemes() in src/routes/addresses/[id]/+page.svelte sorts schemes by:

  1. Primary development’s Status.SortIndex, ascending — the enum’s SortIndex is the source of truth for status ordering. Values are set in the DevelopmentStatus table, and as currently configured the “future” end of the pipeline (e.g. Planned, SortIndex 202) sorts before the “past” end (e.g. Complete, SortIndex 400). Changing that ordering is a data change, not a code change.
  2. Primary development’s CompletionDate || PlannedDate, descending — newest first within a status tier. Completion wins if set; otherwise planned.

Missing status sorts to Infinity; missing date sorts to -Infinity. Both push schemes to the bottom of their tier while preserving relative OData insertion order among themselves. The asymmetric sentinel values are deliberate — because the date tiebreak is descending, “no date” has to be the smallest value to drop to the bottom rather than jump to the top.

Because the sort reads nested fields, the Scheme OData config (configs/schemes.svelte.ts) must expand both:

'Developments': ['Id', 'CompletionDate', 'PlannedDate', 'DevelopmentOrdinal'],
'Developments/Status': ['DevelopmentStatusCode', 'DevelopmentStatusName', 'SortIndex'],

If either is dropped from the expand, the sort silently degrades — Status.SortIndex becomes undefined for every scheme (all tie at Infinity, so step 1 no-ops), and without PlannedDate the date fallback only fires when CompletionDate is set. The page still renders — it just renders schemes in whatever order OData returned them.

Why schemes might look “out of order” after merge

Section titled “Why schemes might look “out of order” after merge”

When two addresses are merged (EntityMerger.cs), the source address’s schemes are re-parented to the target by updating Scheme.AddressId. Ordering is not reassigned at the backend — there is no field to reassign. The schemes must be re-fetched on the frontend so orderSchemes() can re-sort the combined list.

Ensure the merge completion handler on the address detail page refreshes schemesStore:

onMergeComplete={async () => {
await odata.getDetail(idFilter, ProjectionType.Single)
refreshSchemes() // re-apply orderSchemes() across source + target schemes
}}

Without the refresh, the page still shows the target’s original schemes until full reload, and re-parented schemes appear out of order (or absent) briefly.

Always use Tailwind utility classes. Do not create custom CSS or <style> blocks in components.

Formation uses Skeleton UI’s colour tokens:

  • bg-surface-{50..900} — Neutral backgrounds
  • text-surface-{600..900} — Text colours
  • bg-primary-{600,700} — Primary actions
  • border-error-500 — Error states