diff --git a/packages/admin/src/lib/builder/settings-to-manifest.test.ts b/packages/admin/src/lib/builder/settings-to-manifest.test.ts new file mode 100644 index 0000000..411b10a --- /dev/null +++ b/packages/admin/src/lib/builder/settings-to-manifest.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + collectionEntityFromSettings, + singleEntityFromSettings, +} from "./settings-to-manifest"; + +const FIELDS = [{ name: "headline", type: "text", required: false }]; + +describe("collectionEntityFromSettings", () => { + it("forwards status: true into the manifest entity", () => { + const entity = collectionEntityFromSettings( + "posts", + { + singularName: "Post", + pluralName: "Posts", + slug: "posts", + icon: "Database", + status: true, + }, + FIELDS + ); + expect(entity.status).toBe(true); + expect(entity.slug).toBe("posts"); + expect(entity.labels).toEqual({ singular: "Post", plural: "Posts" }); + expect(entity.fields.map(f => f.name)).toEqual(["headline"]); + }); + + it("writes status: false explicitly so Draft/Published can be turned off", () => { + const entity = collectionEntityFromSettings( + "posts", + { + singularName: "Post", + pluralName: "Posts", + slug: "posts", + icon: "Database", + status: false, + }, + FIELDS + ); + expect(entity.status).toBe(false); + }); + + it("coerces an absent status to false (never undefined)", () => { + const entity = collectionEntityFromSettings( + "posts", + { + singularName: "Post", + pluralName: "Posts", + slug: "posts", + icon: "Database", + }, + FIELDS + ); + expect(entity.status).toBe(false); + }); +}); + +describe("singleEntityFromSettings", () => { + it("forwards status: true and sets single labels from singularName", () => { + const entity = singleEntityFromSettings( + "home_hero", + { + singularName: "Home Hero", + slug: "home_hero", + icon: "FileText", + status: true, + }, + FIELDS + ); + expect(entity.status).toBe(true); + expect(entity.labels).toEqual({ + singular: "Home Hero", + plural: "Home Hero", + }); + }); + + it("writes status: false explicitly", () => { + const entity = singleEntityFromSettings( + "home_hero", + { + singularName: "Home Hero", + slug: "home_hero", + icon: "FileText", + status: false, + }, + FIELDS + ); + expect(entity.status).toBe(false); + }); +}); diff --git a/packages/admin/src/lib/builder/settings-to-manifest.ts b/packages/admin/src/lib/builder/settings-to-manifest.ts new file mode 100644 index 0000000..ad112f4 --- /dev/null +++ b/packages/admin/src/lib/builder/settings-to-manifest.ts @@ -0,0 +1,49 @@ +/** + * Map the builder's settings snapshot (`BuilderSettingsValues`) → a complete + * `ui-schema.json` `ManifestEntity`. The single chokepoint both edit pages use + * from BOTH their save paths (field-change + settings-only), so the + * Draft/Published `status` flag can never again be dropped on the way to + * ui-schema.json. `status` is passed explicitly (true OR false, never + * undefined) so the dev-write full-replace can turn the lifecycle back off. + * + * @module lib/builder/settings-to-manifest + */ +import type { BuilderSettingsValues } from "../../components/features/schema-builder"; + +import { + collectionToManifestEntity, + type BuilderFieldInput, + type ManifestEntity, +} from "./to-manifest-entity"; +import { singleToManifestEntity } from "./to-manifest-entity-single"; + +export function collectionEntityFromSettings( + slug: string, + settings: BuilderSettingsValues, + fields: BuilderFieldInput[] +): ManifestEntity { + return collectionToManifestEntity({ + slug, + settings: { + singularName: settings.singularName, + pluralName: settings.pluralName, + status: settings.status === true, + }, + fields, + }); +} + +export function singleEntityFromSettings( + slug: string, + settings: BuilderSettingsValues, + fields: BuilderFieldInput[] +): ManifestEntity { + return singleToManifestEntity({ + slug, + settings: { + singularName: settings.singularName, + status: settings.status === true, + }, + fields, + }); +} diff --git a/packages/admin/src/pages/dashboard/collections/builder/[slug].tsx b/packages/admin/src/pages/dashboard/collections/builder/[slug].tsx index d77b2c3..74d4e98 100644 --- a/packages/admin/src/pages/dashboard/collections/builder/[slug].tsx +++ b/packages/admin/src/pages/dashboard/collections/builder/[slug].tsx @@ -60,7 +60,7 @@ import { countDirtyFields } from "@admin/lib/builder/dirty-tracking"; import { nextDuplicateName } from "@admin/lib/builder/duplicate-field-name"; import { isInsideRepeatingAncestor } from "@admin/lib/builder/is-inside-repeating-ancestor"; import { packIntoRows, parseWidth } from "@admin/lib/builder/reflow"; -import { collectionToManifestEntity } from "@admin/lib/builder/to-manifest-entity"; +import { collectionEntityFromSettings } from "@admin/lib/builder/settings-to-manifest"; import { COLLECTION_BUILDER_CONFIG } from "@admin/pages/dashboard/collections/builder/builder-config"; import { schemaApi, @@ -310,15 +310,11 @@ export default function CollectionBuilderEditPage({ // so the entity has a migration record (matches code-first). A // failure here must NOT undo the already-successful DB apply. try { - const entity = collectionToManifestEntity({ - slug, - settings: { - singularName: settings?.singularName, - pluralName: settings?.pluralName, - }, - fields: fieldDefinitions, - }); - await schemaFileApi.writeCollection(entity); + if (settings) { + await schemaFileApi.writeCollection( + collectionEntityFromSettings(slug, settings, fieldDefinitions) + ); + } } catch (err) { const m = (err as { message?: string })?.message; toast.warning( @@ -380,6 +376,22 @@ export default function CollectionBuilderEditPage({ // disables again immediately after a successful save. setOriginalSettings(settings); setOriginalFields(builder.fields.filter(f => !f.isSystem)); + // Mirror the settings change (notably Draft/Published) into the + // committable ui-schema.json. Best-effort: the DB write already + // succeeded, so a file-write failure only warns. Void IIFE keeps + // the mutation callback synchronous. + void (async () => { + try { + await schemaFileApi.writeCollection( + collectionEntityFromSettings(slug, settings, fieldDefinitions) + ); + } catch (err) { + const m = (err as { message?: string })?.message; + toast.warning( + `Collection updated, but ui-schema.json could not be updated${m ? `: ${m}` : ""}.` + ); + } + })(); }, onError: err => { const errorObj = err as { message?: string }; diff --git a/packages/admin/src/pages/dashboard/singles/builder/[slug].tsx b/packages/admin/src/pages/dashboard/singles/builder/[slug].tsx index 91bab53..26e33a3 100644 --- a/packages/admin/src/pages/dashboard/singles/builder/[slug].tsx +++ b/packages/admin/src/pages/dashboard/singles/builder/[slug].tsx @@ -47,16 +47,16 @@ import { reorderNestedFields, } from "@admin/lib/builder"; import { countDirtyFields } from "@admin/lib/builder/dirty-tracking"; -import { singleToManifestEntity } from "@admin/lib/builder/to-manifest-entity-single"; -import { schemaFileApi } from "@admin/services/schemaFileApi"; import { nextDuplicateName } from "@admin/lib/builder/duplicate-field-name"; import { isInsideRepeatingAncestor } from "@admin/lib/builder/is-inside-repeating-ancestor"; import { packIntoRows, parseWidth } from "@admin/lib/builder/reflow"; +import { singleEntityFromSettings } from "@admin/lib/builder/settings-to-manifest"; import type { FieldResolution, SchemaPreviewResponse, SchemaRenameResolution, } from "@admin/services/schemaApi"; +import { schemaFileApi } from "@admin/services/schemaFileApi"; import { singleApi } from "@admin/services/singleApi"; import type { FieldDefinition } from "@admin/types/collection"; import type { ApiSingle } from "@admin/types/entities"; @@ -238,12 +238,11 @@ export default function SingleBuilderEditPage({ // D-series: database mode also writes the committable ui-schema.json // so the single has a migration record. Non-fatal if it fails. try { - const entity = singleToManifestEntity({ - slug, - settings: { singularName: settings?.singularName }, - fields: fieldDefinitions, - }); - await schemaFileApi.writeSingle(entity); + if (settings) { + await schemaFileApi.writeSingle( + singleEntityFromSettings(slug, settings, fieldDefinitions) + ); + } } catch (err) { const m = (err as { message?: string })?.message; toast.warning( @@ -301,6 +300,20 @@ export default function SingleBuilderEditPage({ setOriginalFields(builder.fields.filter(f => !f.isSystem)); // Re-pin settings baseline so the Save button disables again. setOriginalSettings(settings); + // Mirror the settings change (notably Draft/Published) into the + // committable ui-schema.json. Best-effort; DB write already done. + void (async () => { + try { + await schemaFileApi.writeSingle( + singleEntityFromSettings(slug, settings, fieldDefinitions) + ); + } catch (err) { + const m = (err as { message?: string })?.message; + toast.warning( + `Single updated, but ui-schema.json could not be updated${m ? `: ${m}` : ""}.` + ); + } + })(); }, onError: err => { const errorObj = err as { message?: string }; diff --git a/packages/nextly/src/domains/schema/ui-schema/merge.test.ts b/packages/nextly/src/domains/schema/ui-schema/merge.test.ts index 7b72113..adab7df 100644 --- a/packages/nextly/src/domains/schema/ui-schema/merge.test.ts +++ b/packages/nextly/src/domains/schema/ui-schema/merge.test.ts @@ -67,4 +67,25 @@ describe("mergeUiEntities", () => { expect(r.singles[0].tableName).toBe("single_home"); expect(r.components[0].tableName).toBe("comp_seo"); }); + + it("forwards a UI collection's status: true into the merged MinimalConfigEntity", () => { + const manifest = uiSchemaManifest.parse({ + collections: [ + { + slug: "stories", + status: true, + fields: [{ name: "body", type: "text" }], + }, + ], + }); + const merged = mergeUiEntities({ + codeCollections: [], + codeSingles: [], + codeComponents: [], + manifest, + }); + const stories = merged.collections.find(c => c.slug === "stories"); + expect(stories?.status).toBe(true); + expect(stories?.tableName).toBe("dc_stories"); + }); }); diff --git a/packages/nextly/src/domains/schema/ui-schema/mutate.test.ts b/packages/nextly/src/domains/schema/ui-schema/mutate.test.ts index ca54ad9..8fcd322 100644 --- a/packages/nextly/src/domains/schema/ui-schema/mutate.test.ts +++ b/packages/nextly/src/domains/schema/ui-schema/mutate.test.ts @@ -85,4 +85,42 @@ describe("mutateManifest", () => { }); expect(withComponent.components.map(c => c.slug)).toEqual(["seo"]); }); + + it("rejects a structurally-partial upsert (missing fields) — Zod guards full-replace", () => { + expect(() => + mutateManifest(base, { + type: "upsert", + kind: "collections", + entity: { slug: "events" }, // no `fields` — would clobber on replace + }) + ).toThrowError( + expect.objectContaining({ code: "NEXTLY_UI_SCHEMA_INVALID" }) + ); + // The original entity is untouched (mutateManifest is pure; caller writes nothing). + expect(base.collections[0].fields.map(f => f.name)).toEqual(["title"]); + }); + + it("allows turning status off via a complete upsert (full-replace preserves unset)", () => { + const withStatus = mutateManifest(base, { + type: "upsert", + kind: "collections", + entity: { + slug: "events", + status: true, + fields: [{ name: "title", type: "text" }], + }, + }); + expect(withStatus.collections[0].status).toBe(true); + + const off = mutateManifest(withStatus, { + type: "upsert", + kind: "collections", + entity: { + slug: "events", + status: false, + fields: [{ name: "title", type: "text" }], + }, + }); + expect(off.collections[0].status).toBe(false); + }); }); diff --git a/packages/nextly/src/domains/schema/ui-schema/mutate.ts b/packages/nextly/src/domains/schema/ui-schema/mutate.ts index 97a6e2c..332ae87 100644 --- a/packages/nextly/src/domains/schema/ui-schema/mutate.ts +++ b/packages/nextly/src/domains/schema/ui-schema/mutate.ts @@ -49,6 +49,13 @@ export function mutateManifest( const slug = slugOf(mutation.entity); const idx = slug === undefined ? -1 : list.findIndex(e => slugOf(e) === slug); + // Full-replace by slug is intentional: callers (the admin builder via + // settings-to-manifest.ts) send a COMPLETE entity, so replacing lets a + // user unset an optional flag (e.g. turn Draft/Published off). We do not + // merge with the stored entity — a shallow merge would make unsetting + // impossible (an omitted key would silently retain the old value). + // Structurally-partial entities (missing slug/fields) are already + // rejected by the Zod re-validation below (NEXTLY_UI_SCHEMA_INVALID). if (idx >= 0) list[idx] = mutation.entity; else list.push(mutation.entity); } else {