diff --git a/evals/edit-models-precisely.eval.ts b/evals/edit-models-precisely.eval.ts index 7a7fc9ad..19c25153 100644 --- a/evals/edit-models-precisely.eval.ts +++ b/evals/edit-models-precisely.eval.ts @@ -51,6 +51,76 @@ it.for(trials)( }, ); +it.for(trials)("upgrades a legacy slice", async (_, { project, agent, expect }) => { + const page = buildCustomType({ + id: "page", + label: "Page", + json: { + Main: { + slices: { + type: "Slices", + fieldset: "Slice Zone", + config: { + choices: { + hero: { + type: "Slice", + fieldset: "Hero", + "non-repeat": { title: { type: "Text", config: { label: "Title" } } }, + repeat: {}, + }, + }, + }, + }, + }, + }, + }); + await writeLocalCustomType(project, page); + + const result = await agent(`Upgrade the legacy slices in the "page" type.`); + + expect(result).toHaveRun(["slice", "upgrade-legacy", "hero"]); + const slice = await readLocalSlice(project, "hero"); + expect(slice?.legacyPaths).toEqual({ "page::slices::hero": "default" }); + const model = await readLocalCustomType(project, page.id); + expect(model.json.Main.slices).toMatchObject({ + config: { choices: { hero: { type: "SharedSlice" } } }, + }); +}); + +it.for(trials)("asks before combining legacy slices", async (_, { project, agent, expect }) => { + const hero = { + type: "Slice", + fieldset: "Hero", + "non-repeat": { title: { type: "Text", config: { label: "Title" } } }, + repeat: {}, + } as const; + for (const id of ["blog_post", "landing_page"]) { + await writeLocalCustomType( + project, + buildCustomType({ + id, + json: { Main: { body: { type: "Slices", config: { choices: { hero } } } } }, + }), + ); + } + + const result = await agent(`Upgrade all the legacy slices.`); + + await expect(result.text).toSatisfyJudge( + `Asks the user whether the two "hero" legacy slices should become one slice or separate slices, and offers the options, instead of choosing on its own.`, + ); + const upgraded = await readLocalSlice(project, "hero"); + expect(Object.keys(upgraded?.legacyPaths ?? {}).length).toBeLessThan(2); + + await result.continue("Combine them into one slice."); + + const slice = await readLocalSlice(project, "hero"); + expect(Object.keys(slice?.legacyPaths ?? {}).sort()).toEqual([ + "blog_post::body::hero", + "landing_page::body::hero", + ]); +}); + // The CLI cannot rename a field ID; `field edit` only changes label and config. it.todo("renames a field without disturbing field order", async ({ project, agent, expect }) => { const article = buildCustomType({ diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 1f765c0a..febb620c 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -187,8 +187,10 @@ export abstract class Adapter { const customTypes = (await this.getCustomTypes()).map((customType) => customType.model); const legacySlice = getLegacySlices(customTypes).find((s) => s.id === id); if (legacySlice) { + const { customTypeId, sliceZoneId } = legacySlice; + const zoneOption = sliceZoneId === "body" ? "" : ` --slice-zone ${sliceZoneId}`; throw new CommandError( - `"${id}" is a legacy slice in "${legacySlice.customTypeId}". The CLI cannot edit legacy slices.`, + `"${id}" is a legacy slice in "${customTypeId}". Upgrade it first: \`prismic slice upgrade-legacy ${id} --from ${customTypeId}${zoneOption}\`.`, ); } throw new Error(`No slice found with ID: ${id}`); diff --git a/src/commands/init.ts b/src/commands/init.ts index 20856975..faf8c992 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -22,7 +22,7 @@ import { } from "../lib/prismic/clients/core"; import { getRepository, type Repository } from "../lib/prismic/clients/repository"; import { getProfile } from "../lib/prismic/clients/user"; -import { diffModels, getRemoteModels } from "../lib/prismic/models"; +import { diffModels, getLegacySlices, getRemoteModels } from "../lib/prismic/models"; import { completeOnboardingSteps } from "../lib/prismic/onboarding"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; import { sentryCaptureError } from "../lib/sentry"; @@ -283,6 +283,10 @@ export default createCommand(config, async ({ values }) => { await adapter.generateTypes(); + if (getLegacySlices(remote.customTypes).length > 0) { + console.info("\nRun `prismic slice list --legacy` to find legacy slices to upgrade."); + } + if (hasStarterModelChanges) { console.warn( dedent` diff --git a/src/commands/slice-list.ts b/src/commands/slice-list.ts index 1d2a8a14..6bb0128d 100644 --- a/src/commands/slice-list.ts +++ b/src/commands/slice-list.ts @@ -59,6 +59,6 @@ export default createCommand(config, async ({ values }) => { ]; console.info(formatTable(rows, { headers: ["NAME", "ID", "DEFINED IN"] })); if (legacySlices.length > 0) { - console.info("\n* Legacy slice. The CLI cannot edit legacy slices."); + console.info("\n* Legacy slice. Run `prismic slice upgrade-legacy --help` to upgrade it."); } }); diff --git a/src/commands/slice-upgrade-legacy.ts b/src/commands/slice-upgrade-legacy.ts new file mode 100644 index 00000000..4d703af5 --- /dev/null +++ b/src/commands/slice-upgrade-legacy.ts @@ -0,0 +1,237 @@ +import { isDeepStrictEqual } from "node:util"; + +import type { SharedSliceModel, SharedSliceModelVariation } from "@prismicio/types-internal"; +import { camelCase, pascalCase, snakeCase } from "change-case"; + +import { getAdapter } from "../adapters"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { getLegacySlices, type LegacySlice } from "../lib/prismic/models"; +import { relativePathname } from "../lib/url"; +import { findProjectRoot } from "../project"; + +const config = { + name: "prismic slice upgrade-legacy", + description: ` + Upgrade a legacy slice from the Legacy Builder. + + The CLI and the Type Builder cannot edit legacy slices. Run + \`prismic slice list --legacy\` to list them. + + Each legacy slice becomes its own slice unless --to names an existing + slice, which gets it as a new variation. Legacy slices with the same ID + in several types are often the same component. Combining them is a content + modeling decision for the project's owner, since it changes how editors pick + slices. Get their choice before upgrading them. + + Only local models change. After \`prismic push\`, Prismic returns the + slice's content in its upgraded shape, so deploy the updated component + with the push. + `, + sections: { + EXAMPLES: ` + Upgrade a legacy slice: + prismic slice upgrade-legacy hero --from blog_post + + Upgrade a legacy slice that is not in the "body" slice zone: + prismic slice upgrade-legacy hero --from blog_post --slice-zone page_slices + + Add a legacy slice to an existing slice as a new variation: + prismic slice upgrade-legacy hero --from landing_page --to hero --variation landing + + Merge a legacy slice into an existing variation with the same fields: + prismic slice upgrade-legacy hero --from landing_page --to hero --variation default + `, + }, + positionals: { + id: { description: "ID of the legacy slice", required: true }, + }, + options: { + from: { + type: "string", + required: true, + description: "ID of the type that contains the legacy slice", + }, + "slice-zone": { + type: "string", + description: 'Slice zone field ID (default: "body")', + }, + to: { + type: "string", + description: + "ID of the slice to upgrade to, created if missing (default: the legacy slice ID)", + }, + variation: { + type: "string", + description: + 'Variation to create, or to merge into if it has the same fields (default: "default" for a new slice, the legacy slice ID otherwise)', + }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { from, "slice-zone": sliceZoneId = "body", to, variation } = values; + + const adapter = await getAdapter(); + const customTypes = (await adapter.getCustomTypes()).map((customType) => customType.model); + const slices = (await adapter.getSlices()).map((slice) => slice.model); + const legacySlices = getLegacySlices(customTypes); + + const customType = customTypes.find((ct) => ct.id === from); + if (!customType) { + throw new CommandError(`Type "${from}" not found.`); + } + const legacySlice = legacySlices.find( + (s) => s.customTypeId === from && s.sliceZoneId === sliceZoneId && s.id === id, + ); + if (!legacySlice) { + throw new CommandError( + `Legacy slice "${id}" not found in the "${sliceZoneId}" slice zone of "${from}". Use --slice-zone to choose the slice zone.`, + ); + } + const legacyPath = `${from}::${sliceZoneId}::${id}`; + + const sliceId = to ?? snakeCase(id); + const zoneChoice = legacySlice.sliceZone.config?.choices?.[sliceId]; + if (sliceId !== id && zoneChoice && zoneChoice.type !== "SharedSlice") { + throw new CommandError( + `The "${sliceZoneId}" slice zone of "${from}" also has legacy slice "${sliceId}". Upgrade it first.`, + ); + } + let slice = slices.find((s) => s.id === sliceId); + const variationId = camelCase(variation ?? (slice ? id : "default")); + const upgraded = toVariation(legacySlice, variationId); + let summary: string; + if (slice) { + if (!to) { + throw new CommandError(getSliceExistsMessage(slice, legacySlice, upgraded)); + } + const target = slice.variations.find((v) => v.id === variationId); + if (target && !hasSameFields(target, upgraded)) { + throw new CommandError( + `Variation "${variationId}" of slice "${sliceId}" has different fields than legacy slice "${id}". Use another ID with --variation.`, + ); + } + if (target) { + summary = `Merged legacy slice "${id}" into variation "${variationId}" of slice "${sliceId}"`; + } else { + slice.variations.push(upgraded); + summary = `Added legacy slice "${id}" to slice "${sliceId}" as variation "${variationId}"`; + } + slice.legacyPaths = { ...slice.legacyPaths, [legacyPath]: variationId }; + await adapter.updateSlice(slice); + } else { + const { model } = legacySlice; + const name = "fieldset" in model ? model.fieldset : undefined; + slice = { + id: sliceId, + type: "SharedSlice", + name: pascalCase(to ?? name ?? sliceId), + legacyPaths: { [legacyPath]: variationId }, + variations: [upgraded], + }; + summary = `Upgraded legacy slice "${id}" to slice "${sliceId}"`; + await adapter.createSlice(slice); + } + + replaceChoice(legacySlice, slice.id); + await adapter.updateCustomType(customType); + await adapter.generateTypes(); + + const { directory } = await adapter.getSlice(slice.id); + const componentPath = relativePathname(await findProjectRoot(), directory); + const remaining = legacySlices.length - 1; + + console.info(summary); + console.info(`After \`prismic push\`: ${getContentChange(legacySlice, sliceId, variationId)}`); + console.info( + `Update the component in ${componentPath}, then deploy it with \`prismic push\`. Documents use the new shape after the next publish.`, + ); + if (remaining > 0) { + console.info( + `\n${remaining} legacy ${remaining === 1 ? "slice remains" : "slices remain"}. Run \`prismic slice list --legacy\` to list them.`, + ); + } +}); + +// Upgrading into an existing slice changes how editors use it, so list the choices instead of picking one. +function getSliceExistsMessage( + slice: SharedSliceModel, + legacySlice: LegacySlice, + upgraded: SharedSliceModelVariation, +) { + const { id, customTypeId, sliceZoneId } = legacySlice; + const zoneOption = sliceZoneId === "body" ? "" : ` --slice-zone ${sliceZoneId}`; + const command = `prismic slice upgrade-legacy ${id} --from ${customTypeId}${zoneOption}`; + const sameFields = slice.variations.find((v) => hasSameFields(v, upgraded)); + return [ + `Slice "${slice.id}" already exists. How do you want to upgrade legacy slice "${id}" of "${customTypeId}"? This changes how editors pick slices, so the project's owner should choose:`, + ` Add it to slice "${slice.id}" as a new variation: ${command} --to ${slice.id}`, + ...(sameFields + ? [ + ` Merge it into variation "${sameFields.id}", which has the same fields: ${command} --to ${slice.id} --variation ${sameFields.id}`, + ] + : []), + ` Upgrade it to a separate slice: ${command} --to `, + ].join("\n"); +} + +function getContentChange(legacySlice: LegacySlice, sliceId: string, variationId: string) { + const { model, id } = legacySlice; + const changes = []; + if (sliceId !== id || variationId !== "default") { + changes.push(`\`slice_type\` becomes "${sliceId}" and \`variation\` becomes "${variationId}".`); + } + if (model.type === "Group") changes.push("`slice.value` moves to `slice.items`."); + else if (model.type !== "Slice") + changes.push(`\`slice.value\` moves to \`slice.primary.${id}\`.`); + return changes.join(" ") || "`slice.primary` and `slice.items` keep their shape."; +} + +// Mirrors Slice Machine's legacy slice upgrader so Prismic reads existing +// content the same way through `legacyPaths`. +function toVariation(legacySlice: LegacySlice, id: string): SharedSliceModelVariation { + const { model } = legacySlice; + const name = id === "default" ? "Default" : pascalCase(id); + const variation: SharedSliceModelVariation = { + id, + name, + description: name, + docURL: "", + imageUrl: "", + version: "initial", + primary: {}, + items: {}, + }; + + switch (model.type) { + case "Slice": + variation.primary = model["non-repeat"] ?? {}; + variation.items = model.repeat ?? {}; + break; + case "Group": + variation.items = model.config?.fields ?? {}; + break; + default: + variation.primary = { [legacySlice.id]: model }; + break; + } + + return variation; +} + +function hasSameFields(a: SharedSliceModelVariation, b: SharedSliceModelVariation): boolean { + return ( + isDeepStrictEqual(a.primary ?? {}, b.primary ?? {}) && + isDeepStrictEqual(a.items ?? {}, b.items ?? {}) + ); +} + +// Keeps the slice at the same position in the zone. Choice order is slice order. +function replaceChoice({ id, sliceZone }: LegacySlice, sliceId: string) { + sliceZone.config!.choices = Object.fromEntries( + Object.entries(sliceZone.config!.choices!) + .filter(([key]) => key === id || key !== sliceId) + .map(([key, choice]) => (key === id ? [sliceId, { type: "SharedSlice" }] : [key, choice])), + ); +} diff --git a/src/commands/slice.ts b/src/commands/slice.ts index 6fe93f76..47823570 100644 --- a/src/commands/slice.ts +++ b/src/commands/slice.ts @@ -8,6 +8,7 @@ import sliceEditVariation from "./slice-edit-variation"; import sliceList from "./slice-list"; import sliceRemove from "./slice-remove"; import sliceRemoveVariation from "./slice-remove-variation"; +import sliceUpgradeLegacy from "./slice-upgrade-legacy"; import sliceView from "./slice-view"; export default createCommandRouter({ @@ -46,6 +47,10 @@ export default createCommandRouter({ handler: sliceDisconnect, description: "Disconnect a slice from a type's slice zone", }, + "upgrade-legacy": { + handler: sliceUpgradeLegacy, + description: "Upgrade a legacy slice", + }, "add-variation": { handler: sliceAddVariation, description: "Add a variation to a slice", diff --git a/src/commands/status.ts b/src/commands/status.ts index 1e350973..a4062c29 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -4,7 +4,12 @@ import { createCommand, type CommandConfig } from "../lib/command"; import { hasChanges } from "../lib/diff"; import { getDirtyPaths, getGitRoot } from "../lib/git"; import { getProfile } from "../lib/prismic/clients/user"; -import { diffModels, getRemoteModels, type ModelsDiff } from "../lib/prismic/models"; +import { + diffModels, + getLegacySlices, + getRemoteModels, + type ModelsDiff, +} from "../lib/prismic/models"; import { isDescendant, relativePathname } from "../lib/url"; import { findProjectRoot, getRepositoryName } from "../project"; @@ -80,6 +85,13 @@ export default createCommand(config, async ({ values }) => { console.info("Not logged in — log in with `prismic login` to compare with remote."); } + const legacySliceCount = getLegacySlices(local.customTypes).length; + if (legacySliceCount > 0) { + console.info( + `\n${legacySliceCount} legacy ${legacySliceCount === 1 ? "slice needs" : "slices need"} upgrading. Run \`prismic slice list --legacy\`.`, + ); + } + const inSync = diff !== undefined && !hasChanges(diff.customTypes) && !hasChanges(diff.slices); if (inSync && dirtyModelFiles.length === 0) { diff --git a/src/commands/type-view.ts b/src/commands/type-view.ts index 938e6ef4..72f56102 100644 --- a/src/commands/type-view.ts +++ b/src/commands/type-view.ts @@ -50,6 +50,6 @@ export default createCommand(config, async ({ positionals, values }) => { } if (getLegacySlices([type]).length > 0) { - console.info("\n* Legacy slice. The CLI cannot edit legacy slices."); + console.info("\n* Legacy slice. Run `prismic slice upgrade-legacy --help` to upgrade it."); } }); diff --git a/src/lib/prismic/models.ts b/src/lib/prismic/models.ts index e7678797..d8c81251 100644 --- a/src/lib/prismic/models.ts +++ b/src/lib/prismic/models.ts @@ -289,6 +289,7 @@ export type LegacySlice = { id: string; customTypeId: string; sliceZoneId: string; + sliceZone: DynamicSlicesModel; model: Exclude; }; @@ -301,7 +302,7 @@ export function getLegacySlices(customTypes: DynamicCustomTypeModel[]): LegacySl return Object.entries(sliceZone.config?.choices ?? {}).flatMap(([id, model]) => model.type === "SharedSlice" ? [] - : [{ id, customTypeId: customType.id, sliceZoneId, model }], + : [{ id, customTypeId: customType.id, sliceZoneId, sliceZone, model }], ); }), ), diff --git a/test/slice-list.test.ts b/test/slice-list.test.ts index 7bbbbf58..fc4c7a43 100644 --- a/test/slice-list.test.ts +++ b/test/slice-list.test.ts @@ -32,7 +32,9 @@ it("marks legacy slices", async ({ expect, prismic, project }) => { const { stdout, stderr, exitCode } = await prismic("slice", ["list"]); expect(exitCode, stderr).toBe(0); expect(stdout).toMatch(new RegExp(`Hero \\*\\s+hero\\s+${customType.id}`)); - expect(stdout).toContain("* Legacy slice. The CLI cannot edit legacy slices."); + expect(stdout).toContain( + "* Legacy slice. Run `prismic slice upgrade-legacy --help` to upgrade it.", + ); }); it("lists only legacy slices as JSON", async ({ expect, prismic, project }) => { diff --git a/test/slice-upgrade-legacy.test.ts b/test/slice-upgrade-legacy.test.ts new file mode 100644 index 00000000..fa6c6b23 --- /dev/null +++ b/test/slice-upgrade-legacy.test.ts @@ -0,0 +1,271 @@ +import type { DynamicCustomTypeModel, DynamicSlicesModel } from "@prismicio/types-internal"; +import { describe } from "vitest"; + +import { + buildLegacyCustomType, + buildSlice, + it, + readLocalCustomType, + readLocalSlice, + writeLocalCustomType, + writeLocalSlice, +} from "./it"; +import { getCustomTypes, getSlices, insertCustomType } from "./prismic"; + +function getChoices(customType: DynamicCustomTypeModel) { + return (customType.json.Main.body as DynamicSlicesModel).config!.choices!; +} + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, stderr, exitCode } = await prismic("slice", ["upgrade-legacy", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("prismic slice upgrade-legacy [options]"); +}); + +it("upgrades a legacy slice to a new slice", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "hero", + "--from", + customType.id, + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain('Upgraded legacy slice "hero" to slice "hero"'); + expect(stdout).toContain("keep their shape"); + expect(stdout).toContain("2 legacy slices remain"); + + const slice = await readLocalSlice(project, "hero"); + expect(slice).toMatchObject({ + id: "hero", + type: "SharedSlice", + name: "Hero", + legacyPaths: { [`${customType.id}::body::hero`]: "default" }, + }); + expect(slice!.variations[0]).toMatchObject({ + id: "default", + primary: { title: { type: "StructuredText" } }, + items: { label: { type: "Text" } }, + }); + + const choices = getChoices(await readLocalCustomType(project, customType.id)); + expect(Object.keys(choices)).toEqual(["hero", "gallery", "quote"]); + expect(choices.hero).toEqual({ type: "SharedSlice" }); + expect(choices.gallery.type).toBe("Group"); +}); + +it("upgrades group and single-field legacy slices", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const gallery = await prismic("slice", ["upgrade-legacy", "gallery", "--from", customType.id]); + expect(gallery.exitCode, gallery.stderr).toBe(0); + expect(gallery.stdout).toContain("`slice.value` moves to `slice.items`"); + + const quote = await prismic("slice", ["upgrade-legacy", "quote", "--from", customType.id]); + expect(quote.exitCode, quote.stderr).toBe(0); + expect(quote.stdout).toContain("`slice.value` moves to `slice.primary.quote`"); + + const gallerySlice = await readLocalSlice(project, "gallery"); + expect(gallerySlice!.variations[0].items).toEqual({ + caption: { type: "Text", config: { label: "Caption" } }, + }); + const quoteSlice = await readLocalSlice(project, "quote"); + expect(quoteSlice!.variations[0].primary).toEqual({ + quote: { type: "Text", config: { label: "Quote" } }, + }); +}); + +it("converts an invalid legacy slice ID", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + const choices = getChoices(customType); + choices["Hero-Banner"] = choices.hero; + delete choices.hero; + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "Hero-Banner", + "--from", + customType.id, + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain('`slice_type` becomes "hero_banner"'); + + const slice = await readLocalSlice(project, "hero_banner"); + expect(slice!.legacyPaths).toEqual({ [`${customType.id}::body::Hero-Banner`]: "default" }); + expect(getChoices(await readLocalCustomType(project, customType.id)).hero_banner).toEqual({ + type: "SharedSlice", + }); +}); + +it("upgrades a legacy slice in another slice zone", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + customType.json.Main = { page_slices: customType.json.Main.body }; + await writeLocalCustomType(project, customType); + + const missing = await prismic("slice", ["upgrade-legacy", "hero", "--from", customType.id]); + expect(missing.exitCode).toBe(1); + expect(missing.stderr).toContain('not found in the "body" slice zone'); + + const { stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "hero", + "--from", + customType.id, + "--slice-zone", + "page_slices", + ]); + expect(exitCode, stderr).toBe(0); + const slice = await readLocalSlice(project, "hero"); + expect(slice!.legacyPaths).toEqual({ [`${customType.id}::page_slices::hero`]: "default" }); +}); + +it("asks how to upgrade when the slice exists, then merges", async ({ + expect, + prismic, + project, +}) => { + const first = buildLegacyCustomType(); + const second = buildLegacyCustomType(); + await writeLocalCustomType(project, first); + await writeLocalCustomType(project, second); + await prismic("slice", ["upgrade-legacy", "hero", "--from", first.id]); + const args = ["upgrade-legacy", "hero", "--from", second.id]; + + const ask = await prismic("slice", args); + expect(ask.exitCode).toBe(1); + expect(ask.stderr).toContain('Slice "hero" already exists. How do you want to upgrade'); + expect(ask.stderr).toContain(`--from ${second.id} --to hero\n`); + expect(ask.stderr).toContain(`--from ${second.id} --to hero --variation default`); + expect(ask.stderr).toContain(`--from ${second.id} --to `); + + const { stdout, stderr, exitCode } = await prismic("slice", [ + ...args, + "--to", + "hero", + "--variation", + "default", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain('Merged legacy slice "hero" into variation "default" of slice "hero"'); + + const slice = await readLocalSlice(project, "hero"); + expect(slice!.variations).toHaveLength(1); + expect(slice!.legacyPaths).toEqual({ + [`${first.id}::body::hero`]: "default", + [`${second.id}::body::hero`]: "default", + }); +}); + +it("adds a legacy slice to an existing slice as a new variation", async ({ + expect, + prismic, + project, +}) => { + const slice = buildSlice(); + const customType = buildLegacyCustomType(); + await writeLocalSlice(project, slice); + await writeLocalCustomType(project, customType); + const args = ["upgrade-legacy", "hero", "--from", customType.id, "--to", slice.id]; + + const merge = await prismic("slice", [...args, "--variation", "default"]); + expect(merge.exitCode).toBe(1); + expect(merge.stderr).toContain("has different fields"); + + const { stdout, stderr, exitCode } = await prismic("slice", args); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain('as variation "hero"'); + + const updated = await readLocalSlice(project, slice.id); + expect(updated!.variations.map((v) => v.id)).toEqual(["default", "hero"]); + expect(updated!.legacyPaths).toEqual({ [`${customType.id}::body::hero`]: "hero" }); + + const choices = getChoices(await readLocalCustomType(project, customType.id)); + expect(Object.keys(choices)).toEqual([slice.id, "gallery", "quote"]); +}); + +it("fails when the slice ID is another legacy slice in the zone", async ({ + expect, + prismic, + project, +}) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "quote", + "--from", + customType.id, + "--to", + "hero", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain('also has legacy slice "hero". Upgrade it first.'); + expect(getChoices(await readLocalCustomType(project, customType.id)).hero.type).toBe("Slice"); +}); + +it("upgrades a legacy slice to a slice with another ID", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "hero", + "--from", + customType.id, + "--to", + "blog_hero", + ]); + expect(exitCode, stderr).toBe(0); + const slice = await readLocalSlice(project, "blog_hero"); + expect(slice).toMatchObject({ + name: "BlogHero", + legacyPaths: { [`${customType.id}::body::hero`]: "default" }, + }); +}); + +it("fails when the type or legacy slice is not found", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const type = await prismic("slice", ["upgrade-legacy", "hero", "--from", "missing"]); + expect(type.exitCode).toBe(1); + expect(type.stderr).toContain('Type "missing" not found.'); + + const slice = await prismic("slice", ["upgrade-legacy", "missing", "--from", customType.id]); + expect(slice.exitCode).toBe(1); + expect(slice.stderr).toContain('Legacy slice "missing" not found'); +}); + +describe("with an isolated repository", () => { + it.scoped({ isolateRepo: true }); + + it("pushes an upgraded legacy slice", async ({ expect, prismic, repo, token, host }) => { + const customType = buildLegacyCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const pull = await prismic("pull", ["--repo", repo]); + expect(pull.exitCode, pull.stderr).toBe(0); + + const upgrade = await prismic("slice", ["upgrade-legacy", "hero", "--from", customType.id]); + expect(upgrade.exitCode, upgrade.stderr).toBe(0); + + const push = await prismic("push", ["--repo", repo, "--force"]); + expect(push.exitCode, push.stderr).toBe(0); + + const slices = await getSlices({ repo, token, host }); + expect(slices.find((slice) => slice.id === "hero")?.legacyPaths).toEqual({ + [`${customType.id}::body::hero`]: "default", + }); + const remoteCustomType = (await getCustomTypes({ repo, token, host })).find( + (ct) => ct.id === customType.id, + )!; + const choices = getChoices(remoteCustomType); + expect(choices.hero).toEqual({ type: "SharedSlice" }); + expect(choices.gallery.type).toBe("Group"); + }); +}); diff --git a/test/slice-view.test.ts b/test/slice-view.test.ts index ea1d8648..2c360704 100644 --- a/test/slice-view.test.ts +++ b/test/slice-view.test.ts @@ -66,13 +66,13 @@ it("views a slice as JSON", async ({ expect, prismic, project }) => { expect(parsed).toMatchObject({ id: slice.id, name: slice.name }); }); -it("fails for a legacy slice", async ({ expect, prismic, project }) => { +it("points at the upgrade for a legacy slice", async ({ expect, prismic, project }) => { const customType = buildLegacyCustomType(); await writeLocalCustomType(project, customType); const { stderr, exitCode } = await prismic("slice", ["view", "hero"]); expect(exitCode).toBe(1); expect(stderr).toContain( - `"hero" is a legacy slice in "${customType.id}". The CLI cannot edit legacy slices.`, + `"hero" is a legacy slice in "${customType.id}". Upgrade it first: \`prismic slice upgrade-legacy hero --from ${customType.id}\`.`, ); }); diff --git a/test/status.test.ts b/test/status.test.ts index fce0a73f..1f20501d 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -3,7 +3,14 @@ import { fileURLToPath } from "node:url"; import { x } from "tinyexec"; import { describe } from "vitest"; -import { buildCustomType, buildSlice, it, writeLocalCustomType, writeLocalSlice } from "./it"; +import { + buildCustomType, + buildLegacyCustomType, + buildSlice, + it, + writeLocalCustomType, + writeLocalSlice, +} from "./it"; import { insertCustomType, insertSlice } from "./prismic"; it("supports --help", async ({ expect, prismic }) => { @@ -25,6 +32,15 @@ describe("with an isolated repository", () => { expect(stdout).toContain("Already up to date."); }); + it("reports legacy slices", async ({ expect, project, prismic, repo }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("status", ["--repo", repo]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("3 legacy slices need upgrading. Run `prismic slice list --legacy`."); + }); + it("reports local-only models when added locally but not pushed", async ({ expect, project,