From de4a80771e83afa8502f834227351cff54c5f236 Mon Sep 17 00:00:00 2001 From: "Josh Berman [SSW]" <137844305+joshbermanssw@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:38:20 +1000 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Track=20editorial-workflow=20sa?= =?UTF-8?q?ve=20option=20choice=20in=20PostHog=20(#7140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/editorial-workflow-save-metrics.md | 5 +++++ packages/tinacms/src/lib/posthog/posthog.ts | 10 ++++++++++ .../form-builder/branch-deleted-modal.tsx | 2 +- .../form-builder/create-branch-modal.tsx | 18 +++++++++++++++--- .../src/toolkit/form-builder/form-builder.tsx | 19 ++++++++++++++++++- .../form-builder/use-editorial-workflow.ts | 18 ++++++++++-------- 6 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 .changeset/editorial-workflow-save-metrics.md diff --git a/.changeset/editorial-workflow-save-metrics.md b/.changeset/editorial-workflow-save-metrics.md new file mode 100644 index 0000000000..a1280c05ca --- /dev/null +++ b/.changeset/editorial-workflow-save-metrics.md @@ -0,0 +1,5 @@ +--- +"tinacms": patch +--- + +Add a PostHog `editorial-workflow-save` event that records which save option was used in the "Save changes to new branch" modal (draft, ready for review, or publish), whether the save succeeded, and the failure reason when it didn't. diff --git a/packages/tinacms/src/lib/posthog/posthog.ts b/packages/tinacms/src/lib/posthog/posthog.ts index ec3c3d3769..28be14d304 100644 --- a/packages/tinacms/src/lib/posthog/posthog.ts +++ b/packages/tinacms/src/lib/posthog/posthog.ts @@ -34,6 +34,16 @@ export type SaveContentErrorPayload = { error?: string; }; +// When a user saves from the editorial-workflow modal: which option they +// chose (draft / ready for review / publish) and whether the save succeeded. +export const EditorialWorkflowSaveEvent: string = 'editorial-workflow-save'; +export type EditorialWorkflowSavePayload = { + choice: 'draft' | 'review' | 'publish'; + success: boolean; + // Failure reason when success is false. + error?: string; +}; + // When a user resets a form in the TinaCMS Editor export const FormResetEvent: string = 'form-reset'; diff --git a/packages/tinacms/src/toolkit/form-builder/branch-deleted-modal.tsx b/packages/tinacms/src/toolkit/form-builder/branch-deleted-modal.tsx index aa1dc1549b..3e727173fc 100644 --- a/packages/tinacms/src/toolkit/form-builder/branch-deleted-modal.tsx +++ b/packages/tinacms/src/toolkit/form-builder/branch-deleted-modal.tsx @@ -50,7 +50,7 @@ export const BranchDeletedModal = ({ } = useEditorialWorkflow(); const handleCreate = async () => { - const success = await executeWorkflow({ + const { success } = await executeWorkflow({ branchName: `tina/${newBranchName}`, baseBranch, path, diff --git a/packages/tinacms/src/toolkit/form-builder/create-branch-modal.tsx b/packages/tinacms/src/toolkit/form-builder/create-branch-modal.tsx index b54380958c..58be47e930 100644 --- a/packages/tinacms/src/toolkit/form-builder/create-branch-modal.tsx +++ b/packages/tinacms/src/toolkit/form-builder/create-branch-modal.tsx @@ -27,6 +27,8 @@ import { type SaveChoice, resolveSaveOptions, } from './save-options'; +import { EditorialWorkflowSaveEvent } from '../../lib/posthog/posthog'; +import { captureEvent } from '../../lib/posthog/posthogProvider'; // Format the default branch name by removing content/ prefix and file extension const formatDefaultBranchName = ( @@ -65,7 +67,7 @@ export const CreateBranchModal = ({ tinaForm, onBaseBranchDeleted, }: { - safeSubmit: () => Promise; + safeSubmit: (editorialWorkflowChoice?: SaveChoice) => Promise; close: () => void; path: string; values: Record; @@ -132,7 +134,7 @@ export const CreateBranchModal = ({ setIsBranchGuardChecking(false); - const success = await executeWorkflow({ + const { success, error } = await executeWorkflow({ branchName: targetBranch, baseBranch, path, @@ -146,6 +148,16 @@ export const CreateBranchModal = ({ branchGuardAbortRef.current = null; } + // Cancelled mid-run (modal closed, branch renamed, another save started, or + // unmounted) — treat as a no-op and record nothing. + if (abortController.signal.aborted) return; + + captureEvent(EditorialWorkflowSaveEvent, { + choice: isDraft ? 'draft' : 'review', + success, + error, + }); + if (success) { close(); } @@ -179,7 +191,7 @@ export const CreateBranchModal = ({ onSaveToProtectedBranch={() => { abortBranchGuard(); close(); - safeSubmit(); + safeSubmit('publish'); }} showSaveOptions={true} disablePublish={!!tinaApi.usingProtectedBranch()} diff --git a/packages/tinacms/src/toolkit/form-builder/form-builder.tsx b/packages/tinacms/src/toolkit/form-builder/form-builder.tsx index dc84c9cf37..c531f46248 100644 --- a/packages/tinacms/src/toolkit/form-builder/form-builder.tsx +++ b/packages/tinacms/src/toolkit/form-builder/form-builder.tsx @@ -22,9 +22,11 @@ import { BranchDeletedModal } from './branch-deleted-modal'; import { SavedContentEvent, SaveContentErrorEvent, + EditorialWorkflowSaveEvent, FormResetEvent, } from '../../lib/posthog/posthog'; import { captureEvent } from '../../lib/posthog/posthogProvider'; +import type { SaveChoice } from './save-options'; export interface FormBuilderProps { form: { tinaForm: Form; activeFieldName?: string }; @@ -184,7 +186,19 @@ export const FormBuilder: FC = ({ !hasValidationErrors && !(invalid && !dirtySinceLastSubmit); - const safeSubmit = async () => { + const safeSubmit = async (editorialWorkflowChoice?: SaveChoice) => { + // When invoked as the "Save and publish" choice from the editorial + // workflow modal, also record the choice + outcome in one event. + const captureWorkflowChoice = (success: boolean, error?: string) => { + if (editorialWorkflowChoice) { + captureEvent(EditorialWorkflowSaveEvent, { + choice: editorialWorkflowChoice, + success, + error, + }); + } + }; + if (canSubmit) { const alertsBefore = new Set(cms.alerts.all.map((a) => a.id)); console.debug( @@ -214,6 +228,7 @@ export const FormBuilder: FC = ({ cms.alerts.dismiss(alert); } } + captureWorkflowChoice(false, errorMsg); setDeletedBranchModalOpen(true); return; } @@ -222,10 +237,12 @@ export const FormBuilder: FC = ({ documentPath: tinaForm.path, error: errorMsg, }); + captureWorkflowChoice(false, errorMsg); } else { captureEvent(SavedContentEvent, { documentPath: tinaForm.path, }); + captureWorkflowChoice(true); } } else { console.debug( diff --git a/packages/tinacms/src/toolkit/form-builder/use-editorial-workflow.ts b/packages/tinacms/src/toolkit/form-builder/use-editorial-workflow.ts index 5e6bbe860c..ed4a5d36bb 100644 --- a/packages/tinacms/src/toolkit/form-builder/use-editorial-workflow.ts +++ b/packages/tinacms/src/toolkit/form-builder/use-editorial-workflow.ts @@ -78,8 +78,10 @@ export interface UseEditorialWorkflowResult { errorMessage: string; currentStep: number; elapsedTime: number; - /** Returns true on success, false on failure (error captured in errorMessage) */ - executeWorkflow: (opts: ExecuteWorkflowOptions) => Promise; + /** Resolves with the outcome; on failure `error` holds the message. */ + executeWorkflow: ( + opts: ExecuteWorkflowOptions + ) => Promise<{ success: boolean; error?: string }>; /** Reset error/executing state so the form can be retried */ reset: () => void; } @@ -156,9 +158,9 @@ export function useEditorialWorkflow(): UseEditorialWorkflowResult { tinaForm, signal, isDraft, - }: ExecuteWorkflowOptions): Promise => { + }: ExecuteWorkflowOptions): Promise<{ success: boolean; error?: string }> => { try { - if (signal?.aborted) return false; + if (signal?.aborted) return { success: false }; const targetBranchExists = await checkTargetBranchExists( tinaApi, @@ -167,13 +169,13 @@ export function useEditorialWorkflow(): UseEditorialWorkflowResult { signal ); - if (signal?.aborted) return false; + if (signal?.aborted) return { success: false }; if (targetBranchExists) { setErrorMessage(TARGET_BRANCH_EXISTS_ERROR); setIsExecuting(false); setCurrentStep(0); - return false; + return { success: false, error: TARGET_BRANCH_EXISTS_ERROR }; } setIsExecuting(true); @@ -261,7 +263,7 @@ export function useEditorialWorkflow(): UseEditorialWorkflowResult { }`; } - return true; + return { success: true }; } catch (e: unknown) { console.error(e); const errMessage = getEditorialWorkflowErrorMessage(e); @@ -270,7 +272,7 @@ export function useEditorialWorkflow(): UseEditorialWorkflowResult { setIsExecuting(false); setCurrentStep(0); - return false; + return { success: false, error: errMessage }; } }; From d0d00d83e82499032f9b0cf33bff2a789da9a711 Mon Sep 17 00:00:00 2001 From: "Ivan Gaiduk [SSW]" Date: Fri, 3 Jul 2026 15:34:18 +1000 Subject: [PATCH 2/2] Adding a number field (#7139) Closes #6917 * Number field plugin (schema, client, ui, plugin) with parse/serialize, min/max, step * Adds useFieldSchema() hook so a field can read its own node (wires step) * Tests + docs This notably wires a schema into the field to be exposed through the context --------- Co-authored-by: Jack Pettit [SSW] <57518417+JackDevAU@users.noreply.github.com> --- .../v4/@tinacms/tinacms/_docs/architecture.md | 5 +- .../@tinacms/tinacms/_docs/field-plugins.md | 15 +- .../v4/@tinacms/tinacms/_docs/number-field.md | 122 ++++++++++ packages/v4/@tinacms/tinacms/_docs/plugins.md | 1 + .../tinacms/src/core/field/contract.ts | 6 +- .../@tinacms/tinacms/src/core/form/ingest.ts | 5 +- .../v4/@tinacms/tinacms/src/editor/context.ts | 9 +- .../v4/@tinacms/tinacms/src/editor/field.tsx | 5 +- .../v4/@tinacms/tinacms/src/editor/hooks.ts | 17 +- .../v4/@tinacms/tinacms/src/editor/index.ts | 1 + packages/v4/@tinacms/tinacms/src/index.ts | 1 + .../tinacms/src/plugins/fields/index.ts | 11 +- .../src/plugins/fields/number/.gitkeep | 0 .../fields/number/number-field.client.tsx | 18 ++ .../fields/number/number-field.plugin.ts | 9 + .../fields/number/number-field.schema.ts | 53 +++++ .../fields/number/number-field.test.tsx | 210 ++++++++++++++++++ .../plugins/fields/number/number-field.ui.tsx | 49 ++++ 18 files changed, 518 insertions(+), 19 deletions(-) create mode 100644 packages/v4/@tinacms/tinacms/_docs/number-field.md delete mode 100644 packages/v4/@tinacms/tinacms/src/plugins/fields/number/.gitkeep create mode 100644 packages/v4/@tinacms/tinacms/src/plugins/fields/number/number-field.client.tsx create mode 100644 packages/v4/@tinacms/tinacms/src/plugins/fields/number/number-field.plugin.ts create mode 100644 packages/v4/@tinacms/tinacms/src/plugins/fields/number/number-field.schema.ts create mode 100644 packages/v4/@tinacms/tinacms/src/plugins/fields/number/number-field.test.tsx create mode 100644 packages/v4/@tinacms/tinacms/src/plugins/fields/number/number-field.ui.tsx diff --git a/packages/v4/@tinacms/tinacms/_docs/architecture.md b/packages/v4/@tinacms/tinacms/_docs/architecture.md index 76f83a5231..54f2f4f20a 100644 --- a/packages/v4/@tinacms/tinacms/_docs/architecture.md +++ b/packages/v4/@tinacms/tinacms/_docs/architecture.md @@ -33,8 +33,8 @@ RHF's own `FormProvider`. `` (`editor/field.tsx`) finds the schema node by `name`, reads its `type`, pulls the `descriptor` from the registry, and renders -`descriptor.Component` inside a `FieldAddressContext` carrying the address. The -component takes no props. +`descriptor.Component` inside a `FieldAddressContext` carrying the address and a +`FieldSchemaContext` carrying the resolved node. The component takes no props. ## 4. Read & write through hooks @@ -43,6 +43,7 @@ The component pulls everything from address-keyed hooks (`editor/hooks.ts`): | Hook | Backed by | |---|---| | `useFieldAddress()` | `FieldAddressContext` | +| `useFieldSchema()` | `FieldSchemaContext` (the field's resolved node) | | `useFieldValue(address)` | RHF `useController` → `[value, setValue]` | | `useFieldErrors(address)` | RHF `useFormState`, keyed by field name | | `useFieldActivation(handler)` | fires when the active field equals this address | diff --git a/packages/v4/@tinacms/tinacms/_docs/field-plugins.md b/packages/v4/@tinacms/tinacms/_docs/field-plugins.md index 0511e00efe..b4b0280f3b 100644 --- a/packages/v4/@tinacms/tinacms/_docs/field-plugins.md +++ b/packages/v4/@tinacms/tinacms/_docs/field-plugins.md @@ -26,7 +26,10 @@ below — substitute your own `type` for `string` throughout. For that field's exact config options and validation semantics, see [`string-field.md`](./string-field.md). The shipped `boolean` field ([`boolean-field.md`](./boolean-field.md)) is a second example — a two-state -checkbox where `required` is a no-op. +checkbox where `required` is a no-op. The shipped `number` field +([`number-field.md`](./number-field.md)) is a third — a string editor value ↔ +numeric stored value via `parse`/`serialize`, reading its own config +(`step`) through `useFieldSchema`. ### 1. Manifest (`.plugin.ts`) @@ -89,10 +92,11 @@ export const string = (config: Omit): StringFieldSche ### 4. The component (`.ui.tsx`) -The component receives **only its address** via context and pulls value/errors -through address-keyed hooks (`editor/hooks.ts`). No `value`/`onChange` props: -that keeps each field an O(1) react-hook-form subscription instead of -re-rendering the whole form on a keystroke. +The component receives **its address and its resolved schema node** via context +(both provided by ``) and pulls value/errors through address-keyed hooks +(`editor/hooks.ts`). No `value`/`onChange` props: that keeps each field an O(1) +react-hook-form subscription instead of re-rendering the whole form on a +keystroke. ```tsx import { useRef } from 'react'; @@ -123,6 +127,7 @@ Hooks: | Hook | Does | |---|---| | `useFieldAddress()` | this field's address | +| `useFieldSchema()` | this field's resolved schema node (render hints, e.g. `step`) | | `useFieldValue(address)` | `[value, setValue]` via the RHF controller | | `useFieldErrors(address)` | validation messages at the address | | `useFieldActivation(handler)` | run `handler` when this becomes the active field (visual editing) | diff --git a/packages/v4/@tinacms/tinacms/_docs/number-field.md b/packages/v4/@tinacms/tinacms/_docs/number-field.md new file mode 100644 index 0000000000..557160074e --- /dev/null +++ b/packages/v4/@tinacms/tinacms/_docs/number-field.md @@ -0,0 +1,122 @@ +# The `number` field + +A shipped field plugin: a numeric `` whose **stored value is +a number** but whose **editor value is the raw input string**. Source: +`plugins/fields/number/`. + +## Authoring + +`t.number({...})` stamps `type: 'number'` (`NUMBER_FIELD_TYPE`) onto the config: + +```ts +import { t } from '@tinacms/tinacms'; + +const collection = { + name: 'post', + fields: [ + t.number({ name: 'rating', label: 'Rating', required: true, min: 1, max: 5, step: 0.5 }), + ], +}; +``` + +Config (`NumberFieldSchema`, extends `BaseFieldSchema`): + +| Key | Type | Effect | +|---|---|---| +| `name` | `string` (required) | field key in the document; also the fallback label | +| `label` | `string` | display label; used in validation messages | +| `required` | `boolean` | empty value fails validation | +| `min` | `number` | minimum **value** (not length) | +| `max` | `number` | maximum **value** | +| `step` | `number` | the input's `step` (render hint; no validation role) | + +## Descriptor + +The client segment (`number-field.client.tsx`) claims the `number` key: + +```tsx +defineClientPlugin({ + field: { + type: 'number', // NUMBER_FIELD_TYPE + Component: NumberField, + // no defaultValue — an absent field stays absent + metadata: { layout: 'inline' }, + schema: numberSchema, + parse: (stored) => (stored == null ? undefined : String(stored)), // load: number → string + serialize: (value) => Number(value), // save: string → number + }, +}); +``` + +## Editor value vs stored value + +The DOM gives `` a **string**, but the document stores a +**number**. `parse`/`serialize` (above) bridge them, so partial entries +(`-`, `1.`) survive typing and the document always gets a clean number back. + +Empty is `undefined`, converted in **one place** — the component's `onChange` +(`'' → undefined`). There is no `defaultValue`, and `digestDocument` drops +`undefined` before calling `serialize`, so `serialize` only ever sees a real +string and returns a strict `number` (never `number | undefined`); an empty field +is omitted from the saved document. A stored `null` is normalised to empty by +`parse`, so it round-trips as absent rather than `"null"`/`NaN`. + +## Validation + +`numberSchema(node)` coerces the editor string, then applies the bounds. The +coercion guards empty explicitly — never a falsy test, since `0` is valid and +`Number('') === 0` would smuggle empty in as zero: + +| Config | Rule | Message | +|---|---|---| +| `min` | `.min(min)` | `