From 05274d3679d6031820dd7afb3ac5c484b894af72 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:50:24 +0300 Subject: [PATCH] feat(studio): typed fields in Studio (RFC-0041 step 5) - EntryFieldsForm (packages/ui): one Kumo control per field type, words as props, required-to-publish badges, inline issues; translations show non-localized values read-only with a link to the original, and empty localized values with the original's as a placeholder. - Entry editor: a lazy Fields card saved with the entry (rich text in BodyEditor, images through the asset picker). - Collection settings: a lazy fields builder (add, edit labels and options, reorder, save) and operation dialogs for rename, type, languages and remove: dry-run report, explicit "clear" opt-in, run, continue, revert, a banner for a running operation. Read-only without site:settings. - Approvals: labels for the field tools; error codes through apiErrorText. - i18n: lazy `fields` namespace, drafts for the other languages. Studio 1240/1240, ui 210/210, design lint, size; initial load 389.1 kB gzip (+0.8, budget 420); screenshots at 1440 and 390 px. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/typed-fields-studio.md | 9 + apps/studio/bundle-budget.ts | 6 + .../src/components/fields/EntryFieldsCard.tsx | 184 +++++++ .../src/components/fields/FieldDialog.tsx | 392 +++++++++++++++ .../src/components/fields/FieldImage.tsx | 106 ++++ .../fields/FieldOperationDialog.tsx | 387 +++++++++++++++ .../fields/FieldOperationReport.tsx | 148 ++++++ .../studio/src/components/fields/FieldRow.tsx | 156 ++++++ .../src/components/fields/FieldsSection.tsx | 232 +++++++++ .../components/fields/useFieldOperation.ts | 148 ++++++ apps/studio/src/i18n/ar.ts | 24 +- apps/studio/src/i18n/de.ts | 26 +- apps/studio/src/i18n/en.ts | 22 + apps/studio/src/i18n/es.ts | 24 +- apps/studio/src/i18n/fa.ts | 24 +- apps/studio/src/i18n/fr.ts | 27 +- apps/studio/src/i18n/generated/params.ts | 26 + apps/studio/src/i18n/he.ts | 24 +- apps/studio/src/i18n/hi.ts | 24 +- apps/studio/src/i18n/id.ts | 25 +- apps/studio/src/i18n/it.ts | 24 +- apps/studio/src/i18n/ja.ts | 26 +- apps/studio/src/i18n/ko.ts | 24 +- apps/studio/src/i18n/lazy/fields.ar.ts | 209 ++++++++ apps/studio/src/i18n/lazy/fields.de.ts | 219 +++++++++ apps/studio/src/i18n/lazy/fields.en.ts | 209 ++++++++ apps/studio/src/i18n/lazy/fields.es.ts | 218 +++++++++ apps/studio/src/i18n/lazy/fields.fa.ts | 215 ++++++++ apps/studio/src/i18n/lazy/fields.fr.ts | 218 +++++++++ apps/studio/src/i18n/lazy/fields.he.ts | 206 ++++++++ apps/studio/src/i18n/lazy/fields.hi.ts | 208 ++++++++ apps/studio/src/i18n/lazy/fields.id.ts | 214 ++++++++ apps/studio/src/i18n/lazy/fields.it.ts | 218 +++++++++ apps/studio/src/i18n/lazy/fields.ja.ts | 213 ++++++++ apps/studio/src/i18n/lazy/fields.ko.ts | 208 ++++++++ apps/studio/src/i18n/lazy/fields.nl.ts | 217 ++++++++ apps/studio/src/i18n/lazy/fields.pl.ts | 217 ++++++++ apps/studio/src/i18n/lazy/fields.pt-BR.ts | 219 +++++++++ apps/studio/src/i18n/lazy/fields.ru.ts | 218 +++++++++ apps/studio/src/i18n/lazy/fields.tr.ts | 214 ++++++++ apps/studio/src/i18n/lazy/fields.uk.ts | 219 +++++++++ apps/studio/src/i18n/lazy/fields.zh-Hans.ts | 201 ++++++++ apps/studio/src/i18n/namespaces.ts | 2 + apps/studio/src/i18n/nl.ts | 26 +- apps/studio/src/i18n/pl.ts | 25 +- apps/studio/src/i18n/pt-BR.ts | 25 +- apps/studio/src/i18n/ru.ts | 24 +- apps/studio/src/i18n/tr.ts | 24 +- apps/studio/src/i18n/uk.ts | 24 +- apps/studio/src/i18n/zh-Hans.ts | 23 +- apps/studio/src/lib/activity.ts | 8 + apps/studio/src/lib/api.ts | 6 + apps/studio/src/lib/api/collections.ts | 47 +- apps/studio/src/lib/approvals.ts | 10 +- apps/studio/src/lib/field-form.ts | 244 +++++++++ apps/studio/src/lib/fields.ts | 110 +++++ apps/studio/src/lib/schedules.ts | 58 +-- apps/studio/src/router.tsx | 2 + apps/studio/src/routes/ArticleEditorPage.tsx | 96 ++-- .../src/routes/CollectionSettingsPage.tsx | 45 +- apps/studio/test/design-rules.test.ts | 2 + apps/studio/test/fields-studio.test.tsx | 462 ++++++++++++++++++ docs/design/DESIGN.md | 11 +- packages/ui/src/blocks/DateTimeField.tsx | 39 +- packages/ui/src/blocks/EntryFieldsForm.tsx | 424 ++++++++++++++++ packages/ui/src/blocks/entry-fields-model.ts | 97 ++++ packages/ui/src/blocks/zoned-time.ts | 62 +++ packages/ui/src/index.ts | 14 + packages/ui/test/entry-fields-form.test.tsx | 236 +++++++++ scripts/size-baseline.json | 2 +- 70 files changed, 8165 insertions(+), 131 deletions(-) create mode 100644 .changeset/typed-fields-studio.md create mode 100644 apps/studio/src/components/fields/EntryFieldsCard.tsx create mode 100644 apps/studio/src/components/fields/FieldDialog.tsx create mode 100644 apps/studio/src/components/fields/FieldImage.tsx create mode 100644 apps/studio/src/components/fields/FieldOperationDialog.tsx create mode 100644 apps/studio/src/components/fields/FieldOperationReport.tsx create mode 100644 apps/studio/src/components/fields/FieldRow.tsx create mode 100644 apps/studio/src/components/fields/FieldsSection.tsx create mode 100644 apps/studio/src/components/fields/useFieldOperation.ts create mode 100644 apps/studio/src/i18n/lazy/fields.ar.ts create mode 100644 apps/studio/src/i18n/lazy/fields.de.ts create mode 100644 apps/studio/src/i18n/lazy/fields.en.ts create mode 100644 apps/studio/src/i18n/lazy/fields.es.ts create mode 100644 apps/studio/src/i18n/lazy/fields.fa.ts create mode 100644 apps/studio/src/i18n/lazy/fields.fr.ts create mode 100644 apps/studio/src/i18n/lazy/fields.he.ts create mode 100644 apps/studio/src/i18n/lazy/fields.hi.ts create mode 100644 apps/studio/src/i18n/lazy/fields.id.ts create mode 100644 apps/studio/src/i18n/lazy/fields.it.ts create mode 100644 apps/studio/src/i18n/lazy/fields.ja.ts create mode 100644 apps/studio/src/i18n/lazy/fields.ko.ts create mode 100644 apps/studio/src/i18n/lazy/fields.nl.ts create mode 100644 apps/studio/src/i18n/lazy/fields.pl.ts create mode 100644 apps/studio/src/i18n/lazy/fields.pt-BR.ts create mode 100644 apps/studio/src/i18n/lazy/fields.ru.ts create mode 100644 apps/studio/src/i18n/lazy/fields.tr.ts create mode 100644 apps/studio/src/i18n/lazy/fields.uk.ts create mode 100644 apps/studio/src/i18n/lazy/fields.zh-Hans.ts create mode 100644 apps/studio/src/lib/field-form.ts create mode 100644 apps/studio/src/lib/fields.ts create mode 100644 apps/studio/test/fields-studio.test.tsx create mode 100644 packages/ui/src/blocks/EntryFieldsForm.tsx create mode 100644 packages/ui/src/blocks/entry-fields-model.ts create mode 100644 packages/ui/src/blocks/zoned-time.ts create mode 100644 packages/ui/test/entry-fields-form.test.tsx diff --git a/.changeset/typed-fields-studio.md b/.changeset/typed-fields-studio.md new file mode 100644 index 00000000..4d9de478 --- /dev/null +++ b/.changeset/typed-fields-studio.md @@ -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). diff --git a/apps/studio/bundle-budget.ts b/apps/studio/bundle-budget.ts index eafbf896..d3cc669c 100644 --- a/apps/studio/bundle-budget.ts +++ b/apps/studio/bundle-budget.ts @@ -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. diff --git a/apps/studio/src/components/fields/EntryFieldsCard.tsx b/apps/studio/src/components/fields/EntryFieldsCard.tsx new file mode 100644 index 00000000..36e6eefd --- /dev/null +++ b/apps/studio/src/components/fields/EntryFieldsCard.tsx @@ -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(() => { + 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) => 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 = {}; + 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 ( + + fieldHint(t, def)} + original={from} + timeZone={site.timeZone ?? "UTC"} + zoneLabel={(zone) => zoneLabel(zone)} + labels={labels} + disabled={disabled} + renderImage={(slot) => ( + + )} + renderRichText={(slot) => ( +
+ + + {(slot.description || slot.placeholder) && ( + + {slot.description} + {slot.placeholder && ` ${labels.originalValue(slot.placeholder)}`} + + )} + {slot.error && ( + + {slot.error} + + )} +
+ )} + /> +
+ ); +} diff --git a/apps/studio/src/components/fields/FieldDialog.tsx b/apps/studio/src/components/fields/FieldDialog.tsx new file mode 100644 index 00000000..eb002f5d --- /dev/null +++ b/apps/studio/src/components/fields/FieldDialog.tsx @@ -0,0 +1,392 @@ +import type { FieldDefinition } from "@cloudhal/core-contracts"; +import { FIELD_LIMITS, FIELD_TYPES } from "@cloudhal/core-ts/fields"; +import { Button, Input, InsetPanel, LayerDialog, Select, Text } from "@cloudhal/ui"; +import { Plus, Trash } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { type TFunction, useI18n } from "../../i18n/index.tsx"; +import { + definitionOf, + emptyForm, + type FieldForm, + type FormProblems, + formOf, + formProblems, + hasProblems, + withLabel, + withOptionLabel, +} from "../../lib/field-form.ts"; +import { type FieldType, localizedByDefault } from "../../lib/fields.ts"; +import { languageName } from "../../lib/languages.ts"; +import { SwitchField } from "../TaxonomySettingsDialog.tsx"; + +type FT = TFunction<"fields">; +type SetForm = (next: FieldForm) => void; + +export interface FieldFormContext { + t: FT; + form: FieldForm; + set: SetForm; + locales: readonly string[]; + defaultLocale: string; + uiLocale: string; + isNew: boolean; + problems: FormProblems; +} + +/** Label and help per site language; the default language's label is required. */ +function LabelFields({ + t, + form, + set, + locales, + defaultLocale, + uiLocale, + problems, +}: FieldFormContext) { + const many = locales.length > 1; + return ( + <> + {locales.map((l) => { + const v = form.labels[l] ?? { label: "", help: "" }; + const isDefault = l === defaultLocale; + const language = languageName(uiLocale, l); + return ( +
+ set(withLabel(form, l, x, isDefault))} + error={isDefault && problems.label ? t("fields.dialog.errors.label") : undefined} + maxLength={100} + /> + + set({ ...form, labels: { ...form.labels, [l]: { ...v, help: x } } }) + } + maxLength={300} + /> +
+ ); + })} + + ); +} + +/** A select's options: value (stored, never shown to readers) and a label per language. */ +export function OptionFields({ + t, + form, + set, + locales, + defaultLocale, + uiLocale, + isNew, + problems, +}: FieldFormContext) { + const many = locales.length > 1; + return ( +
+
+ + {t("fields.dialog.options")} + + + {t("fields.dialog.optionsHint")} + + {problems.options && ( + + {t(`fields.dialog.errors.options.${problems.options}`)} + + )} +
+ {form.options.map((o, i) => ( + +
+ {locales.map((l) => ( + + set( + withOptionLabel(form, { + index: i, + locale: l, + label: x, + isDefault: l === defaultLocale, + }), + ) + } + maxLength={100} + /> + ))} +
+ + set({ + ...form, + options: form.options.map((p, j) => + j === i ? { ...p, value: x, valueEdited: true } : p, + ), + }) + } + maxLength={128} + /> +
+
+
+ ))} +
+ +
+ set({ ...form, multiple })} + disabled={!isNew} + /> +
+ ); +} + +/** What only some types have: length and lines, bounds, search, options. */ +function TypeFields(c: FieldFormContext) { + const { t, form, set, problems } = c; + if (form.type === "select") return ; + return ( + <> + {form.type === "text" && ( + <> + set({ ...form, maxLength })} + error={ + problems.maxLength + ? t("fields.dialog.errors.maxLength", { max: FIELD_LIMITS.textLength }) + : undefined + } + min={1} + max={FIELD_LIMITS.textLength} + /> + set({ ...form, multiline })} + /> + + )} + {form.type === "number" && ( + <> +
+ set({ ...form, minimum })} + error={ + problems.bounds ? t(`fields.dialog.errors.bounds.${problems.bounds}`) : undefined + } + /> + set({ ...form, maximum })} + /> +
+ set({ ...form, integer })} + /> + + )} + {(form.type === "text" || form.type === "richText") && ( + set({ ...form, searchable })} + /> + )} + + ); +} + +/** The type, the key and the switches every type has. */ +function CommonFields(c: FieldFormContext) { + const { t, form, set, isNew, problems } = c; + const types = Object.fromEntries(FIELD_TYPES.map((type) => [type, t(`fields.types.${type}`)])); + return ( + <> + set({ ...form, key, keyEdited: true })} + error={problems.key ? t(`fields.dialog.errors.key.${problems.key}`) : undefined} + maxLength={40} + /> + set({ ...form, localized })} + disabled={!isNew} + /> + {form.type !== "boolean" && ( + set({ ...form, requiredToPublish })} + /> + )} + + ); +} + +/** + * "Add field" and "Edit field" (RFC-0041 §7): type, label and help per site language, the key + * (derived from the label and editable until saved), per-language values, "Needed to publish" and + * the type's options. A saved field keeps its key, type, localization and `multiple` here: those + * change through the operation dialogs. The dialog edits the builder's draft only. + */ +export function FieldDialog({ + open, + onOpenChange, + initial, + isNew, + taken, + defaultLocale, + locales, + onApply, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The field being edited; none adds one. */ + initial: FieldDefinition | undefined; + /** Not saved yet: the key, type and localization can still change. */ + isNew: boolean; + /** Keys the collection's other fields use. */ + taken: readonly string[]; + defaultLocale: string; + locales: readonly string[]; + onApply: (def: FieldDefinition) => void; +}) { + const { t, locale } = useI18n("fields"); + const [form, setForm] = useState(() => emptyForm("text", locales)); + const [submitted, setSubmitted] = useState(false); + useEffect(() => { + if (!open) return; + setForm(initial ? formOf(initial, locales) : emptyForm("text", locales)); + setSubmitted(false); + }, [open]); + const problems = formProblems(form, { defaultLocale, taken, isNew }); + const ctx: FieldFormContext = { + t, + form, + set: setForm, + locales, + defaultLocale, + uiLocale: locale, + isNew, + problems: submitted ? problems : {}, + }; + const submit = () => { + setSubmitted(true); + if (hasProblems(problems)) return; + onApply(definitionOf(form)); + onOpenChange(false); + }; + return ( + + + + {initial ? t("fields.dialog.editTitle") : t("fields.dialog.addTitle")} + + {t("fields.dialog.description")} + +
+ + +
+
+ + + {initial ? t("fields.dialog.apply") : t("fields.dialog.add")} + + +
+
+ ); +} diff --git a/apps/studio/src/components/fields/FieldImage.tsx b/apps/studio/src/components/fields/FieldImage.tsx new file mode 100644 index 00000000..303e4a2b --- /dev/null +++ b/apps/studio/src/components/fields/FieldImage.tsx @@ -0,0 +1,106 @@ +import type { FieldImageSlot } from "@cloudhal/ui"; +import { Button, Field, ImagePreview, InsetPanel, SkeletonLine, Text } from "@cloudhal/ui"; +import { Images, Trash } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useI18n } from "../../i18n/index.tsx"; +import { api } from "../../lib/api.ts"; +import { assetMeta, assetPreviewUrl, assetTitle } from "../../lib/media.ts"; +import { AssetPickerDialog } from "../MediaPicker.tsx"; + +/** + * An image field (RFC-0041 §2): the chosen media-library image as a thumbnail with its title, and + * "Choose image" / "Change" / "Remove"; the value is the bare asset id (the alt text is the + * asset's own, per language). The picker is the editor's (`AssetPickerDialog`). + */ +export function FieldImage({ + siteId, + defaultLocale, + slot, +}: { + siteId: string; + defaultLocale: string; + slot: FieldImageSlot; +}) { + const { t, locale } = useI18n("fields", "media"); + const [open, setOpen] = useState(false); + const id = slot.value; + const detail = useQuery({ + queryKey: ["asset", siteId, id], + queryFn: () => api.assets.get(siteId, id as string), + enabled: Boolean(id), + }); + const asset = detail.data?.asset; + return ( + + + + + + {!id ? ( + + {t("fields.image.empty")} + + ) : asset ? ( + <> + + {assetTitle(asset, locale, defaultLocale)} + + + {assetMeta(asset, locale)} + + + ) : detail.isError ? ( + + {t("media.field.missing")} + + ) : ( + + )} + + + {!slot.readOnly && ( + + + {id && ( + + )} + + )} + + { + slot.onChange(item.asset.id); + setOpen(false); + }} + /> + + ); +} diff --git a/apps/studio/src/components/fields/FieldOperationDialog.tsx b/apps/studio/src/components/fields/FieldOperationDialog.tsx new file mode 100644 index 00000000..2741e659 --- /dev/null +++ b/apps/studio/src/components/fields/FieldOperationDialog.tsx @@ -0,0 +1,387 @@ +import type { FieldDefinition } from "@cloudhal/core-contracts"; +import { canConvert, FIELD_TYPES, fieldLabel } from "@cloudhal/core-ts/fields"; +import { Banner, Checkbox, Input, LayerDialog, Loader, Select, Text } from "@cloudhal/ui"; +import { ArrowCounterClockwise, Camera, CheckCircle, Warning } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { type TFunction, useI18n } from "../../i18n/index.tsx"; +import type { FieldOperationRequest } from "../../lib/api.ts"; +import { apiErrorText } from "../../lib/api-errors.ts"; +import { definitionOf, emptyForm, type FieldForm, formOf } from "../../lib/field-form.ts"; +import { type FieldType, keyProblem } from "../../lib/fields.ts"; +import { OptionFields } from "./FieldDialog.tsx"; +import { FieldOperationReport } from "./FieldOperationReport.tsx"; +import { type FieldOperationState, useFieldOperation } from "./useFieldOperation.ts"; + +type FT = TFunction<"fields">; +export type OperationKind = FieldOperationRequest["op"]; + +/** What an operation dialog was opened for: a field and an operation, or a run to pick up. */ +export interface OperationTarget { + kind: OperationKind; + def: FieldDefinition; + /** An operation already started (a closed dialog, another tab, an agent's approved call). */ + resume?: { operationId: string; reverting: boolean } | undefined; +} + +/** The types a field can change to (RFC-0041 §6's table); a select can also flip `multiple`. */ +export const targetTypes = (from: FieldType) => + FIELD_TYPES.filter((to) => (to === from ? to === "select" : canConvert(from, to))); + +interface Inputs { + key: string; + type: FieldType; + options: FieldForm; +} + +function requestOf(target: OperationTarget, inputs: Inputs, clear: boolean): FieldOperationRequest { + const { def } = target; + switch (target.kind) { + case "rename_field": + return { op: "rename_field", from: def.key, to: inputs.key }; + case "set_field_localized": + return { op: "set_field_localized", key: def.key, localized: !def.localized }; + case "remove_field": + return { op: "remove_field", key: def.key }; + default: { + const select = inputs.type === "select" ? definitionOf(inputs.options) : undefined; + return { + op: "change_field_type", + key: def.key, + type: inputs.type, + ...(select?.type === "select" + ? { options: select.options, multiple: select.multiple === true } + : {}), + ...(clear ? { onInvalid: "clear" as const } : {}), + }; + } + } +} + +/** The inputs of rename and type change; localization and removal have none. */ +function OperationInputs(props: { + t: FT; + target: OperationTarget; + inputs: Inputs; + setInputs: (next: Inputs) => void; + keyError: string | undefined; + locales: readonly string[]; + defaultLocale: string; + uiLocale: string; + disabled: boolean; +}) { + const { t, target, inputs, setInputs } = props; + if (target.kind === "rename_field") + return ( + setInputs({ ...inputs, key })} + error={props.keyError} + maxLength={40} + disabled={props.disabled} + /> + ); + if (target.kind !== "change_field_type") return null; + const types = targetTypes(target.def.type); + return ( +
+ + ); +} + +function NumberControl({ def, label, description, error, value, set, readOnly, placeholder }: Ctl) { + const integer = def.type === "number" && def.integer === true; + return ( + + set(raw.trim() === "" ? undefined : Number.isFinite(Number(raw)) ? Number(raw) : raw) + } + /> + ); +} + +function BooleanControl({ label, description, value, set, readOnly }: Ctl) { + return ( +
+ set(on || undefined)} + /> + {description && ( + + {description} + + )} +
+ ); +} + +function SelectControl(c: Ctl) { + const { def, label, description, error, value, set, readOnly, placeholder, props } = c; + if (def.type !== "select") return null; + const words = (v: string) => optionLabel(def, v, props.locale, props.defaultLocale); + if (def.multiple) { + const chosen = Array.isArray(value) ? value.map(String) : []; + return ( + o.value)} + onValueChange={(next: string[]) => + set(def.options.map((o) => o.value).filter((v) => next.includes(v))) + } + > + {def.options.map((o) => ( + + ))} + + ); + } + return ( + + ); +} + +const CONTROLS: Record ReactNode> = { + text: TextControl, + url: TextControl, + richText: RichTextControl, + number: NumberControl, + boolean: BooleanControl, + date: DateControl, + datetime: DateControl, + image: ImageControl, + select: SelectControl, +}; + +/** Placeholders only fit controls that show text; the rest say it under the control. */ +const HAS_PLACEHOLDER = new Set(["text", "url", "number", "richText"]); + +/** The description: "Needed to publish", help, limits, and where a translation's value comes from. */ +function describe( + def: FieldDefinition, + props: EntryFieldsFormProps, + extra: { inherited: boolean; fallback: string | undefined }, +): ReactNode { + const { labels, locale, defaultLocale, original } = props; + const help = fieldHelp(def, locale, defaultLocale); + const hint = props.hint?.(def); + const required = def.type !== "boolean" && def.requiredToPublish === true; + const words = [help, hint].filter(Boolean).join(" "); + const fallback = + extra.fallback && !HAS_PLACEHOLDER.has(def.type) ? labels.originalValue(extra.fallback) : ""; + if (!required && !words && !extra.inherited && !fallback) return undefined; + return ( + + {required && ( + + {labels.neededToPublish} + + )} + {words && {words}} + {fallback && {fallback}} + {extra.inherited && original && ( + + {labels.fromOriginal} · {labels.openOriginal} + + )} + + ); +} + +/** + * An entry's typed custom fields (RFC-0041 §7, DESIGN.md §2ah): one Kumo control per type — text + * (`Input`, `InputArea` when multiline), rich text (the host's markdown editor, else a markdown + * `InputArea`), number, boolean (`Switch`), date and date-time (`DateTimeField`, the lazy + * `DatePicker` in the Studio locale; date-times in their own zone), URL, image (the host's media + * picker) and select (`Select`, a `Checkbox` group when multiple). Labels and help come from the + * definitions in the Studio language; required fields carry "Needed to publish"; errors show on + * their field. On a translation, non-localized fields show the original's value read-only with a + * link to it, and empty localized ones show the original's value as their placeholder. + */ +export function EntryFieldsForm(props: EntryFieldsFormProps) { + const { definitions, value, onChange, locale, defaultLocale, original, labels } = props; + const context = { + locale, + defaultLocale, + yes: labels.yes, + no: labels.no, + image: labels.originalImage, + }; + return ( +
+ {definitions.map((def) => { + const inherited = original !== undefined && !def.localized; + const own = readField(value, def); + const theirs = original ? readField(original.values, def) : undefined; + const fallback = + original && def.localized && !hasValue(own) && hasValue(theirs) + ? fallbackText(def, theirs, context) + : undefined; + const Control = CONTROLS[def.type]; + return ( +
+ onChange(writeField(value, def, v))} + readOnly={props.disabled === true || inherited} + placeholder={fallback && HAS_PLACEHOLDER.has(def.type) ? fallback : undefined} + props={props} + /> +
+ ); + })} +
+ ); +} diff --git a/packages/ui/src/blocks/entry-fields-model.ts b/packages/ui/src/blocks/entry-fields-model.ts new file mode 100644 index 00000000..c4e7b2aa --- /dev/null +++ b/packages/ui/src/blocks/entry-fields-model.ts @@ -0,0 +1,97 @@ +/** + * The pure part of `EntryFieldsForm` (RFC-0041 §7): reading and writing one typed value in an + * entry's `fields`, through a running field operation's marker, and the words a translation shows + * for its original's value. No React, no English: words come from the caller. + */ +import type { FieldDefinition } from "@cloudhal/core-contracts"; +import { optionLabel } from "@cloudhal/core-ts/fields"; +import { dateTimeFormat, formatList, formatNumber } from "@cloudhal/messages/format"; +import { formatDay } from "./schedule-model.ts"; + +export type FieldValues = Readonly>; + +const isRecord = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** A field's stored value; while a rename runs, the old key's (RFC-0041 §6 phase A). */ +export function readField(values: FieldValues | undefined, def: FieldDefinition): unknown { + if (!values) return undefined; + if (Object.hasOwn(values, def.key)) return values[def.key]; + const from = def.migration?.fromKey; + return from && Object.hasOwn(values, from) ? values[from] : undefined; +} + +/** True when a value counts as set (empty strings and lists are no value). */ +export function hasValue(v: unknown): boolean { + if (v === undefined || v === null || v === "") return false; + return !(Array.isArray(v) && v.length === 0); +} + +/** + * `values` with the field set to `v`, or removed when `v` is no value. A rename's old key goes + * at the same time, so a write never leaves both. + */ +export function writeField( + values: FieldValues | undefined, + def: FieldDefinition, + v: unknown, +): Record { + const next: Record = { ...(values ?? {}) }; + const from = def.migration?.fromKey; + if (from) delete next[from]; + if (hasValue(v)) next[def.key] = v; + else delete next[def.key]; + return next; +} + +/** A `datetime` value (`{ at, timeZone }`) or none. */ +export function instantOf(v: unknown): { at: string; timeZone: string } | undefined { + if (!isRecord(v) || typeof v.at !== "string" || typeof v.timeZone !== "string") return undefined; + return { at: v.at, timeZone: v.timeZone }; +} + +/** What `fallbackText` needs besides the value. */ +export interface FallbackContext { + locale: string; + defaultLocale: string; + yes: string; + no: string; + /** An image field's words ("The original's image"). */ + image: string; +} + +/** + * The original's value as one line of text, in the Studio language: what an empty localized field + * of a translation shows as its placeholder, or under the control when it has none. + */ +export function fallbackText(def: FieldDefinition, v: unknown, ctx: FallbackContext): string { + if (!hasValue(v) && def.type !== "boolean") return ""; + switch (def.type) { + case "boolean": + return v === true ? ctx.yes : ctx.no; + case "number": + return typeof v === "number" ? formatNumber(ctx.locale, v) : String(v); + case "date": + return typeof v === "string" ? formatDay(v, ctx.locale, "medium") : ""; + case "datetime": { + const i = instantOf(v); + if (!i) return ""; + return dateTimeFormat(ctx.locale, { + dateStyle: "medium", + timeStyle: "short", + timeZone: i.timeZone, + }).format(new Date(i.at)); + } + case "select": { + const list = Array.isArray(v) ? v : [v]; + return formatList( + ctx.locale, + list.map((x) => optionLabel(def, String(x), ctx.locale, ctx.defaultLocale)), + ); + } + case "image": + return ctx.image; + default: + return typeof v === "string" ? v : JSON.stringify(v); + } +} diff --git a/packages/ui/src/blocks/zoned-time.ts b/packages/ui/src/blocks/zoned-time.ts new file mode 100644 index 00000000..92a01ed7 --- /dev/null +++ b/packages/ui/src/blocks/zoned-time.ts @@ -0,0 +1,62 @@ +/** + * Wall-clock time in an IANA zone and the instants it stands for, through `Intl` only (no date + * library): what `DateTimeField` edits (RFC-0008 §15.4) and what a typed `datetime` field stores + * (`{ at, timeZone }`, RFC-0041 §2). Studio's schedule screens re-export these from + * `lib/schedules.ts`. + */ +import { zonedParts } from "./schedule-model.ts"; + +const DAY_MS = 86_400_000; +const MINUTE_MS = 60_000; + +/** A date (`YYYY-MM-DD`) and a 24-hour time (`HH:mm`) on a zone's wall clock. */ +export interface WallClock { + date: string; + time: string; +} + +/** The zone's offset from UTC at an instant, in ms (Istanbul: +3 h). */ +export function offsetAt(ms: number, timeZone: string): number { + const p = zonedParts(new Date(ms).toISOString(), timeZone); + const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute); + return wall - Math.floor(ms / MINUTE_MS) * MINUTE_MS; +} + +/** Where an instant is on the wall clock of `timeZone`. */ +export function wallClockOf(at: string, timeZone: string): WallClock { + const p = zonedParts(at, timeZone); + const two = (n: number) => String(n).padStart(2, "0"); + return { + date: `${String(p.year).padStart(4, "0")}-${two(p.month)}-${two(p.day)}`, + time: `${two(p.hour)}:${two(p.minute)}`, + }; +} + +/** + * The instant a wall-clock time in `timeZone` stands for. Times that occur twice (the clock goes + * back) take the first; a time that does not exist (the clock goes forward) moves forward by the + * gap, as the server does (RFC-0008 D2: "02:30 does not exist on 29 March in Berlin; it runs at + * 03:30") and says so in `gap`. + */ +export function zonedToInstant( + { date, time }: WallClock, + timeZone: string, +): { at: string; gap?: { runsAt: string } } { + const [y, m, d] = date.split("-").map(Number) as [number, number, number]; + const [hh, mm] = time.split(":").map(Number) as [number, number]; + const target = Date.UTC(y, m - 1, d, hh, mm); + const offsets = [ + ...new Set([offsetAt(target - DAY_MS, timeZone), offsetAt(target + DAY_MS, timeZone)]), + ]; + const exact = offsets + .map((o) => target - o) + .filter((ms) => { + const w = wallClockOf(new Date(ms).toISOString(), timeZone); + return w.date === date && w.time === time; + }) + .sort((a, b) => a - b); + if (exact[0] !== undefined) return { at: new Date(exact[0]).toISOString() }; + // A gap: the later offset's instant is the time after the jump. + const at = new Date(target - Math.min(...offsets)).toISOString(); + return { at, gap: { runsAt: wallClockOf(at, timeZone).time } }; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ca26db3d..38d5ccb4 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -251,6 +251,14 @@ export { TransitionArrow, type TransitionArrowProps, } from "./blocks/DirectionalIcon.tsx"; +export { + EntryFieldsForm, + type EntryFieldsFormLabels, + type EntryFieldsFormProps, + type EntryFieldsOriginal, + type FieldImageSlot, + type FieldRichTextSlot, +} from "./blocks/EntryFieldsForm.tsx"; export { type FocalPoint, FocalPointPicker, @@ -497,3 +505,9 @@ export { useColorMode, useReducedMotion, } from "./blocks/useChartPalette.ts"; +export { + offsetAt, + type WallClock, + wallClockOf, + zonedToInstant, +} from "./blocks/zoned-time.ts"; diff --git a/packages/ui/test/entry-fields-form.test.tsx b/packages/ui/test/entry-fields-form.test.tsx new file mode 100644 index 00000000..4c9d579e --- /dev/null +++ b/packages/ui/test/entry-fields-form.test.tsx @@ -0,0 +1,236 @@ +/** RFC-0041 §7: an entry's typed fields, one Kumo control per type, translations and errors. */ +import type { FieldDefinition } from "@cloudhal/core-contracts"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + EntryFieldsForm, + type EntryFieldsFormLabels, + type EntryFieldsOriginal, +} from "../src/blocks/EntryFieldsForm.tsx"; +import { fallbackText, readField, writeField } from "../src/blocks/entry-fields-model.ts"; +import { wallClockOf, zonedToInstant } from "../src/blocks/zoned-time.ts"; + +const labels: EntryFieldsFormLabels = { + neededToPublish: "Needed to publish", + fromOriginal: "From the original (English)", + openOriginal: "Open the original", + originalValue: (v) => `The original: ${v}`, + yes: "Yes", + no: "No", + noValue: "No value", + clear: "Clear", + originalImage: "The original's image", + date: { + date: "Date", + pickDate: "Pick a date", + chooseDate: "Choose a date", + invalidTime: "Enter a time such as 09:30", + calendar: { + previousMonth: "Previous month", + nextMonth: "Next month", + navigation: "Months", + day: (date) => date, + }, + }, +}; + +const DEFS: FieldDefinition[] = [ + { + key: "venue", + type: "text", + labels: { en: { label: "Venue", help: "Where it happens" }, tr: { label: "Mekan" } }, + localized: true, + requiredToPublish: true, + maxLength: 80, + }, + { key: "notes", type: "richText", labels: { en: { label: "Notes" } }, localized: true }, + { key: "seats", type: "number", labels: { en: { label: "Seats" } }, localized: false }, + { key: "free", type: "boolean", labels: { en: { label: "Free entry" } }, localized: false }, + { key: "day", type: "date", labels: { en: { label: "Day" } }, localized: false }, + { key: "startsAt", type: "datetime", labels: { en: { label: "Starts at" } }, localized: false }, + { key: "site", type: "url", labels: { en: { label: "Website" } }, localized: true }, + { key: "poster", type: "image", labels: { en: { label: "Poster" } }, localized: false }, + { + key: "format", + type: "select", + labels: { en: { label: "Format" } }, + localized: false, + options: [ + { value: "talk", labels: { en: "Talk", tr: "Konuşma" } }, + { value: "workshop", labels: { en: "Workshop" } }, + ], + }, + { + key: "tracks", + type: "select", + labels: { en: { label: "Tracks" } }, + localized: false, + multiple: true, + options: [ + { value: "web", labels: { en: "Web" } }, + { value: "ai", labels: { en: "AI" } }, + ], + }, +]; + +function Harness({ + initial = {}, + onChange = () => {}, + original, + errors, + locale = "en", +}: { + initial?: Record; + onChange?: (v: Record) => void; + original?: EntryFieldsOriginal; + errors?: Record; + locale?: string; +}) { + const [value, setValue] = useState(initial); + return ( + { + setValue(next); + onChange(next); + }} + locale={locale} + defaultLocale="en" + errors={errors} + original={original} + timeZone="Europe/Istanbul" + zoneLabel={(z) => `zone:${z}`} + labels={labels} + renderImage={(slot) => ( + + )} + /> + ); +} + +const field = (key: string) => document.querySelector(`[data-field="${key}"]`) as HTMLElement; + +afterEach(cleanup); + +describe("EntryFieldsForm", () => { + it("renders one control per type with labels, help and the required marker", () => { + render(); + expect(screen.getByRole("textbox", { name: /Venue/ })).toHaveAttribute("maxLength", "80"); + expect(within(field("venue")).getByText("Needed to publish")).toBeInTheDocument(); + expect(within(field("venue")).getByText("Where it happens")).toBeInTheDocument(); + expect(field("notes").querySelector("textarea")).not.toBeNull(); + expect(screen.getByRole("spinbutton", { name: "Seats" })).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Free entry" })).toBeInTheDocument(); + expect(field("day").querySelector("[data-datetime-field]")).not.toBeNull(); + expect(field("day").querySelector("[data-datetime-time]")).toBeNull(); + expect(field("startsAt").querySelector("[data-datetime-time]")).not.toBeNull(); + expect(within(field("startsAt")).getByText("zone:Europe/Istanbul")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /pick Poster/ })).toBeInTheDocument(); + expect(within(field("tracks")).getAllByRole("checkbox")).toHaveLength(2); + }); + + it("labels follow the Studio language, then the site's default", () => { + render(); + expect(screen.getByRole("textbox", { name: /Mekan/ })).toBeInTheDocument(); + expect(screen.getByRole("spinbutton", { name: "Seats" })).toBeInTheDocument(); + }); + + it("writes typed values and removes emptied ones", async () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox", { name: /Venue/ }), { target: { value: "" } }); + expect(onChange).toHaveBeenLastCalledWith({}); + fireEvent.change(screen.getByRole("spinbutton", { name: "Seats" }), { + target: { value: "120" }, + }); + expect(onChange).toHaveBeenLastCalledWith({ seats: 120 }); + fireEvent.click(screen.getByRole("switch", { name: "Free entry" })); + expect(onChange).toHaveBeenLastCalledWith({ seats: 120, free: true }); + fireEvent.click(within(field("tracks")).getByText("AI")); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ seats: 120, free: true, tracks: ["ai"] }), + ); + fireEvent.click(screen.getByRole("button", { name: /pick Poster/ })); + expect(onChange.mock.lastCall?.[0]).toMatchObject({ poster: "asset_poster1" }); + }); + + it("enters a date and time in the site's zone as { at, timeZone }", () => { + const onChange = vi.fn(); + render( + , + ); + const time = field("startsAt").querySelector("[data-datetime-time]") as HTMLInputElement; + expect(time.value).toMatch(/19:30|7:30/); + fireEvent.change(time, { target: { value: "20:00" } }); + fireEvent.blur(time); + expect(onChange).toHaveBeenLastCalledWith({ + startsAt: { at: "2026-11-05T17:00:00.000Z", timeZone: "Europe/Istanbul" }, + }); + fireEvent.click(within(field("startsAt")).getByRole("button", { name: "Clear" })); + expect(onChange).toHaveBeenLastCalledWith({}); + }); + + it("shows each field's error", () => { + render(); + expect(within(field("venue")).getByText("Needed to publish. Add a value.")).toBeInTheDocument(); + }); + + it("on a translation: shared fields are read-only from the original, empty localized ones show it as a placeholder", () => { + render( + , + ); + const seats = screen.getByRole("spinbutton", { name: "Seats" }); + expect(seats).toBeDisabled(); + expect((seats as HTMLInputElement).value).toBe("40"); + expect(within(field("seats")).getByText(/From the original \(English\)/)).toBeInTheDocument(); + expect(within(field("seats")).getByRole("link", { name: "Open the original" })).toHaveAttribute( + "href", + "/sites/site_1/collections/events/entry_en", + ); + const venue = screen.getByRole("textbox", { name: /Venue/ }); + expect(venue).not.toBeDisabled(); + expect(venue).toHaveAttribute("placeholder", "Main hall"); + expect(screen.getByRole("textbox", { name: "Website" })).toHaveAttribute( + "placeholder", + "https://x.test", + ); + }); +}); + +describe("entry fields model", () => { + const rename = { + ...DEFS[0], + key: "place", + migration: { id: "snap_1", fromKey: "venue" }, + } as FieldDefinition; + it("reads through a rename's marker and drops the old key on write", () => { + expect(readField({ venue: "Hall" }, rename)).toBe("Hall"); + expect(writeField({ venue: "Hall", x: 1 }, rename, "Room")).toEqual({ x: 1, place: "Room" }); + expect(writeField({ place: "Room" }, rename, "")).toEqual({}); + }); + it("words an original's value in the Studio language", () => { + const ctx = { locale: "en", defaultLocale: "en", yes: "Yes", no: "No", image: "Image" }; + const format = DEFS[8] as FieldDefinition; + expect(fallbackText(format, "talk", { ...ctx, locale: "tr" })).toBe("Konuşma"); + expect(fallbackText(DEFS[3] as FieldDefinition, undefined, ctx)).toBe("No"); + expect(fallbackText(DEFS[4] as FieldDefinition, "2026-11-05", ctx)).toBe("Nov 5, 2026"); + }); + it("converts wall-clock times in a zone and back", () => { + const { at } = zonedToInstant({ date: "2026-11-05", time: "19:30" }, "Europe/Istanbul"); + expect(at).toBe("2026-11-05T16:30:00.000Z"); + expect(wallClockOf(at, "Europe/Istanbul")).toEqual({ date: "2026-11-05", time: "19:30" }); + }); +}); diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 2ae62579..0bf11779 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -181,7 +181,7 @@ "lines": 191 }, "apps/studio/src/routes/ArticleEditorPage.tsx::ArticleEditorPage": { - "lines": 469 + "lines": 446 }, "apps/studio/src/routes/ArticlesPage.tsx::ArticlesPage": { "lines": 197