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
9 changes: 9 additions & 0 deletions .changeset/typed-fields-studio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@cloudhal/cli": minor
---

Typed custom fields, step 5 (RFC-0041 §7 / ADR-0043): Studio.

- **Entry editor › Fields:** collections with field definitions get a "Fields" card in the entry editor (loaded only for them), one control per type — text, rich text (the body editor), number, yes/no, date, date and time in its own zone, link, image (the media picker) and choice (one or several). Labels and help follow the Studio language, required fields say "Needed to publish", and the API's issues show on their field. On a translation, shared (non-localized) fields are read-only with the original's value and a link to it; empty per-language fields show the original's value as their placeholder. Values save with the entry.
- **Collection settings › Fields:** a builder to add fields (the key follows the label until saved), reorder them and edit labels, help, limits and options, saved together. Renaming, changing a type or the languages of a saved field, and removing one open a dialog that runs the dry run first (entries changed, values moved, converted or cleared, per language, examples, a note about themes), needs confirmation — and an explicit opt-in to clear values that cannot be converted — then runs the operation to the end and offers Revert. A running operation shows its progress and can be continued. Only people who may change site settings edit fields; others see them read-only.
- Activity and Approvals name the collection and field tools, field operations that convert or remove values ask for confirmation before approval, and every field error code reads as a sentence in the Studio language. New words live in a lazy `fields` namespace (drafts in every language, awaiting review).
6 changes: 6 additions & 0 deletions apps/studio/bundle-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export const LAZY_ONLY: readonly RegExp[] = [
// keeps the hooks (`elevation-actions.ts`, `useShortDuration.ts`, `taxonomy-keys.ts`).
/[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/](NotificationItems|AgentModes|Elevation|ElevationRequest|Taxonomy|RelativeTime)\.tsx$/,
/[\\/]apps[\\/]studio[\\/]src[\\/]lib[\\/](taxonomy|seo|activity)\.ts$/,
// Typed custom fields (RFC-0041 §7): the entry form, its zone helpers, the builder, the operation
// dialogs and the field model load with a collection that has fields, never with the shell.
/[\\/]packages[\\/]ui[\\/]src[\\/]blocks[\\/](EntryFieldsForm|entry-fields-model|zoned-time)\.tsx?$/,
/[\\/]packages[\\/]core-ts[\\/]src[\\/]fields[\\/]/,
/[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/]fields[\\/]/,
/[\\/]apps[\\/]studio[\\/]src[\\/]lib[\\/](fields|field-form)\.ts$/,
// Lazy message namespaces: their words load with the screens that show them.
/[\\/]apps[\\/]studio[\\/]src[\\/]i18n[\\/]lazy[\\/]/,
// Core catalogs other than English load with their language (`loadLocale`), never with English.
Expand Down
184 changes: 184 additions & 0 deletions apps/studio/src/components/fields/EntryFieldsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts";
import {
EntryFieldsForm,
type EntryFieldsFormLabels,
type EntryFieldsOriginal,
Label,
SectionCard,
Text,
} from "@cloudhal/ui";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { type TFunction, useI18n } from "../../i18n/index.tsx";
import { api } from "../../lib/api.ts";
import { entryHref } from "../../lib/entry-href.ts";
import { fieldIssues, issuesByField } from "../../lib/fields.ts";
import { languageName } from "../../lib/languages.ts";
import { zoneLabel } from "../../lib/schedules.ts";
import { primaryHostname } from "../../lib/site-settings.ts";
import { BodyEditor } from "../BodyEditor.tsx";
import { FieldImage } from "./FieldImage.tsx";

type FT = TFunction<"fields">;

const ISSUE_CODES = [
"invalid_value",
"unknown_field",
"field_not_localized",
"required_field",
"too_large",
] as const;
type IssueCode = (typeof ISSUE_CODES)[number];
const isIssueCode = (code: string): code is IssueCode =>
(ISSUE_CODES as readonly string[]).includes(code);

/** A field's limits in words: length, bounds, whole numbers. */
export function fieldHint(t: FT, def: FieldDefinition): string | undefined {
if (def.type === "text" && def.maxLength !== undefined)
return t("fields.hints.maxLength", { count: def.maxLength });
if (def.type !== "number") return undefined;
const parts = [
def.minimum !== undefined && def.maximum !== undefined
? t("fields.hints.range", { min: def.minimum, max: def.maximum })
: def.minimum !== undefined
? t("fields.hints.min", { min: def.minimum })
: def.maximum !== undefined
? t("fields.hints.max", { max: def.maximum })
: undefined,
def.integer ? t("fields.hints.integer") : undefined,
].filter(Boolean);
return parts.length ? parts.join(" ") : undefined;
}

/** The form's words, in the Studio language. */
function useFormLabels(t: FT, originalLocale: string | undefined, locale: string) {
return useMemo<EntryFieldsFormLabels>(() => {
const d = "fields.editor.date" as const;
return {
neededToPublish: t("fields.editor.neededToPublish"),
fromOriginal: t("fields.editor.fromOriginal", {
language: originalLocale ? languageName(locale, originalLocale) : "",
}),
openOriginal: t("fields.editor.openOriginal"),
originalValue: (value) => t("fields.editor.originalValue", { value }),
yes: t("fields.editor.yes"),
no: t("fields.editor.no"),
noValue: t("fields.editor.noValue"),
clear: t("fields.editor.clear"),
originalImage: t("fields.editor.originalImage"),
date: {
date: t(`${d}.date`),
pickDate: t(`${d}.pickDate`),
chooseDate: t(`${d}.chooseDate`),
invalidTime: t(`${d}.invalidTime`),
calendar: {
previousMonth: t(`${d}.previousMonth`),
nextMonth: t(`${d}.nextMonth`),
navigation: t(`${d}.months`),
day: (date, { today, selected }) => {
const named = selected ? t(`${d}.daySelected`, { date }) : date;
return today ? t(`${d}.dayToday`, { date: named }) : named;
},
},
},
};
}, [t, originalLocale, locale]);
}

/** The translation group's original in this site, when the entry is one of its translations. */
function useOriginal(siteId: string, entry: ContentEntry) {
const tr = entry.translation;
const id = tr && tr.group !== entry.id && !tr.source?.siteId ? tr.group : undefined;
return useQuery({
queryKey: ["entry", siteId, id],
queryFn: () => api.entries.get(siteId, id as string),
enabled: id !== undefined,
});
}

/**
* The entry editor's "Fields" card (RFC-0041 §7): the collection's typed fields as
* `EntryFieldsForm`, saved with the entry. Shown only when the collection has definitions; loaded
* lazily with them. Rich text uses the body editor, images the media picker; issues from the
* last save show on their field.
*/
export function EntryFieldsCard({
siteId,
site,
entry,
onChange,
error,
disabled,
}: {
siteId: string;
site: Site;
entry: ContentEntry;
onChange: (fields: Record<string, unknown>) => void;
/** The last save's error: its `details.issues` show on the fields. */
error: unknown;
disabled: boolean;
}) {
const { t, locale } = useI18n("fields", "bodyEditor", "media");
const definitions = site.collections?.[entry.collection]?.fields ?? [];
const original = useOriginal(siteId, entry);
const labels = useFormLabels(t, original.data?.locale, locale);
const errors = useMemo(() => {
const out: Record<string, string> = {};
for (const [key, issue] of Object.entries(issuesByField(fieldIssues(error))))
out[key] = isIssueCode(issue.code) ? t(`fields.issues.${issue.code}`) : issue.message;
return out;
}, [error, t]);
const from: EntryFieldsOriginal | undefined = original.data
? {
values: original.data.fields,
href: entryHref(siteId, original.data.id, original.data.collection),
}
: undefined;
const defaultLocale = site.defaultLocale;
return (
<SectionCard id="fields" title={t("fields.editor.title")}>
<EntryFieldsForm
definitions={definitions}
value={entry.fields}
onChange={onChange}
locale={locale}
defaultLocale={defaultLocale}
errors={errors}
hint={(def) => fieldHint(t, def)}
original={from}
timeZone={site.timeZone ?? "UTC"}
zoneLabel={(zone) => zoneLabel(zone)}
labels={labels}
disabled={disabled}
renderImage={(slot) => (
<FieldImage siteId={siteId} defaultLocale={defaultLocale} slot={slot} />
)}
renderRichText={(slot) => (
<div className="grid min-w-0 gap-1.5" data-field-rich-text={slot.def.key}>
<Label>{slot.label}</Label>
<BodyEditor
siteId={siteId}
defaultLocale={defaultLocale}
host={primaryHostname(site.domains ?? [])}
value={slot.value}
onChange={slot.onChange}
size="sm"
readOnly={slot.readOnly}
/>
{(slot.description || slot.placeholder) && (
<Text variant="secondary" size="sm" as="span">
{slot.description}
{slot.placeholder && ` ${labels.originalValue(slot.placeholder)}`}
</Text>
)}
{slot.error && (
<Text variant="error" size="sm" as="span">
{slot.error}
</Text>
)}
</div>
)}
/>
</SectionCard>
);
}
Loading
Loading