Component Patterns
Entity Edit Form Pattern
Section titled “Entity Edit Form Pattern”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 statelet changes = $derived( calculateChanges(entity, editEntity, { ignore: ['/NavigationProps', '/Collections'] }))Save Button Pattern
Section titled “Save Button Pattern”<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$derivedto enable/disable the save button - Use
hasNoChanges()forcheckExitto prevent accidental close with unsaved changes - Disable button when
saving || changes.length === 0 - Always pass
ignoreoption to exclude navigation properties from change detection
File Naming Conventions
Section titled “File Naming Conventions”| Type | Convention | Example |
|---|---|---|
| TypeScript/JavaScript | kebab-case | development-operations.svelte.ts |
| Svelte components | PascalCase | SchemeDetail.svelte |
| Folders | kebab-case | src/lib/odata/ |
Measurement Display
Section titled “Measurement Display”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.
Scheme Ordering
Section titled “Scheme Ordering”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.
The rule
Section titled “The rule”orderSchemes() in src/routes/addresses/[id]/+page.svelte sorts schemes by:
- Primary development’s
Status.SortIndex, ascending — the enum’sSortIndexis the source of truth for status ordering. Values are set in theDevelopmentStatustable, 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. - 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.
Required OData expand
Section titled “Required OData expand”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.
Styling
Section titled “Styling”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 backgroundstext-surface-{600..900}— Text coloursbg-primary-{600,700}— Primary actionsborder-error-500— Error states