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
5 changes: 5 additions & 0 deletions .changeset/editorial-workflow-save-metrics.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/tinacms/src/lib/posthog/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const BranchDeletedModal = ({
} = useEditorialWorkflow();

const handleCreate = async () => {
const success = await executeWorkflow({
const { success } = await executeWorkflow({
branchName: `tina/${newBranchName}`,
baseBranch,
path,
Expand Down
18 changes: 15 additions & 3 deletions packages/tinacms/src/toolkit/form-builder/create-branch-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -65,7 +67,7 @@ export const CreateBranchModal = ({
tinaForm,
onBaseBranchDeleted,
}: {
safeSubmit: () => Promise<void>;
safeSubmit: (editorialWorkflowChoice?: SaveChoice) => Promise<void>;
close: () => void;
path: string;
values: Record<string, unknown>;
Expand Down Expand Up @@ -132,7 +134,7 @@ export const CreateBranchModal = ({

setIsBranchGuardChecking(false);

const success = await executeWorkflow({
const { success, error } = await executeWorkflow({
branchName: targetBranch,
baseBranch,
path,
Expand All @@ -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();
}
Expand Down Expand Up @@ -179,7 +191,7 @@ export const CreateBranchModal = ({
onSaveToProtectedBranch={() => {
abortBranchGuard();
close();
safeSubmit();
safeSubmit('publish');
}}
showSaveOptions={true}
disablePublish={!!tinaApi.usingProtectedBranch()}
Expand Down
19 changes: 18 additions & 1 deletion packages/tinacms/src/toolkit/form-builder/form-builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -184,7 +186,19 @@ export const FormBuilder: FC<FormBuilderProps> = ({
!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(
Expand Down Expand Up @@ -214,6 +228,7 @@ export const FormBuilder: FC<FormBuilderProps> = ({
cms.alerts.dismiss(alert);
}
}
captureWorkflowChoice(false, errorMsg);
setDeletedBranchModalOpen(true);
return;
}
Expand All @@ -222,10 +237,12 @@ export const FormBuilder: FC<FormBuilderProps> = ({
documentPath: tinaForm.path,
error: errorMsg,
});
captureWorkflowChoice(false, errorMsg);
} else {
captureEvent(SavedContentEvent, {
documentPath: tinaForm.path,
});
captureWorkflowChoice(true);
}
} else {
console.debug(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
/** 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;
}
Expand Down Expand Up @@ -156,9 +158,9 @@ export function useEditorialWorkflow(): UseEditorialWorkflowResult {
tinaForm,
signal,
isDraft,
}: ExecuteWorkflowOptions): Promise<boolean> => {
}: ExecuteWorkflowOptions): Promise<{ success: boolean; error?: string }> => {
try {
if (signal?.aborted) return false;
if (signal?.aborted) return { success: false };

const targetBranchExists = await checkTargetBranchExists(
tinaApi,
Expand All @@ -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);
Expand Down Expand Up @@ -261,7 +263,7 @@ export function useEditorialWorkflow(): UseEditorialWorkflowResult {
}`;
}

return true;
return { success: true };
} catch (e: unknown) {
console.error(e);
const errMessage = getEditorialWorkflowErrorMessage(e);
Expand All @@ -270,7 +272,7 @@ export function useEditorialWorkflow(): UseEditorialWorkflowResult {
setIsExecuting(false);
setCurrentStep(0);

return false;
return { success: false, error: errMessage };
}
};

Expand Down
5 changes: 3 additions & 2 deletions packages/v4/@tinacms/tinacms/_docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ RHF's own `FormProvider`.

`<Field address="title" />` (`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

Expand All @@ -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 |
Expand Down
15 changes: 10 additions & 5 deletions packages/v4/@tinacms/tinacms/_docs/field-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -89,10 +92,11 @@ export const string = (config: Omit<StringFieldSchema, 'type'>): 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 `<Field>`) 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';
Expand Down Expand Up @@ -123,6 +127,7 @@ Hooks:
| Hook | Does |
|---|---|
| `useFieldAddress()` | this field's address |
| `useFieldSchema<T>()` | this field's resolved schema node (render hints, e.g. `step`) |
| `useFieldValue<T>(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) |
Expand Down
122 changes: 122 additions & 0 deletions packages/v4/@tinacms/tinacms/_docs/number-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# The `number` field

A shipped field plugin: a numeric `<input type="number">` 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 `<input type="number">` 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)` | `<label> must be at least <min>` |
| `max` | `.max(max)` | `<label> must be at most <max>` |
| `required` | empty (`undefined`) fails `z.number()` | `<label> is required` |
|| a non-numeric string coerces to `NaN` | `<label> must be a number` |

Zero passes `required` (present, not empty); negatives/decimals round-trip
unchanged; an optional empty value passes as `.optional()`. Runs through the
shared path (`validateField`); see
[`field-plugins.md`](./field-plugins.md#validation--two-layers).

## Component

`NumberField` (`number-field.ui.tsx`) takes **no props** — it reads value/errors
through address-keyed hooks and its own node through `useFieldSchema` to wire
`step`:

```tsx
export function NumberField() {
const address = useFieldAddress();
const field = useFieldSchema<NumberFieldSchema>();
const [value, setValue] = useFieldValue<string | undefined>(address);
const errors = useFieldErrors(address);
const inputRef = useRef<HTMLInputElement>(null);

useFieldActivation(() => inputRef.current?.focus());

return (
<div>
<input ref={inputRef} type='number' step={field.step} aria-label={address}
value={value ?? ''}
onChange={(e) => setValue(e.target.value === '' ? undefined : e.target.value)} />
{errors.map((e) => <span key={e} role='alert'>{e}</span>)}
</div>
);
}
```

## Where it's wired

- Manifest: `number-field.plugin.ts``tina:field:number`, exported as
`numberFieldPlugin`.
- Registration: `plugins/fields/index.ts` adds it to `corePlugins` and exposes
`t.number`.

## Tests

`number-field.test.tsx` covers rendering (including a stored zero), the `step`
wiring, decimal/negative keystrokes, min/max bounds, zero-is-not-empty under
`required`, non-numeric rejection, ingest/digest round-trips, null/empty →
absent, and the registered descriptor metadata.
```
1 change: 1 addition & 0 deletions packages/v4/@tinacms/tinacms/_docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ a *keyed* capability: many field plugins coexist, one per schema `type`
- [Field plugins](./field-plugins.md) — how to build one
- [The `string` field](./string-field.md) — the shipped text input
- [The `boolean` field](./boolean-field.md) — the shipped checkbox
- [The `number` field](./number-field.md) — the shipped numeric input
- [Architecture](./architecture.md) — how a plugin reaches the screen
6 changes: 3 additions & 3 deletions packages/v4/@tinacms/tinacms/src/core/field/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ export interface FieldDescriptor<TValue = unknown, TStored = unknown> {
metadata?: FieldMetadata;
schema?: (node: FieldSchema) => ZodType;
validate?: (value: TValue) => string | null;
// parse/serialize are the per-field ingest/digest transforms. Declared but not
// wired to any built-in field yet (string is identity) — image/datetime/reference
// will use them (e.g. path <-> media object, ISO string <-> Date).
// parse/serialize are the per-field ingest/digest transforms. string/boolean are
// identity; the number field uses them for string <-> number. image/datetime/reference
// will use them too (e.g. path <-> media object, ISO string <-> Date).
parse?: (stored: TStored) => TValue;
serialize?: (value: TValue) => TStored;
}
Loading
Loading