diff --git a/src/features/instance/applications/components/SchemaEditorView/FieldRow.tsx b/src/features/instance/applications/components/SchemaEditorView/FieldRow.tsx index 5a31b374e..0c52a97d5 100644 --- a/src/features/instance/applications/components/SchemaEditorView/FieldRow.tsx +++ b/src/features/instance/applications/components/SchemaEditorView/FieldRow.tsx @@ -41,12 +41,15 @@ export function FieldRow({ field, typeNames, readOnly, + disableRemove, onChange, onRemove, }: { field: FieldModel; typeNames: string[]; readOnly: boolean; + /** True when this is the table's only field: a GraphQL type needs at least one, so removal is blocked. */ + disableRemove?: boolean; onChange: (field: FieldModel) => void; onRemove: () => void; }) { @@ -107,9 +110,9 @@ export function FieldRow({ variant="destructiveGhost" size="icon" className="self-end" - disabled={readOnly} + disabled={readOnly || disableRemove} onClick={onRemove} - title="Remove field" + title={disableRemove ? 'A table needs at least one field' : 'Remove field'} > diff --git a/src/features/instance/applications/components/SchemaEditorView/TableCard.tsx b/src/features/instance/applications/components/SchemaEditorView/TableCard.tsx index bed3a4fe3..aa89fefd7 100644 --- a/src/features/instance/applications/components/SchemaEditorView/TableCard.tsx +++ b/src/features/instance/applications/components/SchemaEditorView/TableCard.tsx @@ -89,6 +89,7 @@ export function TableCard({ {fieldCount} field{fieldCount === 1 ? '' : 's'} {exported ? ' · REST' : ''} {nameError ? ' · ⚠ invalid name' : ''} + {fieldCount === 0 ? ' · ⚠ needs a field' : ''} Fields + {fieldCount === 0 && ( + + A table needs at least one field to be valid. Add one below. + + )} {table.fields.map((field, index) => ( onChange({ ...table, fields: table.fields.map((f, i) => i === index ? next : f) })} onRemove={() => onChange({ ...table, fields: table.fields.filter((_, i) => i !== index) })} diff --git a/src/features/instance/applications/lib/schema/mutations.ts b/src/features/instance/applications/lib/schema/mutations.ts index b65954aaa..7b515a26f 100644 --- a/src/features/instance/applications/lib/schema/mutations.ts +++ b/src/features/instance/applications/lib/schema/mutations.ts @@ -53,6 +53,7 @@ export function createTable(id: string): TableModel { directives: [{ name: 'primaryKey', args: [], hadParens: false }], }, ], + trailingComments: [], raw: '', edited: true, }; diff --git a/src/features/instance/applications/lib/schema/parseSchema.test.ts b/src/features/instance/applications/lib/schema/parseSchema.test.ts index 1ca3e62a8..0f9dbf9ec 100644 --- a/src/features/instance/applications/lib/schema/parseSchema.test.ts +++ b/src/features/instance/applications/lib/schema/parseSchema.test.ts @@ -57,6 +57,14 @@ describe('parseSchema round-trip fidelity', () => { ); }); + it('round-trips a comment trailing the last field, before the closing brace', () => { + expectRoundTrip('type Dog @table {\n\tid: ID @primaryKey\n\t# trailing note about the table\n}\n'); + }); + + it('round-trips a comment on the type header line, after the opening brace', () => { + expectRoundTrip('type Dog @table { # the dogs table\n\tid: ID @primaryKey\n}\n'); + }); + it('leaves an empty document empty', () => { expectRoundTrip(''); }); @@ -98,6 +106,24 @@ describe('parseSchema structure', () => { expect(t.fields[0].leadingComments).toEqual(['# the id']); expect(t.fields[0].lineComment).toBe('# inline'); }); + + it('captures a comment trailing the last field as trailingComments, not a leading comment', () => { + const [t] = tables('type T @table {\n\tid: ID @primaryKey\n\t# trailing note\n}\n'); + expect(t.fields[0].leadingComments).toEqual([]); + expect(t.trailingComments).toEqual(['# trailing note']); + }); + + it('captures a header-line comment separately from the first field', () => { + const [t] = tables('type T @table { # header note\n\tid: ID @primaryKey\n}\n'); + expect(t.headerComment).toBe('# header note'); + expect(t.fields[0].leadingComments).toEqual([]); + }); + + it("keeps a comment on its own line before the first field as that field's leading comment", () => { + const [t] = tables('type T @table {\n\t# not a header comment\n\tid: ID @primaryKey\n}\n'); + expect(t.headerComment).toBeUndefined(); + expect(t.fields[0].leadingComments).toEqual(['# not a header comment']); + }); }); describe('parseSchema failure handling', () => { diff --git a/src/features/instance/applications/lib/schema/parseSchema.ts b/src/features/instance/applications/lib/schema/parseSchema.ts index 545b004df..2c6ba5f7b 100644 --- a/src/features/instance/applications/lib/schema/parseSchema.ts +++ b/src/features/instance/applications/lib/schema/parseSchema.ts @@ -347,8 +347,12 @@ function skipInlineWhitespace(src: string, i: number): number { return j; } -/** Parse the fields between a type body's braces; return null if anything is malformed. */ -function parseFields(body: string): FieldModel[] | null { +/** + * Parse the fields between a type body's braces; return null if anything is + * malformed. Comment/description lines that trail the last field (no field + * follows them) are returned as `trailingComments` so they survive regeneration. + */ +function parseFields(body: string): { fields: FieldModel[]; trailingComments: string[] } | null { const fields: FieldModel[] = []; let pendingComments: string[] = []; let i = 0; @@ -429,7 +433,26 @@ function parseFields(body: string): FieldModel[] | null { }); pendingComments = []; } - return fields; + // Anything still pending never found a field to attach to — it's trailing + // trivia inside the body (after the last field, before `}`). + return { fields, trailingComments: pendingComments }; +} + +/** + * Peel a header-line `# …` comment off the body. `body` is the text after the + * opening `{`, so its first line is the remainder of the `type … {` line; if that + * line is a comment it belongs to the header, not the first field. + */ +function splitHeaderComment(body: string): { headerComment?: string; body: string } { + const newlineIndex = body.indexOf('\n'); + if (newlineIndex < 0) { + return { body }; + } + const firstLine = body.slice(0, newlineIndex); + if (firstLine.replace(/^[ \t,]+/, '').startsWith('#')) { + return { headerComment: firstLine.replace(/\r$/, '').trim(), body: body.slice(newlineIndex + 1) }; + } + return { body }; } /* -------------------------------------------------------------------------- */ @@ -459,11 +482,22 @@ function parseTypeBlock(blockSrc: string, id: string): TableModel | null { if (bodyEnd < braceStart) { return null; } - const fields = parseFields(blockSrc.slice(braceStart + 1, bodyEnd)); - if (!fields) { + const { headerComment, body } = splitHeaderComment(blockSrc.slice(braceStart + 1, bodyEnd)); + const parsed = parseFields(body); + if (!parsed) { return null; } - return { id, leading: '', typeName, directives, fields, raw: blockSrc, edited: false }; + return { + id, + leading: '', + typeName, + headerComment, + directives, + fields: parsed.fields, + trailingComments: parsed.trailingComments, + raw: blockSrc, + edited: false, + }; } /** diff --git a/src/features/instance/applications/lib/schema/serializeSchema.test.ts b/src/features/instance/applications/lib/schema/serializeSchema.test.ts index 8b630c31e..d0e233c76 100644 --- a/src/features/instance/applications/lib/schema/serializeSchema.test.ts +++ b/src/features/instance/applications/lib/schema/serializeSchema.test.ts @@ -100,6 +100,40 @@ describe('serializeSchema description (""") handling', () => { }); }); +describe('serializeSchema preserves in-body comments when a table is edited', () => { + it('keeps a comment trailing the last field instead of dropping it on edit', () => { + const source = 'type Dog @table {\n\tid: ID @primaryKey\n\t# trailing note about the table\n}\n'; + const doc = parse(source); + firstTable(doc).edited = true; + expect(serializeSchema(doc)).toBe(source); + }); + + it('keeps a header-line comment on the header line instead of relocating it on edit', () => { + const source = 'type Dog @table { # the dogs table\n\tid: ID @primaryKey\n}\n'; + const doc = parse(source); + firstTable(doc).edited = true; + expect(serializeSchema(doc)).toBe(source); + }); + + it('preserves a comments-only table body on edit (does not silently drop the comment)', () => { + const source = 'type Dog @table {\n\t# just a note, no fields yet\n}\n'; + const doc = parse(source); + firstTable(doc).edited = true; + expect(serializeSchema(doc)).toContain('# just a note, no fields yet'); + }); + + it('re-editing a table with a trailing multi-line description block stays idempotent', () => { + const source = 'type Dog @table {\n\tid: ID @primaryKey\n\t"""\n\tA trailing note.\n\tOn two lines.\n\t"""\n}\n'; + const doc = parse(source); + firstTable(doc).edited = true; + const once = serializeSchema(doc); + expect(once).toBe(source); + const twice = parse(once); + firstTable(twice).edited = true; + expect(serializeSchema(twice)).toBe(source); + }); +}); + describe('serializeSchema canonical generation for edited tables', () => { it('regenerates directives and args in canonical order, preserving unknown ones', () => { const doc = parse( diff --git a/src/features/instance/applications/lib/schema/serializeSchema.ts b/src/features/instance/applications/lib/schema/serializeSchema.ts index 039c5a3c9..b2355bf86 100644 --- a/src/features/instance/applications/lib/schema/serializeSchema.ts +++ b/src/features/instance/applications/lib/schema/serializeSchema.ts @@ -60,11 +60,24 @@ function serializeDirectives(directives: Directive[], order: string[]): string { .join(' '); } +/** Re-indent a stored comment/description block to the canonical indent, one line at a time. */ +function indentCommentLines(comment: string, indent: string): string[] { + return comment.split('\n').map(rawLine => { + // Strip any indentation the line already carried (e.g. interior lines of a + // multi-line """description""") before applying the canonical indent, so + // re-editing a table doesn't compound the indentation each time. Blank + // lines stay blank. + const line = rawLine.replace(/\r$/, '').replace(/^[ \t]+/, ''); + return line ? `${indent}${line}` : ''; + }); +} + /** Generate the canonical `type … { … }` text for an edited or new table. */ function generateTable(table: TableModel, doc: SchemaDocument): string { const { indent, newline } = doc; const directives = serializeDirectives(table.directives, TABLE_DIRECTIVE_ORDER); - const lines: string[] = [`type ${table.typeName}${directives ? ` ${directives}` : ''} {`]; + const header = `type ${table.typeName}${directives ? ` ${directives}` : ''} {`; + const lines: string[] = [table.headerComment ? `${header} ${table.headerComment}` : header]; for (const field of table.fields) { // An added-but-unnamed field would emit invalid SDL (`\t: String`); skip it @@ -73,20 +86,19 @@ function generateTable(table: TableModel, doc: SchemaDocument): string { continue; } for (const comment of field.leadingComments) { - for (const rawLine of comment.split('\n')) { - // Strip any indentation the line already carried (e.g. interior lines of a - // multi-line """description""") before applying the canonical indent, so - // re-editing a table doesn't compound the indentation each time. Blank - // lines stay blank. - const line = rawLine.replace(/\r$/, '').replace(/^[ \t]+/, ''); - lines.push(line ? `${indent}${line}` : ''); - } + lines.push(...indentCommentLines(comment, indent)); } const fieldDirectives = serializeDirectives(field.directives, FIELD_DIRECTIVE_ORDER); const suffix = [fieldDirectives, field.lineComment].filter(Boolean).join(' '); lines.push(`${indent}${field.name}: ${formatTypeRef(field.type)}${suffix ? ` ${suffix}` : ''}`); } + // Comments that trailed the last field, before `}` — preserved so an edit + // doesn't silently drop them. + for (const comment of table.trailingComments) { + lines.push(...indentCommentLines(comment, indent)); + } + return `${lines.join(newline)}${newline}}`; } diff --git a/src/features/instance/applications/lib/schema/types.ts b/src/features/instance/applications/lib/schema/types.ts index 1753b61af..5d83b23a1 100644 --- a/src/features/instance/applications/lib/schema/types.ts +++ b/src/features/instance/applications/lib/schema/types.ts @@ -80,8 +80,21 @@ export interface TableModel { */ leading: string; typeName: string; + /** + * A `# …` comment written on the type's header line, right after the opening + * brace (e.g. `type Dog @table { # note`). Captured so it isn't relocated to a + * standalone line above the first field when the table is regenerated. + */ + headerComment?: string; directives: Directive[]; fields: FieldModel[]; + /** + * Comment (`# …`) and description (`"""…"""`) lines that sit inside the body + * after the last field, before the closing `}` — each captured verbatim + * (without the structural indent). Re-emitted before `}` when the table is + * regenerated so trailing trivia isn't silently dropped on edit. + */ + trailingComments: string[]; /** * The exact original source of `type … { … }` (excluding {@link leading}). * Emitted verbatim while {@link edited} is false, guaranteeing untouched
+ A table needs at least one field to be valid. Add one below. +