@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 +21 -0
- package/README.md +357 -0
- package/dist/index.cjs +5755 -0
- package/dist/index.d.cts +119 -0
- package/dist/index.d.ts +119 -0
- package/dist/index.js +5796 -0
- package/package.json +80 -0
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.
|