diff --git a/src/clients/core.ts b/src/clients/core.ts index 3002314..92122ea 100644 --- a/src/clients/core.ts +++ b/src/clients/core.ts @@ -157,6 +157,32 @@ export async function setSimulatorUrl( } } +const DocumentSearchTotalSchema = z.object({ + total: z.number(), +}); + +export async function getDocumentTotalByCustomTypes( + customTypeId: string, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const url = new URL("core/documents/search", getCoreBaseUrl(repo, host)); + try { + const response = await request(url, { + method: "POST", + body: { customTypes: [customTypeId], limit: 0 }, + credentials: { "prismic-auth": token }, + schema: DocumentSearchTotalSchema, + }); + return response.total; + } catch (error) { + if (error instanceof NotFoundRequestError) { + error.message = `Repository not found: ${repo}`; + } + throw error; + } +} + function getCoreBaseUrl(repo: string, host: string): URL { return new URL(`https://${repo}.${host}/`); } diff --git a/src/clients/wroom.ts b/src/clients/wroom.ts index 408b5f0..de1ce41 100644 --- a/src/clients/wroom.ts +++ b/src/clients/wroom.ts @@ -421,3 +421,26 @@ function getDashboardUrl(host: string): URL { function getWroomUrl(repo: string, host: string): URL { return new URL(`https://${repo}.${host}/`); } + +/** Editor parity: document list filtered by custom type (sidebar / working view). */ +export function getWorkingDocumentsUrlForCustomType( + args: { repo: string; host: string; customTypeId: string }, +): string { + const { repo, host, customTypeId } = args; + const url = new URL("builder/working", getWroomUrl(repo, host)); + url.searchParams.set("customTypes", customTypeId); + return url.href; +} + +type GetCustomTypePagesUrlArgs = { + repo: string; + host: string; + format: "custom" | "page"; +}; + +export function getCustomTypeListUrl(args: GetCustomTypePagesUrlArgs): string { + const { repo, host, format } = args; + const path = ["builder", "types", format === "custom" ? "custom-types" : "page-types"].join("/"); + const url = new URL(path, getWroomUrl(repo, host)); + return url.href; +} \ No newline at end of file diff --git a/src/commands/push.ts b/src/commands/push.ts index 485b84a..7fb3871 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -1,7 +1,10 @@ import { pascalCase } from "change-case"; +import type { CustomType } from "@prismicio/types-internal/lib/customtypes"; + import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; +import { getDocumentTotalByCustomTypes } from "../clients/core"; import { getCustomTypes, getSlices, @@ -16,10 +19,12 @@ import { completeOnboardingStepsSilently, type OnboardingStep, } from "../clients/repository"; +import { getWorkingDocumentsUrlForCustomType, getCustomTypeListUrl } from "../clients/wroom"; import { resolveEnvironment } from "../environments"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { diffArrays } from "../lib/diff"; import { getDirtyPaths, getGitRoot } from "../lib/git"; +import { BadRequestError } from "../lib/request"; import { appendTrailingSlash, isDescendant, relativePathname } from "../lib/url"; import { canonicalizeModel } from "../models"; import { findProjectRoot, getRepositoryName } from "../project"; @@ -134,8 +139,8 @@ export default createCommand(config, async ({ values }) => { for (const model of customTypeOps.update) { await updateCustomType(model, { repo, token, host }); } - for (const id of customTypeOps.delete.map((m) => m.id)) { - await removeCustomType(id, { repo, token, host }); + for (const model of customTypeOps.delete) { + await removeCustomTypeWithDocumentHandling(model, { repo, token, host }); } for (const model of sliceOps.insert) { await insertSlice(model, { repo, token, host }); @@ -173,3 +178,53 @@ export default createCommand(config, async ({ values }) => { if (totalDeletes > 0) console.info(`Deleted ${totalDeletes} model(s).`); } }); + +async function removeCustomTypeWithDocumentHandling( + model: CustomType, + config: { + repo: string; + token: string | undefined; + host: string; + }, +): Promise { + const { repo, token, host } = config; + const { id, format } = model; + + try { + await removeCustomType(id, { repo, token, host }); + } catch (error) { + if (!(await isDocumentsInUseError(error))) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new CommandError( + `Could not delete type "${id}": ${errorMessage}"` + + "\nPlease try again, or manually deleting the type at: " + + getCustomTypeListUrl({ repo, host, format: format ?? "custom" }) + ); + } + + let documentCount: number; + try { + documentCount = await getDocumentTotalByCustomTypes(id, { repo, token, host }); + } catch { + throw new CommandError( + `Could not check whether type "${id}" has associated pages. ` + + "\nPlease try again, or manually delete any associated pages at: " + + getWorkingDocumentsUrlForCustomType({ repo, host, customTypeId: id }), + ); + } + + const countLabel = documentCount > 0 ? ` ${documentCount}` : ""; + const pluralPages = documentCount === 1 ? "page" : "pages"; + throw new CommandError( + `Could not delete type "${id}" because it has${countLabel} associated ${pluralPages}. ` + + `\nDelete any associated pages manually before pushing at: ` + + getWorkingDocumentsUrlForCustomType({ repo, host, customTypeId: id }), + ); + } +} + +async function isDocumentsInUseError(error: unknown): Promise { + if (!(error instanceof BadRequestError)) return false; + const body = await error.text(); + return body.includes("associated documents") || body.includes("Delete all documents belonging"); +} diff --git a/src/lib/request.ts b/src/lib/request.ts index f9c63ad..b645916 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -53,6 +53,7 @@ export async function request( return value; } else { + if (response.status === 400) throw new BadRequestError(response, value); if (response.status === 401) throw new UnauthorizedRequestError(response); if (response.status === 403) throw new ForbiddenRequestError(response); if (response.status === 404) throw new NotFoundRequestError(response); @@ -89,6 +90,15 @@ export class RequestError extends Error { export class UnknownRequestError extends RequestError { name = "UnknownRequestError"; } +export class BadRequestError extends RequestError { + name = "BadRequestError"; + body: unknown; + + constructor(response: Response, body: unknown) { + super(response); + this.body = body; + } +} export class NotFoundRequestError extends RequestError { name = "NotFoundRequestError";