From d9816852c52d0ae03323e4f373e51f7161e9b598 Mon Sep 17 00:00:00 2001 From: Xuan Thinh Date: Tue, 10 Nov 2026 20:58:27 +0700 Subject: [PATCH] feat(keystatic): support post-save publishNotice toast and onCommit callback (#1643) Signed-off-by: Xuan Thinh --- .changeset/post-save-publish-notice.md | 5 ++++ packages/keystatic/src/app/updating.test.ts | 28 +++++++++++++++++++++ packages/keystatic/src/app/updating.tsx | 26 +++++++++++++++++++ packages/keystatic/src/config.tsx | 12 +++++++++ 4 files changed, 71 insertions(+) create mode 100644 .changeset/post-save-publish-notice.md create mode 100644 packages/keystatic/src/app/updating.test.ts diff --git a/.changeset/post-save-publish-notice.md b/.changeset/post-save-publish-notice.md new file mode 100644 index 000000000..5a22d7773 --- /dev/null +++ b/.changeset/post-save-publish-notice.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': minor +--- + +Add `ui.publishNotice` and `ui.onCommit` configuration options to display a post-save publishing notice or trigger a custom callback when changes are committed to a remote branch. diff --git a/packages/keystatic/src/app/updating.test.ts b/packages/keystatic/src/app/updating.test.ts new file mode 100644 index 000000000..7904f1b99 --- /dev/null +++ b/packages/keystatic/src/app/updating.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@jest/globals'; +import { formatPublishNoticeMessage } from './updating'; + +test('formatPublishNoticeMessage interpolates branch, sha, and slug placeholders', () => { + const formatted = formatPublishNoticeMessage( + 'Saved to {branch} ({sha}) for {slug} — deployment takes ~2 minutes.', + { + branch: 'main', + sha: 'abc1234', + slug: 'hello-world', + } + ); + expect(formatted).toBe( + 'Saved to main (abc1234) for hello-world — deployment takes ~2 minutes.' + ); +}); + +test('formatPublishNoticeMessage leaves static messages unchanged and handles omitted optional fields', () => { + const formatted = formatPublishNoticeMessage( + 'Changes committed to {branch}. Site rebuilds automatically ({slug}).', + { + branch: 'staging', + } + ); + expect(formatted).toBe( + 'Changes committed to staging. Site rebuilds automatically ().' + ); +}); diff --git a/packages/keystatic/src/app/updating.tsx b/packages/keystatic/src/app/updating.tsx index 0234dfef1..b557de4c2 100644 --- a/packages/keystatic/src/app/updating.tsx +++ b/packages/keystatic/src/app/updating.tsx @@ -1,3 +1,4 @@ +import { toastQueue } from '@keystar/ui/toast'; import { gql } from '@ts-gql/tag/no-transform'; import { assert } from 'emery'; import { useContext, useState } from 'react'; @@ -305,6 +306,21 @@ export function useUpsertItem(args: { const target = result.data?.createCommitOnBranch?.ref?.target; if (target) { setState({ kind: 'updated' }); + const publishMessage = args.config.ui?.publishNotice?.message; + if (publishMessage) { + toastQueue.positive( + formatPublishNoticeMessage(publishMessage, { + branch: branch.branchName, + sha: target.oid, + slug: args.slug?.value, + }) + ); + } + args.config.ui?.onCommit?.({ + branch: branch.branchName, + sha: target.oid, + slug: args.slug?.value, + }); return true; } throw new Error('Failed to update'); @@ -492,3 +508,13 @@ const FetchRef = gql` } } ` as import('../../__generated__/ts-gql/FetchRef').type; + +export function formatPublishNoticeMessage( + message: string, + info: { branch: string; sha?: string; slug?: string } +): string { + return message + .replace(/\{branch\}/g, info.branch) + .replace(/\{sha\}/g, info.sha ?? '') + .replace(/\{slug\}/g, info.slug ?? ''); +} diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 4c95368f3..0f3af811c 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -60,6 +60,16 @@ type BrandMark = (props: { colorScheme: Exclude; // we resolve "auto" to "light" or "dark" on the client }) => ReactElement; export const NAVIGATION_DIVIDER_KEY = '---'; +export type CommitInfo = { + branch: string; + sha: string; + slug?: string; +}; + +export type PublishNoticeConfig = { + message: string; +}; + type UserInterface = { brand?: { mark?: BrandMark; @@ -70,6 +80,8 @@ type UserInterface = { | (keyof Singletons & string) | typeof NAVIGATION_DIVIDER_KEY >; + publishNotice?: PublishNoticeConfig; + onCommit?: (info: CommitInfo) => void; }; type Navigation = K[] | { [section: string]: K[] };