diff --git a/.changeset/record-flow-surface.md b/.changeset/record-flow-surface.md new file mode 100644 index 0000000000..31781e8baf --- /dev/null +++ b/.changeset/record-flow-surface.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): `deriveRecordFlowSurface(def, flow, opts)` — flow-aware record-surface derivation (#2604, extends #2578's `deriveRecordSurface`, ADR-0085 §5 one-shared-derivation). + +Decides the default surface per record FLOW: `view` keeps the shipped behavior verbatim (field-heavy → `route`/page, light → drawer overlay); the task flows (`create` / `edit` / `child-create` / `child-edit`) are ALWAYS overlays — never routes — with the derived `'page'` mapped to a full-screen modal (`size: 'full'`) and light objects staying a drawer. `child-*` flows take the CHILD object's def (the overlay sizes to the record being edited; the return target is always the parent detail). Mobile task flows are full-screen modals. + +Rationale: viewing a record is shareable state (deep-link belongs there); making/changing one is a transient task whose URL is a false promise (refresh loses the draft) and whose invariant is lossless return to the origin. Renderers treat the result as the DEFAULT only — explicit `navigation.mode`/`size`, `FormView.type`/`modalSize`, or an assigned page still win. No new authorable key (ADR-0085 §2). Additive, no breaking changes. diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index de297ad342..03f092895a 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -367,6 +367,9 @@ "RangeOperatorSchema (const)", "Reaction (type)", "ReactionSchema (const)", + "RecordFlow (type)", + "RecordFlowContainer (type)", + "RecordFlowSurface (interface)", "RecordSubscription (type)", "RecordSubscriptionSchema (const)", "RecordSurface (type)", @@ -462,6 +465,7 @@ "defineObjectExtension (function)", "defineSeed (function)", "deriveFieldGroupLayout (function)", + "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", "fieldForm (const)", "hasDynamicTokens (function)", diff --git a/packages/spec/src/data/record-surface.test.ts b/packages/spec/src/data/record-surface.test.ts index 9e985a38ad..7f919a1f15 100644 --- a/packages/spec/src/data/record-surface.test.ts +++ b/packages/spec/src/data/record-surface.test.ts @@ -3,8 +3,10 @@ import { describe, it, expect } from 'vitest'; import { deriveRecordSurface, + deriveRecordFlowSurface, countAuthorableFields, RECORD_SURFACE_PAGE_THRESHOLD, + type RecordFlow, } from './record-surface'; /** Build an object def with `n` plain text fields named f0..f(n-1). */ @@ -54,6 +56,65 @@ describe('deriveRecordSurface (ADR-0085 §5)', () => { }); }); +describe('deriveRecordFlowSurface (#2604)', () => { + const TASK_FLOWS: RecordFlow[] = ['create', 'edit', 'child-create', 'child-edit']; + const heavy = objWithFields(RECORD_SURFACE_PAGE_THRESHOLD); + const light = objWithFields(RECORD_SURFACE_PAGE_THRESHOLD - 1); + + it("view keeps the #2578 behavior verbatim: heavy → route('page'), light → overlay('drawer')", () => { + expect(deriveRecordFlowSurface(heavy, 'view')).toEqual({ + container: 'route', surface: 'page', size: 'auto', + }); + expect(deriveRecordFlowSurface(light, 'view')).toEqual({ + container: 'overlay', surface: 'drawer', size: 'auto', + }); + }); + + it('task flows never route: heavy → full-screen modal overlay', () => { + for (const flow of TASK_FLOWS) { + expect(deriveRecordFlowSurface(heavy, flow)).toEqual({ + container: 'overlay', surface: 'modal', size: 'full', + }); + } + }); + + it('task flows on a light object stay a drawer overlay', () => { + for (const flow of TASK_FLOWS) { + expect(deriveRecordFlowSurface(light, flow)).toEqual({ + container: 'overlay', surface: 'drawer', size: 'auto', + }); + } + }); + + it('mobile: view routes to a page; task flows become a full-screen modal', () => { + expect(deriveRecordFlowSurface(light, 'view', { viewport: 'mobile' })).toEqual({ + container: 'route', surface: 'page', size: 'auto', + }); + for (const flow of TASK_FLOWS) { + expect(deriveRecordFlowSurface(light, flow, { viewport: 'mobile' })).toEqual({ + container: 'overlay', surface: 'modal', size: 'full', + }); + } + }); + + it('child-* flows size to the def they are given (the child), independent of any parent', () => { + // A thin child stays a drawer even though its parent (not passed) is heavy. + expect(deriveRecordFlowSurface(objWithFields(3), 'child-create').surface).toBe('drawer'); + // A fat child gets the full-screen modal. + expect(deriveRecordFlowSurface(objWithFields(40), 'child-edit')).toEqual({ + container: 'overlay', surface: 'modal', size: 'full', + }); + }); + + it('honours pageThreshold and tolerates bare/malformed input', () => { + expect(deriveRecordFlowSurface(objWithFields(5), 'create', { pageThreshold: 4 }).size).toBe('full'); + expect(deriveRecordFlowSurface(null, 'create')).toEqual({ + container: 'overlay', surface: 'drawer', size: 'auto', + }); + expect(deriveRecordFlowSurface({ fields: 'nope' } as unknown, 'view').surface).toBe('drawer'); + }); +}); + describe('countAuthorableFields', () => { it('counts visible non-system fields only', () => { expect(countAuthorableFields(objWithFields(5))).toBe(5); diff --git a/packages/spec/src/data/record-surface.ts b/packages/spec/src/data/record-surface.ts index dcb789fe92..d35bca646c 100644 --- a/packages/spec/src/data/record-surface.ts +++ b/packages/spec/src/data/record-surface.ts @@ -91,3 +91,62 @@ export function deriveRecordSurface(def: unknown, opts: RecordSurfaceOptions = { if (countAuthorableFields(def) >= threshold) return 'page'; return 'drawer'; } + +/** + * The record flow being opened. `view` shows state; the other four perform a + * task (create/change a record). For `child-*` flows — a subtable / related- + * list child created or edited from its PARENT's detail — pass the CHILD + * object's def: the overlay sizes to the record being edited, while the + * return target is always the parent (#2604 D3). + */ +export type RecordFlow = 'view' | 'create' | 'edit' | 'child-create' | 'child-edit'; + +/** How the surface is mounted: a navigated route, or an overlay over the origin. */ +export type RecordFlowContainer = 'route' | 'overlay'; + +export interface RecordFlowSurface { + /** + * `'route'` only ever for flow `'view'` (a record is shareable state — + * deep-linkable, refresh-safe). Every task flow is an `'overlay'`: close + * returns to the origin with its context (scroll / filters / tab) intact, + * which is the #2604 return-flow invariant — and a create/edit URL would be + * a false promise anyway (refresh loses the draft). + */ + container: RecordFlowContainer; + surface: RecordSurface; + /** Maps onto `navigation.size` / `FormView.modalSize`; routes ignore it. */ + size: 'auto' | 'full'; +} + +/** + * Derive the DEFAULT surface for a record FLOW (#2604; extends + * {@link deriveRecordSurface}, ADR-0085 §5 "one shared derivation"). + * + * Rule — the two axes are independent: + * - how BIG (field count, via {@link deriveRecordSurface}) is unchanged; + * - whether it ROUTES is decided by what the flow *is*: viewing a record is + * state → route-capable; making/changing one is a task → always overlay. + * + * So `view` keeps the #2578 behavior verbatim (`'page'` → route), while the + * task flows map the derived `'page'` to a FULL-SCREEN MODAL — same big + * canvas, overlay return semantics. This mapping is why `'modal'` exists in + * {@link RecordSurface} without the base heuristic ever emitting it. + * + * Like the base derivation this is a DEFAULT only: explicit `navigation.mode` + * / `navigation.size`, `FormView.type` / `modalSize`, or an assigned page win + * (the sanctioned per-object overrides — no new authorable key, ADR-0085 §2). + */ +export function deriveRecordFlowSurface( + def: unknown, + flow: RecordFlow, + opts: RecordSurfaceOptions = {}, +): RecordFlowSurface { + const surface = deriveRecordSurface(def, opts); + if (flow === 'view') { + return { container: surface === 'page' ? 'route' : 'overlay', surface, size: 'auto' }; + } + // Task flows (create / edit / child-*): never a route. Field-heavy (or + // mobile, where the base derivation says 'page') → full-screen modal. + if (surface === 'page') return { container: 'overlay', surface: 'modal', size: 'full' }; + return { container: 'overlay', surface, size: 'auto' }; +}