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
10 changes: 10 additions & 0 deletions .changeset/rfc45-step6b-studio-builder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@cloudhal/api": patch
---

RFC-0045 rollout step 6b: Studio's fields builder, delete dialog and publish warnings for reference and repeater fields. Both types stay refused on every production write path until step 7, and the builder offers them only where the server takes them, so production Studio is unchanged.

- **`/api/instance`** reports `features.referenceFields`: true when the store accepts reference and repeater fields (`SiteGraphStoreOptions.fieldTypes`, MCP's rule).
- **`DELETE /api/sites/:siteId/entries/:entryId?rev=&dryRun=1`** runs the store's delete plan and writes nothing: `{ dryRun: true }`, or the plan's refusal (`entry_referenced` with its referrers, `field_operation_running`, …).
- **New read-only route `GET /api/sites/:siteId/entries/reference-issues`** (`content:read`, in the playground's allow-list): `referenceIssues` over the working copy, the first 50 with each holder's title, and the total.
- **Studio** (not published): reference and list fields in the builder (target collections, "Several", the maximum and conditions; item fields with up/down and ⋯, the maximum and the item name), sub-field operations and reference/list targets in "Change type…", a cleared finish for a run stuck on values that stopped converting, the delete dialog's reference rules (off while published entries show the last entry, drafts that will show nothing, the language that loses the card), publish warnings, and worded API errors for the RFC-0045 refusals.
97 changes: 97 additions & 0 deletions apps/studio/src/components/EntryDeleteDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { ContentEntry } from "@cloudhal/core-contracts";
import { DeleteResource } from "@cloudhal/ui";
import { useQuery } from "@tanstack/react-query";
import { lazy, Suspense, useState } from "react";
import { useT } from "../i18n/index.tsx";
import { ApiRequestError } from "../lib/api/http.ts";
import type { ReferencedDetails } from "../lib/api/references.ts";
import { api } from "../lib/api.ts";
import type { MenuReference } from "../lib/menus.ts";
import { deleteResourceProps } from "./deleteLabels.ts";
import { referenced } from "./EntryFieldCards.tsx";
import { MenuDeleteNotice } from "./Menus.tsx";

// RFC-0045 §5: the references part loads only where some field can reference this collection.
const DeleteReferences = lazy(() =>
import("./fields/DeleteReferences.tsx").then((m) => ({ default: m.DeleteReferences })),
);

/**
* The entry editor's delete (DESIGN.md §2d): `DeleteResource` with the menus that will hide the
* entry and, where references can point at it (RFC-0045 §5), what the store says about its
* referrers. Delete stays off while that check runs and while the store refuses
* (`entry_referenced`); a failed check leaves it on, since the delete itself is guarded. A refusal
* of the delete itself shows in the dialog: its referrers as the blocked state, any other refusal
* (`field_operation_running`, …) in its words.
*/
export function EntryDeleteDialog({
open,
onOpenChange,
siteId,
entry,
menuRefs,
isDeleting,
onDelete,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
siteId: string;
/** The entry to delete; none while the dialog is closed (a list's row). */
entry: ContentEntry | undefined;
menuRefs: MenuReference[] | undefined;
isDeleting: boolean;
onDelete: () => Promise<void>;
}) {
const t = useT();
const site = useQuery({ queryKey: ["site", siteId], queryFn: () => api.sites.get(siteId) });
const checks =
site.data !== undefined && entry !== undefined && referenced(site.data, entry.collection);
// Undefined while the check runs; then whether the store refuses the delete.
const [blocked, setBlocked] = useState<boolean | undefined>();
// The real delete's refusal: its referrers when the check went stale, else its words.
const [refused, setRefused] = useState<{ details?: unknown; error?: unknown }>();
// A refusal belongs to one opening of the dialog for one revision: reopening, or a new revision,
// asks the store again (the editor keeps the dialog mounted for the same entry).
const session = open && entry ? `${entry.id}:${entry.rev}` : undefined;
const [shown, setShown] = useState(session);
if (shown !== session) {
setShown(session);
setRefused(undefined);
}
const deleteNow = async () => {
setRefused(undefined);
try {
await onDelete();
} catch (e) {
const referenced = e instanceof ApiRequestError && e.body.error === "entry_referenced";
setRefused(referenced ? { details: e.body.details } : { error: e });
}
};
return (
<DeleteResource
open={open}
onOpenChange={onOpenChange}
{...deleteResourceProps(t, t("editor.article"), { error: refused?.error })}
resourceName={entry?.slug ?? ""}
notice={
<>
<MenuDeleteNotice refs={menuRefs} kind="contentEntry" />
{checks && site.data && entry && (
<Suspense fallback={null}>
<DeleteReferences
site={site.data}
entry={entry}
open={open}
refused={refused?.details as ReferencedDetails | undefined}
onBlocked={setBlocked}
/>
</Suspense>
)}
</>
}
deleteDisabled={(checks && blocked !== false) || refused?.details !== undefined}
isDeleting={isDeleting}
onDelete={deleteNow}
/>
);
}
2 changes: 1 addition & 1 deletion apps/studio/src/components/EntryFieldCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const UsedByCard = lazy(() =>
);

/** True when some field of the site, or a repeater's sub-field, can reference `collection`. */
function referenced(site: Site, collection: string): boolean {
export function referenced(site: Site, collection: string): boolean {
return Object.values(site.collections ?? {}).some((settings) =>
(settings?.fields ?? []).some(
(def) =>
Expand Down
10 changes: 9 additions & 1 deletion apps/studio/src/components/SitePublish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { lazy, Suspense, useState } from "react";
import { useT } from "../i18n/index.tsx";
import { api } from "../lib/api.ts";
import { apiErrorText } from "../lib/api-errors.ts";
Expand All @@ -17,6 +17,11 @@ import { PendingBadge, pendingText, usePendingChanges } from "./PendingChanges.t
import { usePublishMode, usePublishSite } from "./Publish.tsx";
import { useToast } from "./Toast.ts";

// RFC-0045: the publish dialog's reference warnings, a lazy chunk that loads with the dialog.
const ReferenceWarnings = lazy(() =>
import("./fields/PublishWarnings.tsx").then((m) => ({ default: m.ReferenceWarnings })),
);

/**
* The site surface's Publish split button (top bar, every site screen): Publish → `ApprovalDialog`
* → snapshot + publish; the menu takes a snapshot without publishing (DESIGN.md §1, §2d). It follows
Expand Down Expand Up @@ -121,6 +126,9 @@ export function SitePublish({
onConfirm={() => publish.mutate({ label: t("snapshots.publishedFromStudio") })}
>
<UnpublishedPagesNote siteId={siteId} open={open} onNavigate={() => setOpen(false)} />
<Suspense fallback={null}>
<ReferenceWarnings siteId={siteId} open={open} onNavigate={() => setOpen(false)} />
</Suspense>
</ApprovalDialog>
</>
);
Expand Down
Loading
Loading