@sanity/workflow-studio-plugin 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sanity, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,357 @@
1
+ # @sanity/workflow-studio-plugin
2
+
3
+ > **Status: private, unpublished.** This README describes the intended
4
+ > consumer setup for when the package is published. Every
5
+ > `@sanity/workflow-*` package is pre-1.0; APIs may change.
6
+
7
+ Editorial workflows inside Sanity Studio. You describe a workflow in code —
8
+ stages like _Drafting → Review → Approved_, and the actions that move work
9
+ between them — deploy it to your dataset, and this plugin gives editors the
10
+ UI: a **workflow strip** above the editor form of mapped documents (the
11
+ current stage, your task count, and a **Start workflow** button), and a
12
+ **Workflows tab** next to the editor with the stage's activities and action
13
+ items, where they fire actions and read the history.
14
+
15
+ Four ideas cover everything in this guide:
16
+
17
+ - A **definition** describes a workflow's stages and actions. It's authored
18
+ in code and deployed as a document into your dataset.
19
+ - An **instance** is one run of a definition, attached to one of your
20
+ documents. Starting a workflow creates an instance; firing actions moves it
21
+ through stages.
22
+ - The **plugin** (this package) is the Studio UI over both.
23
+ - A small **runtime** you host (two Sanity Functions, step 5) handles what
24
+ editors can't: waits that resolve on a timer, and side-effects that should
25
+ run unattended.
26
+
27
+ Everything the plugin checks is **advisory** — it disables the right buttons
28
+ and explains why, but the Content Lake is the only enforcement point. Don't
29
+ treat plugin-side checks as a security boundary.
30
+
31
+ ## 1. Install
32
+
33
+ ```sh
34
+ npm install @sanity/workflow-studio-plugin @sanity/workflow-engine @sanity/workflow-cli
35
+ ```
36
+
37
+ Requires a Studio v5 project (`sanity ^5.30`, `react ^19`,
38
+ `styled-components ^6`, `@sanity/sdk ^2.12` — the usual studio peers).
39
+
40
+ ## 2. Define a workflow
41
+
42
+ A complete, working definition — a document goes _Drafting → Review →
43
+ Approved_; a writer submits, an editor approves. Save as
44
+ `workflows/article-review.ts` next to your studio config:
45
+
46
+ ```ts
47
+ import {defineWorkflow} from '@sanity/workflow-engine/define'
48
+
49
+ export const articleReview = defineWorkflow({
50
+ name: 'article-review',
51
+ title: 'Article review',
52
+ description: 'Draft, review, approve.',
53
+ initialStage: 'drafting',
54
+ fields: [
55
+ // The document this workflow is about. The plugin fills it in when an
56
+ // editor starts the workflow from a document.
57
+ {type: 'doc.ref', name: 'subject', title: 'Article', initialValue: {type: 'input'}},
58
+ ],
59
+ stages: [
60
+ {
61
+ name: 'drafting',
62
+ title: 'Drafting',
63
+ activities: [
64
+ {
65
+ name: 'write',
66
+ title: 'Write the article',
67
+ activation: 'auto',
68
+ // `status: 'done'` = firing this action completes the activity.
69
+ actions: [{name: 'submit', title: 'Submit for review', status: 'done'}],
70
+ },
71
+ ],
72
+ // No filter = the stage advances once all its activities are done.
73
+ transitions: [{name: 'to-review', title: 'Send to review', to: 'review'}],
74
+ },
75
+ {
76
+ name: 'review',
77
+ title: 'Editorial review',
78
+ activities: [
79
+ {
80
+ name: 'review',
81
+ title: 'Review the article',
82
+ activation: 'auto',
83
+ actions: [{name: 'approve', title: 'Approve', status: 'done'}],
84
+ },
85
+ ],
86
+ transitions: [{name: 'to-approved', title: 'Approve', to: 'approved'}],
87
+ },
88
+ // No transitions out = terminal stage. The workflow completes here.
89
+ {name: 'approved', title: 'Approved', activities: []},
90
+ ],
91
+ })
92
+ ```
93
+
94
+ ## 3. Deploy it
95
+
96
+ Save as `sanity.workflow.ts` next to your studio config, filling in your
97
+ project id and dataset:
98
+
99
+ ```ts
100
+ import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
101
+ import {articleReview} from './workflows/article-review'
102
+
103
+ export default defineWorkflowConfig({
104
+ deployments: [
105
+ {
106
+ name: 'production',
107
+ // The tag namespaces all workflow data — the plugin only sees
108
+ // definitions and instances deployed under the tag it's configured with.
109
+ tag: 'production',
110
+ workflowResource: {type: 'dataset', id: '<projectId>.<dataset>'},
111
+ definitions: [articleReview],
112
+ },
113
+ ],
114
+ })
115
+ ```
116
+
117
+ ```sh
118
+ npx sanity-workflows deploy --tag production # add --dry-run to preview
119
+ ```
120
+
121
+ Auth comes from your `sanity login` session (or a `SANITY_AUTH_TOKEN` env
122
+ var). Deploys are idempotent — re-running with an unchanged definition is a
123
+ no-op.
124
+
125
+ ## 4. Wire the studio
126
+
127
+ Add to your `sanity.config.ts`, replacing `article` with one of your own
128
+ document types:
129
+
130
+ ```ts
131
+ import {defineConfig} from 'sanity'
132
+ import {structureTool} from 'sanity/structure'
133
+ import {
134
+ workflowDefaultDocumentNode,
135
+ workflowStudioPlugin,
136
+ type WorkflowMapping,
137
+ } from '@sanity/workflow-studio-plugin'
138
+
139
+ const workflowMappings: readonly WorkflowMapping[] = [
140
+ {
141
+ docType: 'article', // your schema type
142
+ definition: 'article-review', // the deployed definition's `name`
143
+ subjectKind: 'document',
144
+ subjectGdrType: 'article',
145
+ label: 'Article review',
146
+ },
147
+ ]
148
+
149
+ export default defineConfig({
150
+ // ...your projectId, dataset, schema...
151
+ plugins: [
152
+ structureTool({
153
+ // Adds the "Workflows" tab next to the editor for mapped types.
154
+ defaultDocumentNode: workflowDefaultDocumentNode({mappings: workflowMappings}),
155
+ }),
156
+ workflowStudioPlugin({
157
+ tag: 'production', // must match the deploy tag
158
+ mappings: workflowMappings,
159
+ }),
160
+ ],
161
+ })
162
+ ```
163
+
164
+ Start the studio and open an `article`: the workflow strip above the form
165
+ offers **Start workflow**. Once started, the strip shows the current
166
+ stage, the document footer shows an **active workflow** chip, and the
167
+ **Workflows** tab lists the stage's activities — click one to open it and
168
+ fire _Submit for review_, then _Approve_, and watch it reach Approved.
169
+
170
+ ### Stage badges in document lists
171
+
172
+ `WorkflowStagePreview` puts each document's current stage in the list pane's
173
+ status slot (it defers to the default preview for everything else). Wire it
174
+ per mapped schema type via `components.preview`, and select `_id` in the
175
+ type's `preview` so the component knows which document to look up — a custom
176
+ `prepare` must pass `_id` through:
177
+
178
+ ```ts
179
+ import {WorkflowStagePreview} from '@sanity/workflow-studio-plugin'
180
+ import {defineType} from 'sanity'
181
+
182
+ export const article = defineType({
183
+ name: 'article',
184
+ type: 'document',
185
+ fields: [
186
+ /* ... */
187
+ ],
188
+ preview: {
189
+ select: {_id: '_id', title: 'title'},
190
+ prepare: ({_id, title}) => ({_id, title}),
191
+ },
192
+ components: {preview: WorkflowStagePreview},
193
+ })
194
+ ```
195
+
196
+ Plugin options, for later: `workflowDataset` (keep workflow state in a
197
+ separate dataset), `effectHandlers` (run effect side-effects in the browser
198
+ — the runtime below is usually the better home), and per-mapping `mandatory`
199
+ (gate the editor form until a workflow is attached), `initialStateBuilder`,
200
+ and `perspectiveField` (bind a release to the workflow).
201
+
202
+ ## 5. Add the runtime (two Sanity Functions)
203
+
204
+ Everything above works with editors driving. Two things need a caller when
205
+ no editor is looking, and both are small
206
+ [Sanity Functions](https://www.sanity.io/docs/functions):
207
+
208
+ - **heartbeat** — definitions can wait on time (`completeWhen` conditions
209
+ over `$now`, e.g. an embargo date). Time passing emits no event, so a
210
+ schedule must periodically `tick` in-flight instances. Ticking an instance
211
+ with nothing due is a no-op.
212
+ - **drain-effects** — definitions can queue **effects** (named side-effects
213
+ like "publish the subject"). This function runs your handlers for them
214
+ whenever an instance has pending effects. Effects it has no handler for
215
+ stay pending; completing one re-triggers the function, so cascades drain
216
+ themselves.
217
+
218
+ `functions/heartbeat/index.ts`:
219
+
220
+ ```ts
221
+ import {createClient} from '@sanity/client'
222
+ import {scheduledEventHandler} from '@sanity/functions'
223
+ import {createEngine, type WorkflowClient} from '@sanity/workflow-engine'
224
+
225
+ const TAG = 'production'
226
+
227
+ export const handler = scheduledEventHandler(async ({context}) => {
228
+ const projectId = process.env.SANITY_PROJECT_ID
229
+ const dataset = process.env.SANITY_DATASET ?? 'production'
230
+ if (!projectId) throw new Error('heartbeat: SANITY_PROJECT_ID is required')
231
+
232
+ const client = createClient({
233
+ projectId,
234
+ dataset,
235
+ apiVersion: '2026-04-29',
236
+ useCdn: false,
237
+ perspective: 'raw',
238
+ token: context.clientOptions?.token,
239
+ })
240
+ const engine = createEngine({
241
+ client: client as unknown as WorkflowClient,
242
+ tag: TAG,
243
+ workflowResource: {type: 'dataset', id: `${projectId}.${dataset}`},
244
+ })
245
+
246
+ const inflight = await engine.query<{_id: string}[]>({
247
+ groq: `*[_type == "sanity.workflow.instance" && tag == $tag && !defined(completedAt)]{_id}`,
248
+ })
249
+ for (const {_id} of inflight) {
250
+ await engine.tick({instanceId: _id})
251
+ }
252
+ })
253
+ ```
254
+
255
+ `functions/drain-effects/index.ts`:
256
+
257
+ ```ts
258
+ import {createClient} from '@sanity/client'
259
+ import {documentEventHandler} from '@sanity/functions'
260
+ import {createEngine, type EffectHandler, type WorkflowClient} from '@sanity/workflow-engine'
261
+
262
+ const TAG = 'production'
263
+
264
+ // Your side-effects, keyed by the effect names your definitions queue.
265
+ const effectHandlers: Record<string, EffectHandler> = {
266
+ // 'publish.article': async (params, ctx) => { ... },
267
+ }
268
+
269
+ export const handler = documentEventHandler(async ({event, context}) => {
270
+ const projectId = process.env.SANITY_PROJECT_ID
271
+ const dataset = process.env.SANITY_DATASET ?? 'production'
272
+ if (!projectId) throw new Error('drain-effects: SANITY_PROJECT_ID is required')
273
+
274
+ const client = createClient({
275
+ projectId,
276
+ dataset,
277
+ apiVersion: '2026-04-29',
278
+ useCdn: false,
279
+ perspective: 'raw',
280
+ token: context.clientOptions?.token,
281
+ })
282
+ const engine = createEngine({
283
+ client: client as unknown as WorkflowClient,
284
+ tag: TAG,
285
+ workflowResource: {type: 'dataset', id: `${projectId}.${dataset}`},
286
+ effectHandlers,
287
+ // Leave effects this runtime has no handler for pending, for another
288
+ // runtime (or the Studio's manual controls) to resolve.
289
+ missingHandler: 'skip',
290
+ })
291
+
292
+ await engine.drainEffects({instanceId: event.data._id as string})
293
+ })
294
+ ```
295
+
296
+ Each function directory needs its own `package.json` depending on
297
+ `@sanity/functions`, `@sanity/client`, and `@sanity/workflow-engine`.
298
+
299
+ `sanity.blueprint.ts` at the project root declares both, plus the robot
300
+ token they run under:
301
+
302
+ ```ts
303
+ import {
304
+ defineBlueprint,
305
+ defineDocumentFunction,
306
+ defineRobotToken,
307
+ defineScheduledFunction,
308
+ } from '@sanity/blueprints'
309
+
310
+ const projectId = process.env.SANITY_PROJECT_ID ?? ''
311
+ const dataset = process.env.SANITY_DATASET ?? 'production'
312
+
313
+ export default defineBlueprint({
314
+ resources: [
315
+ defineRobotToken({
316
+ name: 'wf-robot',
317
+ label: 'Workflow runtime',
318
+ memberships: [{resourceType: 'project', resourceId: projectId, roleNames: ['editor']}],
319
+ }),
320
+ defineDocumentFunction({
321
+ name: 'drain-effects',
322
+ src: './functions/drain-effects',
323
+ event: {
324
+ on: ['create', 'update'],
325
+ filter: "_type == 'sanity.workflow.instance' && count(pendingEffects[!defined(claim)]) > 0",
326
+ projection: '{_id}',
327
+ resource: {type: 'dataset', id: `${projectId}.${dataset}`},
328
+ },
329
+ }),
330
+ defineScheduledFunction({
331
+ name: 'heartbeat',
332
+ src: './functions/heartbeat',
333
+ event: {expression: '* * * * *'}, // every minute; loosen to taste
334
+ robotToken: '$.resources.wf-robot.token',
335
+ }),
336
+ ],
337
+ })
338
+ ```
339
+
340
+ ```sh
341
+ npx sanity blueprints deploy
342
+ ```
343
+
344
+ Note: scheduled functions currently require an **organization-scoped**
345
+ stack — see Sanity's Functions documentation for stack setup. Concurrent
346
+ runtimes are safe: ticks are idempotent, pending effects carry claims, and
347
+ `missingHandler: 'skip'` keeps runtimes out of each other's effects.
348
+
349
+ ## Limitations
350
+
351
+ - **Advisory enforcement.** The plugin's gates and locks are UI; only
352
+ lake-side rules actually block writes.
353
+ - **Studio actions run on the editor's token** — including workflow
354
+ bookkeeping. The runtime's robot token covers the unattended paths.
355
+ - **Failed effects settle as failed** (workflows proceed rather than
356
+ strand) and can't be retried from the Studio — run consequential effects
357
+ in the runtime, not the browser.