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
12 changes: 12 additions & 0 deletions .changeset/21370-starter-field-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'create-objectstack': patch
'@objectstack/cli': patch
---

fix: a fresh project no longer warns about its own starter fields after the first `objectstack generate`

Clause-②: no

The blank starter's `note` object (`npm create objectstack`) and the item object of the `app` template (`objectstack init -t app`) now declare one field group, `fieldGroups: [{ key: 'details', label: 'Details' }]`, and place every field in it with `group: 'details'`. Before this, the first view, flow, dashboard or other metadata that can read a field made `objectstack validate` and `objectstack lint` report `field-no-consumers` on a field the author never wrote: the note's `body`, or the item's `description` and `status`. That held whether the author generated it or wrote it by hand. Both commands still exited 0. A field placed in a declared group is drawn by the object's form and detail page, and the rule counts that as displayed, so a fresh project now reports nothing. The `plugin` and `empty` templates are unchanged: the plugin's one field is the record's title, which the rule never reports, and the empty template declares no object.

**What changes for an author.** In a new project, the object's form and detail page show the starter fields in one section labelled Details instead of a flat list. A field you add joins a section the same way, by naming its `key` in `group`. A project scaffolded by an earlier release keeps its files. To clear the warning there, add the same `fieldGroups` entry to the object and `group: 'details'` to each field the warning names, or give each field another consumer, such as a view column.
14 changes: 14 additions & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,15 +786,28 @@ ${renderWiredStackKeys()}
const ${toCamelCase(namespace)}Item = ObjectSchema.create({
name: '${namespace}_item',
label: '${toTitleCase(namespace)} Item',
// Field groups: the sections an item's form and detail page draw, top to
// bottom in this order. A field joins one by naming its \`key\` in \`group\`.
// That placement is what displays \`description\` and \`status\`.
// \`objectstack validate\` and \`objectstack lint\` report a field that
// nothing displays or reads (\`field-no-consumers\`) once the project holds
// a view, flow, dashboard or anything else that could read it, so give each
// field you add a \`group\` as well. Field groups versus a view's own form
// sections: https://objectstack.ai/docs/ui/field-grouping-and-order
fieldGroups: [
{ key: 'details', label: 'Details' },
],
fields: {
name: {
type: 'text',
label: 'Name',
required: true,
group: 'details',
},
description: {
type: 'textarea',
label: 'Description',
group: 'details',
},
status: {
type: 'select',
Expand All @@ -805,6 +818,7 @@ const ${toCamelCase(namespace)}Item = ObjectSchema.create({
{ label: 'Archived', value: 'archived' },
],
defaultValue: 'draft',
group: 'details',
},
},
// Org-wide default (OWD): who can see records they don't own. 'private' is
Expand Down
45 changes: 28 additions & 17 deletions packages/cli/test/generate-scaffold-gates.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,25 @@
* A CONTROL leg runs the three commands on the bare starter: zero findings,
* which is what makes a finding in any other leg one that `os g` caused.
*
* ## The one exemption, and why it is a ledger and not a filter
* ## The starter ledger — EMPTY since #21370, and shrink-only
*
* The bare starter is clean, but not because it has nothing to report: its
* own `note` object declares a `body` field that nothing reads, and
* `field-no-consumers` stays silent only while the stack has no consumer root
* at all (a stack of objects is judged to be another stack's object library).
* The first view, flow, action, app, dashboard or skill in the project — any
* of them, generated or hand-written — wakes that finding on the STARTER's
* object. It is the starter template's to fix (`os g` cannot give someone
* else's field a consumer), so it is recorded below, ⛔ never filtered by rule
* or by path pattern. The ledger is SHRINK-ONLY and holds itself honest:
* A bare starter can be clean without being clean: `field-no-consumers` stays
* silent while the stack has no consumer root at all (a stack of objects is
* judged to be another stack's object library), so a starter field nothing
* reads stays latent until the first view, flow, action, app, dashboard or
* skill in the project, generated or hand-written, wakes it on the STARTER's
* object. `os g` cannot give someone else's field a consumer, so such a
* finding is the starter template's to fix, and this file recorded the one it
* met in a ledger below, ⛔ never in a filter by rule or by path pattern. That
* entry was the starter note's `body`. #21370 placed every starter field in a
* keyed field group (`fieldGroups` + `group`), which the rule credits as
* displayed, and deleted it; `starter-field-consumers.test.ts` and its `.e2e`
* sibling hold every starter to that.
*
* The ledger is SHRINK-ONLY, so EMPTY is where it stays: a starter finding a
* leg meets is a template to fix, ⛔ never a line to add. Its floor is pinned
* below. The two checks that held each entry honest stay with it, in case a
* maintainer ever lifts that floor:
*
* - an entry must name a finding on an object the bare starter declares,
* so it can never cover what a scaffold wrote;
Expand Down Expand Up @@ -90,14 +98,10 @@ const PREREQUISITE_STEM: Record<string, string> = { object: 'gate_target', flow:
/**
* Findings the bare starter carries latently, by `rule@path`. SHRINK-ONLY —
* see the header: each must sit on an object the starter declares, and each
* must still fire somewhere, or this file is red.
* must still fire somewhere, or this file is red. EMPTY since #21370; keep it
* that way.
*/
const STARTER_LATENT_FINDINGS: Record<string, string> = {
'field-no-consumers@objects[0].fields.body':
"The starter's own `note` object declares `body` and nothing in the starter reads it. "
+ 'Silent while the stack has no consumer root; the first view, flow, action, app, '
+ 'dashboard or skill wakes it. The starter template owns the fix.',
};
const STARTER_LATENT_FINDINGS: Record<string, string> = {};

const GATES = ['validate', 'build', 'lint'] as const;
type Gate = (typeof GATES)[number];
Expand Down Expand Up @@ -248,6 +252,13 @@ describe('[#21325] every generator: fresh starter + `os g <kind>` → zero findi
});

describe('[#21325] the starter ledger cannot outlive its defect or cover a scaffold', () => {
it('[#21370] is at its floor: EMPTY — a starter finding is a template to fix, never a line to add', () => {
expect(
Object.keys(STARTER_LATENT_FINDINGS),
'STARTER_LATENT_FINDINGS is shrink-only and was emptied by #21370: fix the starter template instead',
).toEqual([]);
});

const fired = (key: string) =>
Object.entries(legs).flatMap(([leg, l]) =>
GATES.filter((gate) => l.gates[gate].findings?.includes(key)).map((gate) => ({ leg, gate })));
Expand Down
Loading
Loading