Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/record-flow-surface.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,9 @@
"RangeOperatorSchema (const)",
"Reaction (type)",
"ReactionSchema (const)",
"RecordFlow (type)",
"RecordFlowContainer (type)",
"RecordFlowSurface (interface)",
"RecordSubscription (type)",
"RecordSubscriptionSchema (const)",
"RecordSurface (type)",
Expand Down Expand Up @@ -462,6 +465,7 @@
"defineObjectExtension (function)",
"defineSeed (function)",
"deriveFieldGroupLayout (function)",
"deriveRecordFlowSurface (function)",
"deriveRecordSurface (function)",
"fieldForm (const)",
"hasDynamicTokens (function)",
Expand Down
61 changes: 61 additions & 0 deletions packages/spec/src/data/record-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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);
Expand Down
59 changes: 59 additions & 0 deletions packages/spec/src/data/record-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}