Style Detail, Measurements & Sizes
Style Show/Edit/New UI, garment measurements, XLSX round-trip, size ordering, Measurement library.
Deep-dive doc split out of
.claude/rules/architecture.md(which is now an index). Append new findings about this area here, not to the index.
Size Ordering (Size.sortOrder)
The Size table has a nullable sortOrder Int? column (INFRA-497) that drives apparel-order display (XXS, XS, S, … 5X) on the Style Measurement UI and in the measurement XLSX export. Before it, every size list ordered by sizeId (insertion order), so a later-added size like XXS rendered last.
- Single source of truth:
src/constants/sizeOrder.ts— exportsCANONICAL_SIZE_ORDER(the ordered apparel-size list),SIZE_SORT_ORDER_GAP = 10, andcomputeSizeSortOrder(size, currentMaxSortOrder). Known sizes map to(index + 1) * 10(XXS=10 … 5X=130); an unknown size is appended atcurrentMaxSortOrder + 10. The gap of 10 lets a new size be inserted between two existing ones without renumbering. This one helper is consumed by all four write paths so ordering logic never drifts:- Backfill script —
scripts/backfill-size-sort-order.ts(one-off,--dry-run/--help; usesSizeModel.findMany+SizeModel.update+disconnectPrisma). Run once per environment after the migration. Realigns known sizes to their canonical slot and appends null-valued unknowns after the highest existing value (insizeIdorder). Safe to re-run: it preserves an unknown size an admin has already positioned (non-nullsortOrder), so a re-run never clobbers a manual reposition. - Airtable sync —
SizeModel.upsertByAirtableRecordId(src/models/size/size.model.ts) setssortOrderonly on the create branch: it queriestx.size.aggregate({ _max: { sortOrder } })and callscomputeSizeSortOrder(data.size, max ?? 0). Theupdatebranch and the orphan-linkupdateomitsortOrder, so an admin/backfilled value is preserved across every re-sync. A new size from Airtable lands in canonical position (if known) or appended (if unknown). - Seed —
prisma/seed/seed-sizes.tsprecomputes eachsortOrdersequentially before the (concurrent) create batch, seeding the running max from the DB's current max so an unknown size insizes.jsonappends instead of colliding at the canonical XXS slot.sizes.jsoncarries onlysizeCode+size— it does not own ordering (deliberately decoupled so the seed file never drifts from the canonical list). - Manual AdminJS create/edit — the
Sizeresource (extracted tosrc/routers/admin/resources/size/size.ts, mirroringmeasurement.ts) registersbeforehookssizeBeforeNewHook/sizeBeforeEditHookthat set audit fields (createdByon create,updatedByalways, via a privatesetAuditFields) and callsizeAutoSortOrderBeforeHook— assignssortOrdervia the helper when the form omits it (respecting an explicit, numeric typed value incl.0; a non-numeric value isNumber.isFinite-guarded and falls through to the helper rather than writingNaN). Needed because@adminjs/prisma's default create bypassesSizeModel, so without the hook a manual "New Size" saved nullsortOrder/createdBy.
- Backfill script —
- Read path: the four size queries in
src/services/style/style.service.ts(findAddStylePrepareDatas,makeStyleMeasurementsExcel,updateByMeasurementExcelRows,tranMeasurementExcelRows2StyleMeasurementInfos) useorderBy: { sortOrder: { sort: 'asc', nulls: 'last' } }. These feed the Show/Edit/New measurement tables (record.params.sizeRecords) and the XLSX export columns; there is no separate frontend size sort.nulls: 'last'keeps a row created outside the four paths (still null) at the end rather than hidden. - Admin list: the
Sizeresource setsoptions.sort: { sortBy: 'sortOrder', direction: 'asc' };sortOrderauto-renders as an editable field for manually repositioning an unknown new size. - Nullable on purpose: every create path now assigns it, but the column stays nullable as a safety net. Migration
20260528000000_add_size_sort_orderisADD COLUMNonly; value backfill is the script's job, never SQL.
Gotcha — a fifth insert path bypassed all of this (INFRA-584). scripts/backfill-airtable-sizes.ts (the standalone Size backfill/resync script, distinct from the live webhook's syncAirtableSize → SizeModel.upsertByAirtableRecordId path) has its own "genuinely new Airtable record" branch in processSizeRecord that, before INFRA-584, called a bare SizeModel.create({ data: { airtableRecordId, sizeCode, size, airtableSyncedAt } }) — silently omitting sortOrder, since SizeModel.create is a plain Prisma passthrough with none of upsertByAirtableRecordId's canonical-lookup/max-aggregate logic. Fixed by routing that branch through SizeModel.upsertByAirtableRecordId({ airtableRecordId, data: { sizeCode, size } }) instead — identical to the pattern the same file's orphan-link branch already used a few lines above — rather than duplicating the sortOrder computation inline. SizeModel.create itself is intentionally left as-is (still used by scripts/insert-ambiguous-legacy-sizes.ts, which computes sortOrder inline for its own reasons — see INFRA-571). Why Staging never caught this: Staging's reset-stale-airtable-record-ids.ts had nulled the legacy pre-Airtable Size rows' airtableRecordId, so every duplicate-labeled Airtable record synced afterward found an orphan and got funneled into the (unaffected) ambiguous-detection / orphan-link branches instead of the buggy bare-create branch. Production never runs that reset, so its legacy rows stayed linked — meaning genuinely-distinct same-labeled Airtable records (multiple Size Ranges sharing a label like "XS") had no orphan to match and fell straight into the buggy branch. Lesson: this script's insert path must always go through a model method that assigns sortOrder, never a bare create, or a future path added here will reproduce the same gap.
Style Detail Page (Show / Edit / New + Garment Measurements)
The Style resource (src/routers/admin/resources/style/style.ts) renders a custom React component per action rather than AdminJS defaults:
show→Components.StyleShow(handlerviewActionHandler),edit→Components.StyleEdit(handlereditActionHandler),new→Components.StyleNew(handlernewActionHandler).viewConstructionNotes/editConstructionNotesreuse the view/edit handlers but render the MaterialConstructions table instead of measurements (toggled byaction.nameinside the component).- Visibility split:
showisadminEditorOrBgusAuth(visible) /adminEditorViewerRoleAuth(accessible);edit/newareadminEditorRoleAuth. So viewers/BGUS can open Show but only admin/editor reach Edit.
Server-side data assembly — getStyleRecord (src/routers/admin/resources/style/utils.ts). On GET for show/edit/delete handlers, it calls StyleService.findStyleWithRelationLogError and folds the result into record.params:
...result.styleRecord— includesgarmentMeasurement(only rows attached to this style,isDeleted:false, ordered bymeasurement.sortOrder) andmaterialConstruction.measurementRecords— the full global Measurement library, not just this style's points. Built byfindAddStylePrepareDatas(src/services/style/style.service.ts) viaMeasurementService.findMany({ take: 100, orderBy: { sortOrder: 'asc' } })(nowhere).take: 100is a latent cap — if the library ever exceeds 100 rows it silently truncates (relevant after INFRA-478 lifted the measurement-count cap).materialRecords(full Material library, bymaterialId),sizeRecords(size list, bysizeId).
after.ts (showAfter/editAfter/newAfter) is a separate path that populates response.record.populated (AdminJS's default relation rendering) and, in newAfter, writes garmentMeasurement/materialConstruction rows from request.payload. The custom React components read record.params (from getStyleRecord), not populated — don't confuse the two when changing what the UI sees.
Frontend merge — useStyleDisplayInfo (src/components/admin/Style/hooks.ts). Produces displayMeasurements by mapping over measurementRecords (the full library) into DisplayMeasurement rows (tolerance: null, sizes: {}), then merging each style's garmentMeasurement values keyed by measurementId. Consequence: every library measurement is already a row in both Show and Edit; points not yet attached to the style render with empty tolerance/sizes. displayConstructions mirrors this for the Material library + materialConstruction. The new-style flow (useAddStylePrepareDatas) calls the addStylePrepareDatas resource action for the same library sets with empty garmentMeasurement/materialConstruction.
StyleGarmentMeasurements.tsx. Receives measurements: DisplayMeasurement[], sizes, editable, lang. Keeps a local copy in useState, initialized once via useEffect guarded by initialized when measurements.length > 0 (handles async-populated new-style data). editable only toggles input enablement + styling today (disabled in Show; editable={!isSaving} in Edit, so it momentarily flips false during save). Exposes a ref (StyleGarmentMeasurementsRef: getLocalGarmentMeasurements, updateLocalGarmentMeasurements) — the Excel-upload flow (useStyleMeasurementsUpload) calls updateLocalGarmentMeasurements to bulk-apply parsed rows. Input validation regex: /^[0-9\/\.\-\%\s]*$/ (rejects letters); on failure a per-cell error tooltip keyed row-${idx}-${field} / row-${idx}-size-${size} shows. idx in the updateField/updateSize handlers indexes into local — any view-mode row filtering must preserve that index mapping (skip rows during .map, don't pre-filter the array).
Save path — makeAddUpdateParams (src/components/admin/Style/utils.ts). StyleEdit.handleSubmit reads the ref's local rows, diffs them against the original garmentMeasurement keyed ${measurementId}-${size}: a (measurementId,size) pair absent from the original map → new create (garmentMeasurementId: null); changed tolerance/measurementValue → update. Special case: a row with a string tolerance but no size entries gets all sizes initialized. Result is serialized as garmentMeasurementsJson / materialConstructionsJson and sent via submit(...). Server: editActionHandler → prepareDataForUpdate → StyleService.updateStyleWithRelationLogError, which upserts via GarmentMeasurementModel.upsertMany (raw INSERT ... ON CONFLICT ("styleId","measurementId","size") DO UPDATE). So filling a value on a previously-unattached library point already creates a new GarmentMeasurement on save — no extra wiring needed for that.
INFRA-479 view-mode filtering. In Show, StyleGarmentMeasurements hides rows where every size value is empty/null AND tolerance is empty; in Edit it renders the full merged set. The filter is gated on a dedicated viewMode?: boolean prop, not on editable — important because editable oscillates during save (editable={!isSaving} in StyleEdit) and would briefly hide rows the user just cleared. StyleShow passes viewMode; StyleEdit lets it default to false. Implemented as a memoized visibleRows = useMemo(() => local.map((m, idx) => ({ m, idx })).filter(({ m }) => !viewMode || isRowPopulated(m)), [local, viewMode]) projection — each entry carries its original local index, so the updateField/updateSize handlers (which use that idx to write back into local) keep working unchanged. The empty-state row keys off visibleRows.length, not local.length, and uses the Style.measurementNoData locale key (separate from the styles-list noResults). The isRowPopulated helper lives at the top of the same file: Boolean(m.tolerance?.trim()) || Object.values(m.sizes ?? {}).some(s => Boolean(s?.measurementValue?.trim())). Edit-mode behavior and save path are unchanged — the loader already supplies the full library, so this is a pure presentational filter.
Style Measurement XLSX Export / Import
Style-scoped measurement spreadsheet round-trip, separate from the in-page Edit form (which it feeds via preview):
- Routes (
src/routers/api/style/v1/style.router.ts):GET /style/v1/exportMeasurementExcel?style=<styleNumber>andPOST /style/v1/uploadMeasurementExcel(multipart). Session-authed like the rest of the style API. - Export —
StyleService.makeStyleMeasurementsExcel(styleNumber)(src/services/style/style.service.ts:280): loads the full Measurement library viaMeasurementService.findMany({ orderBy: { sortOrder: 'asc' } })— notake: 100cap (that cap lives only infindAddStylePrepareDatas); sizes orderedsortOrder asc, nulls last. Builds one row per library measurement (aMap<measurementId, info>), then folds the style'sGarmentMeasurementrows in — unattached points keep blank tolerance/size cells. Tolerance is taken from the first garment row that has one (hasToleranceflag), i.e. tolerance is per-measurement, not per-size. Stale garment rows whosemeasurementIdno longer exists in the library are silently skipped. Columns built withmakeExcelColumn(key, width)(header === key,numFmt: '@'text format). Cell locking:sheet.protect('', {...})(empty password) is applied, then every body cell is setprotection: { locked: false }+UNLOCKED_FILL— so all columns (includingmeasurementId) are editable. Only the header row is locked (re-styled bold +HEADER_FILLlast). (Per INFRA-493 follow-up themeasurementIdcolumn is no longer locked; theLOCKED_FILL/LOCKED_FONTconstants remain only because the construction-notes export still uses them.) - File name is generated in the controller (
exportMeasurementExcel,src/controllers/style/style.controller.ts):measurements_${styleNumber}_${formatDateForInput(new Date())}.xlsx, sent viasendExcelFile. The frontend (fetchExportMeasurementExcelinsrc/components/utils/fetchUtils.ts) prefers theContent-Dispositionfilename and falls back to building the same name client-side. - Import —
uploadMeasurementExcelcontroller: multer memory storage,.xlsxext + xlsx MIME only, 10MB cap, wrapped in a promisifiedhandleExelFileso multer errors becomemakeBadRequestError. Pipeline:tranFile2MeasurementExcelRows(parse) → Zod (StyleUploadMeasurementExcelSchemaOpts) → in-cell style check (req.body.styleNumber === rows[0].styleNumber) →tranMeasurementExcelRows2StyleMeasurementInfos→ JSON response. Preview-only: the endpoint never writes to the DB. The frontend (fetchUploadMeasurementExcel→useStyleMeasurementsUpload→updateLocalGarmentMeasurementsref call) applies parsed rows to the Edit form's local state; persistence happens only when the user saves the Style edit form (thegarmentMeasurementsJsonpath above). - Header-keyed parsing:
excelFile2List(src/utils/excelUtils.ts) maps cells to object keys by header-row string, not column position — so reordering export columns can't break import. Aformattermap allows per-column coercion (measurementId: (c) => Number(c.value)); all other numeric cells are stringified post-parse. Numeric cells respectnumFmtvia thenumfmtpackage. - Upload schema (
src/schemas/style/index.ts): array of row objects —styleNumber(via sharedstyleValidator()),developmentIdrequired,styleNameCn/styleNameEnoptional,measurementIdz.coerce.number().positive().int(),toleranceoptional, measurement namesz.any()(reference-only), and.catchall(z.string().max(25))for the dynamic size columns. - Dead code (as of INFRA-493 exploration):
updateByMeasurementExcelRows(style.service.ts:434) — a full diff-and-persist path (upsert + delete inside a$transaction, with ajudgeGarmentActionstate-machineactionMapcomparing file vs DB cell states) — has no callers; the upload endpoint is preview-only and the save goes through the Style edit form instead. ThejudgeGarmentAction/actionMapmachinery at the bottom of the file exists solely for it. - The
multerupload field isfile; the on-screen style is passed alongside as form-datastyleNumber(fetchUploadMeasurementExcelappendsfile.nameas originalname, so the original client file name reachesreq.file.originalname). style.service.spec.tsexists (coversmakeStyleMeasurementsExcel,tranMeasurementExcelRows2StyleMeasurementInfos,updateStyleWithRelationLogError,findStyleWithRelationLogError,exportConstructionNotesAsExcel) — a prior architecture.md pass claimed no spec existed for this file; that was stale even before INFRA-565. No spec exists forstyle.controller.ts(or the style router) — tests for those must still be created from scratch following the controller/router spec conventions used elsewhere. Before trusting an architecture.md claim that a spec file is missing,lsthe directory —Writewill refuse to overwrite an unread existing file, which is the fastest way to catch a stale claim like this one.
Style Name Max Length — Three Independent Enforcement Points (INFRA-575)
Style.styleNameCn/styleNameEn length is enforced in three separate places with no shared constant — a change to one silently leaves the others out of sync:
- DB column (
prisma/schema.prisma,Style.styleNameCn/styleNameEn@db.VarChar(100)as of INFRA-575, previouslyVarChar(50)) — the ultimate ceiling; a value that gets past both Zod schemas below but exceeds this still fails at the DB. - Airtable Style webhook schema (
src/schemas/webhooks/index.ts,AirtableStyleWebhookBodySchema.styleName) — validates the incoming Airtable sync payload. This is what surfaced the INFRA-575 bug: a real Airtable style name over 50 characters (a jewelry-category product name, "North South Emerald Cut Diamond 14K Solid White Gold", 52 chars) got rejected with a 400 before ever reaching the DB. - Style Measurement Excel-upload row schema (
src/schemas/style/index.ts,StyleUploadMeasurementExcelSchemaOpts) — a different, easily-confused schema: it validatesstyleNameCn/styleNameEnas reference-only display columns in each parsed Excel row (see § "Style Measurement XLSX Export / Import" above), not the Style record itself — the upload endpoint is preview-only and never writes these fields anywhere. Before INFRA-575 this wasmax(35), already stricter than the DB column'sVarChar(50), unrelated to and inconsistent with the webhook schema'smax(50).
Not a validation surface at all: the AdminJS New/Edit Style form (src/components/admin/Style/{StyleNew,StyleEdit}.tsx, resource config in src/routers/admin/resources/style/style.ts) has no Zod schema and no maxLength/length property on styleNameCn/styleNameEn — it relies entirely on the DB column ceiling. So widening the DB column alone (without touching any admin-side code) is sufficient to let the admin form accept longer names; only the two Zod schemas above need an explicit code change to match.
When changing this limit again: update all three (DB column via migration, webhook schema, Excel-upload schema) together, and don't assume src/schemas/style/index.ts's limit governs the admin create/edit form — it doesn't.
GarmentMeasurement ↔ Size: sizeId FK + POM Size Range scoping (INFRA-565)
GarmentMeasurement.sizeId (Int?, FK → Size.sizeId, ON DELETE SET NULL ON UPDATE CASCADE) is the authoritative link — free-text label matching (the pre-INFRA-565 GarmentMeasurement.size VarChar scalar) is gone from the Prisma model. @@unique([styleId, measurementId, sizeId]) replaces the old ..., size] constraint; @@index([styleId, sizeId]) replaces [styleId, size]. This was needed because INFRA-562 dropped Size.size's uniqueness (the same label can belong to multiple Size Range–scoped Size rows), so label-based joins against the global Size library were a latent collision risk. Consumers, post-migration:
src/services/style/style.service.ts— all 4 read/write paths now key bysizeIdand scope the size list to the style's own resolvedsizeRangeIdvia the relational filter below, instead of reading the unscoped global library:findAddStylePrepareDatas(sizeRangeId)(feeds Show/Edit viafindStyleWithRelationand the New-style form —sizeRangeId === nullshort-circuits to[], no query),makeStyleMeasurementsExcel(export — builds asizeId2LabelMapto render the Excel size-label column headers, skips aGarmentMeasurementrow whosesizeIddoesn't resolve to a column in the style's current range rather than deleting it),tranMeasurementExcelRows2StyleMeasurementInfos(import preview — resolves the style'ssizeRangeIdfrommeasurementExcelRows[0].styleNumberfirst). The save path (updateStyleWithRelation) diffs/upserts bysizeIdviaGarmentMeasurementModel.upsertMany's rawON CONFLICT ("styleId","measurementId","sizeId"); the delete branch'sORclause is{ measurementId, sizeId }pairs.src/components/admin/Style/{hooks.ts,utils.ts,StyleGarmentMeasurements.tsx}— frontend re-keyed in lockstep:DisplayMeasurement.sizesisRecord<sizeId, {...}>(not label), the save-path diff key inutils.ts#makeAddUpdateParamsis${measurementId}-${sizeId}, and the rendered table's per-columnkey={sizeId}inStyleGarmentMeasurements.tsx(the size label is still what's displayed in the header text — only the React key and the data-lookup index changed). A style with nosizeRangeIdyields an emptysizesarray from the backend;StyleGarmentMeasurementsrenders an amber banner (Style.noSizeRangeAssignedlocale key) instead of an empty table in that case, in both Show and Edit.src/services/changelog/changelog.service.ts(fetchMeasurementChanges) —select: { size: true }becameselect: { size: { select: { size: true } } }(the relation, selecting the label off it) since the raw scalar no longer exists; falls back to'Unknown'if the relation is null (unresolved/legacy row).src/services/workorder/workorder.service.ts(_makeWorkOrderDatas) — the composite join key changed from${styleId}-${size-label}(via a label lookup even thoughwO.sizeIdwas already in hand) to${styleId}-${wO.sizeId}directly — no more label detour.src/models/workorder/workorder.model.ts(findWithRelationsById) filters the loaded style'sgarmentMeasurementarray down to the work order's own size viagM.sizeId === record.sizeId(was a label comparison).admin.router.tsalso registers a separate, raw AdminJS CRUD resource forGarmentMeasurementdirectly against the Prisma model (adminRoleAuth-gated, underproductParent) — independent of the custom Style Show/Edit UI. It now renders/editssizeIdas whatever@adminjs/prismadoes with a plain FK Int column (no custom dropdown was added). Any future scalar→relation column change on this model must be checked against this resource's default rendering, not just the custom Style components.- Backfill:
scripts/backfill-garment-measurement-size-id.tsresolvessizeIdfor any pre-migration row (Style →sizeRangeId→SizeRangeToSize→ label match), reading the now-model-absent legacysizecolumn via$queryRaw(the escape hatch — see Soft Deletes section below for the general pattern of reading columns the Prisma model no longer declares). Unresolvable rows (nosizeRangeId, no matching label in range) are reported, not errored, and left for manual review; safe to re-run since onlysizeId IS NULLrows are touched. The physicalsizecolumn drop is a deferred follow-up ticket (expand-contract: this migration only addedsizeIdand relaxedsizeto nullable, soprisma migrate deployrunning unattended in CI can't destroy the backfill's only data source before the script runs).
Relational scoping pattern: Size carries a reverse relation sizeRangeToSize SizeRangeToSize[] (from INFRA-563), so scoping a Size query to one Size Range doesn't need a new model method — a plain Prisma relation filter works: SizeService.findMany({ where: { sizeRangeToSize: { some: { sizeRangeId } } }, orderBy: {...} }). This is the pattern all 4 style.service.ts read paths now use in place of an unscoped findMany.
Measurement Resource & Limited-User Pattern
The global Measurement library (src/routers/admin/resources/measurement/measurement.ts, a Prisma-backed resource under the "Reference Tables" parent) restricts a set of "limited users" to a guard-railed create/edit experience. The pattern spans three layers:
- Identity —
src/constants/measurementUserRestrictions.tsexportsMEASUREMENT_LIMITED_EMAILS(aSetofbirdygreychina@birdygrey.com(BGC) +bgus@birdygrey.com(BGUS)) andisMeasurementLimitedUser(email). This is the single source of truth for "is this a constrained library editor" and is consumed by both backend hooks and frontend components. - Backend
beforehooks (inmeasurement.ts):measurementBeforeNewHook/measurementBeforeEditHookalways set audit fields (createdBy/updatedBy) viasetAuditFields, then — only forisMeasurementLimitedUser(email)— runmeasurementAutoSortOrderBeforeHook(assignssortOrder = max+1when omitted) andvalidateMeasurement(requires non-empty EN+CN names; case-insensitive duplicate check against non-deleted rows viaprisma.measurement.findFirst, throwing AdminJSValidationErrorkeyed per-field). Admin/editor users skip this validation and entersortOrdermanually (it's a@unique Intcolumn).measurementBeforeNewHookhistorically also enforced a hard cap of 25 measurements for limited users (inline literal, not a named constant). - Frontend field hiding — the resource uses globally-overridden action components
DefaultNewAction/DefaultEditAction/DefaultShowAction(src/components/common/). Each callsfilterFormProperties(resource, action, isRestrictedEmail, TARGET_RESOURCE_ID, HIDDEN_FOR_VIEWER, REQUIRED_FOR_VIEWER)fromsrc/components/utils/. For limited users on theMeasurementresource only, this hidescreatedBy,updatedBy,isDeleted,deletedAt,deletedBy,sortOrder(theHIDDEN_FOR_VIEWERset inmeasurementsViewerFields.ts) and marks requiredmeasurementNameCn/En,unitOfMeasureCn/En(REQUIRED_FOR_VIEWER). AdminJS reuseseditPropertiesfor bothnewandedit, so the create and edit forms hide the same fields. Field visibility for limited users is therefore enforced by these custom components + Sets, not byproperties.<field>.isVisiblein the resource options (the resource only declaresunitOfMeasure{Cn,En}: { isDisabled: true }).
Gotcha — the cap had a frontend mirror. Beyond the backend count >= 25 throw, the "Create new" button on the Measurement list was also hidden client-side in ActionHeader.tsx (shouldHideNew = isMeasurement && isListAction && isLimitedUser && totalRecords >= 25) — so a limited user at 25 rows saw the list but no Create-new button even if the backend allowed it. When changing the measurement cap or limited-user reach, both the backend hook and this ActionHeader gate must move together. INFRA-478 removed both; ActionHeader.tsx no longer imports isMeasurementLimitedUser.
Delete is gated by adminRoleAuth (visible) / adminEditorRoleAuth (accessible), so BGC/BGUS (neither admin nor editor) cannot soft-delete.
Unit-of-measure defaults (two layers). unitOfMeasureCn/En are isDisabled in resource options (read-only in forms) — so no role can type them via the UI. Every measurement in the library is in inches (英寸 / Inch, matching the seeded prisma/seed/data/measurements.json). The defaults live in src/constants/measurementUserRestrictions.ts (DEFAULT_UNIT_OF_MEASURE_CN/EN + DEFAULT_UNIT_OF_MEASURE_BY_PROPERTY), shared by both layers so they can't drift:
- Server-side backfill —
measurementBeforeNewHook/measurementBeforeEditHookcallsetDefaultUnitsOfMeasure(payload), filling empty/whitespace unit fields and preserving any non-empty value. Runs for all roles (the disabled field never carries a user value). This is the authoritative safety net. - Form display —
src/components/properties/MeasurementUnitInput/MeasurementUnitInput.tsxis a customeditproperty component (registered asComponents.MeasurementUnitInputincomponentLoader.ts, mounted on both unit properties viaproperties.unitOfMeasure{Cn,En}.components.edit). AdminJS shares theeditcomponent between thenewandeditforms. It renders a disabled<Input>showing the current value or the default, and on mount seeds an empty record viaonChange(propertyName, default)so the default both displays in the greyed box on the create form and is submitted. Without this component the disabled field rendered blank onnewuntil first save.
This is the codebase pattern for "read-only field with a fixed default that must still display + submit": a custom edit property component (MeasurementUnitInput, like ConfigValueInput) + a server-side hook backfill, both reading defaults from one shared constants module.
Reference Seed (prisma/seed/seed-reference.ts)
Seeds the global Material and Measurement libraries from prisma/seed/data/{materials,measurements}.json (run via run-reference.ts). Purely additive, never deletes/updates: it findManys existing rows keyed by sortOrder (measurements) / materialCategoryEn_sortOrder (materials), then createManys only the JSON entries whose key is absent. Consequences:
- Removing an entry from
measurements.jsononly stops it being seeded fresh — existing DB rows (in any already-seeded env) are untouched; clean those via AdminJS soft-delete or a script. sortOrderis the natural key for measurement idempotency, so JSONsortOrdervalues must stay unique and stable; reusing a value silently skips the new row.unitOfMeasure{Cn,En}in the JSON are all英寸/Inch, matching the read-only UI defaults (above). No spec covers this seed.- Placeholder rows (
Measurement Name N/尺寸名 N) were temporary fillers; once BGC can author real measurements via the MES UI (INFRA-478), they're removed from the JSON (INFRA-504).