sanity-plugin-workflow 1.0.0-beta.9 → 1.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/README.md CHANGED
@@ -1,18 +1,14 @@
1
1
  > This is a **Sanity Studio v3** plugin.
2
2
 
3
- ## Installation
4
-
5
- ```sh
6
- npm install sanity-plugin-workflow
7
- ```
8
-
9
3
  # Sanity Workflow Demo Plugin Example
10
4
 
11
5
  With Sanity Studio you can [customize your content tools to support arbitrary workflows like assignment and content pipelines](https://www.sanity.io/docs/custom-workflows).
12
6
 
13
- This plugin is distributed as a **reference implementation** of these customization APIs and is not considered to be a feature-complete implementation of what workflow management requires in production. It is a starting point intended to be forked and customized to the needs of your organization and content creators.
7
+ This plugin is distributed as an **example implementation** of customization APIs in the Sanity Studio V3 and is not considered to be a feature-complete implementation of what workflow management requires in production. It is meant as a starting point intended to be forked and customized to the needs of your organization and content creators, or simply as an illustration of what is possible in Sanity Studio V3.
8
+
9
+ An intentional design choice of this plugin is that it **does not influence or modify whether a document is in draft or published**. It only tracks the values of a separate "metadata" document. In this implementation, an "Approved" document could be a draft but will still need publishing. "Approving" the document deletes the "metadata" and so removes it from the "Workflow" process. You choose if Publishing the document happens in the Studio like normal, using the [Scheduled Publishing plugin](https://www.sanity.io/plugins/scheduled-publishing) or the [Scheduling API](https://www.sanity.io/docs/scheduling-api#fa3bb95f83ed).
14
10
 
15
- A key intention of this plugin is that it **does not influence or modify whether a document is in draft or published**. It only tracks the values of a separate "metadata" document. In this implementation, an "Approved" document could be a draft but will still need publishing. "Approving" the document simply removes it from the Workflow process. You will decide if Publishing the document happens in the Studio like normal, using the [Scheduled Publishing plugin](https://www.sanity.io/plugins/scheduled-publishing) or the [Scheduling API](https://www.sanity.io/docs/scheduling-api#fa3bb95f83ed).
11
+ This plugin is considered finished in its current form. Your feedback for workflow features you would like to see in Sanity Studio would be appreciated and can be [shared in our Slack community](https://slack.sanity.io/).
16
12
 
17
13
  ![Screenshot 2023-03-21 at 12 11 24](https://user-images.githubusercontent.com/9684022/226602179-5bd3d91a-9c27-431e-be18-3c70f06c6ccb.png)
18
14
 
@@ -28,13 +24,13 @@ This work demonstrates how a single plugin can define:
28
24
  ## Installation
29
25
 
30
26
  ```
31
- npm install --save sanity-plugin-workflow@beta
27
+ npm install --save sanity-plugin-workflow
32
28
  ```
33
29
 
34
30
  or
35
31
 
36
32
  ```
37
- yarn add sanity-plugin-workflow@beta
33
+ yarn add sanity-plugin-workflow
38
34
  ```
39
35
 
40
36
  ## Usage
@@ -61,9 +57,9 @@ Add it as a plugin in sanity.config.ts (or .js):
61
57
 
62
58
  ## Configuring "States"
63
59
 
64
- The plugin comes with a default set of "States". These are tracked by the plugin creating a separate "metadata" document for each document that has begun the Workflow.
60
+ The plugin comes with a default set of "States". These are tracked by the plugin creating a separate "metadata" document for each document that has begun the Workflow.
65
61
 
66
- Documents can be promoted and demoted in the Workflow with the provided Document Actions as well as a drag-and-drop custom Tool. The settings below are not enforced by the API, custom access control rules could be used to do so.
62
+ Documents can be promoted and demoted in the Workflow with the provided Document Actions as well as a drag-and-drop custom Tool. The settings below are not enforced by the API, custom access control rules could be used to do so.
67
63
 
68
64
  ```ts
69
65
  {
@@ -78,7 +74,7 @@ Documents can be promoted and demoted in the Workflow with the provided Document
78
74
  // Requires the user to be "assigned" in order to update to this State
79
75
  requireAssignment: true,
80
76
  // Requires the document to be valid before being promoted out of this State
81
- // Warning: With many documents in the Kanban view this can negatively impact performance
77
+ // Warning: With many documents in the Kanban view this can negatively impact performance
82
78
  requireValidation: true,
83
79
  // Defines which States a document can be moved to from this one
84
80
  transitions: ['changesRequested', 'approved']
@@ -103,12 +99,12 @@ Once the Workflow is complete, the metadata can be removed by using the "Complet
103
99
 
104
100
  This plugin is largely based on the original Workflow Demo built into a Sanity Studio v2 project. The major differences are:
105
101
 
106
- * This plugin is not concerned with nor will modify whether a document is in draft or published.
107
- * This plugin can be more easily installed and configured, not just code examples built into a Studio project.
108
- * Documents must "opt-in" to and be removed from the Workflow. In the previous version, all documents were in the workflow which would fill up the interface and negatively affect performance.
109
- * Document validation status can be used as a way to prevent movement through the workflow.
110
- * User Roles and Assignments can affect the Workflow. Set rules to enforce which States documents can move between and if being assigned to a document is required to move it to a new State.
111
- * This plugin can filter Schema types and assigned Users.
102
+ - This plugin is not concerned with nor will modify whether a document is in draft or published.
103
+ - This plugin can be more easily installed and configured.
104
+ - Documents must "opt-in" to and be removed from the Workflow. In the previous version, all documents were in the workflow which would fill up the interface and negatively affect performance.
105
+ - Document validation status can be used as a way to prevent movement through the workflow.
106
+ - User Roles and Assignments can affect the Workflow. Set rules to enforce which States documents can move between and if being assigned to a document is required to move it to a new State. These are only enforced in the Studio and not the API.
107
+ - This plugin can filter Schema types and assigned Users.
112
108
 
113
109
  ## License
114
110
 
package/lib/index.esm.js CHANGED
@@ -1,9 +1,9 @@
1
1
  var _templateObject, _templateObject2, _templateObject3;
2
2
  function _taggedTemplateLiteral(strings, raw) { if (!raw) { raw = strings.slice(0); } return Object.freeze(Object.defineProperties(strings, { raw: { value: Object.freeze(raw) } })); }
3
- import { useClient, useCurrentUser, useValidationStatus, useSchema, Preview, useFormValue, defineType, defineField, UserAvatar, useTimeAgo, TextWithTone, definePlugin } from 'sanity';
3
+ import { useClient, useCurrentUser, useValidationStatus, useSchema, Preview, useFormValue, defineType, defineField, UserAvatar, useTimeAgo, TextWithTone, definePlugin, isObjectInputProps } from 'sanity';
4
4
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
5
5
  import { UsersIcon, SplitVerticalIcon, CheckmarkIcon, ArrowRightIcon, ArrowLeftIcon, EditIcon, AddIcon, PublishIcon, ErrorOutlineIcon, WarningOutlineIcon, DragHandleIcon, UserIcon, ResetIcon, InfoOutlineIcon } from '@sanity/icons';
6
- import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
6
+ import React, { useMemo, createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
7
7
  import { UserSelectMenu, useListeningQuery, useProjectUsers, Feedback } from 'sanity-plugin-utils';
8
8
  import { useToast, Button, Spinner, Card, Flex, Box, Text, useClickOutside, Popover, Grid, Tooltip, useTheme, Stack, MenuButton, Menu, Badge, Container } from '@sanity/ui';
9
9
  import { LexoRank } from 'lexorank';
@@ -122,58 +122,99 @@ function UserAssignment(props) {
122
122
  onRemove: removeAssignee
123
123
  });
124
124
  }
125
- function useWorkflowMetadata(id, states) {
125
+ function useWorkflowMetadata(ids) {
126
126
  const {
127
- data: metadata,
127
+ data: rawData,
128
128
  loading,
129
129
  error
130
- } = useListeningQuery("*[_type == \"workflow.metadata\" && documentId == $id][0]", {
130
+ } = useListeningQuery("*[_type == \"workflow.metadata\" && documentId in $ids]{\n _id,\n _type,\n _rev,\n assignees,\n documentId,\n state,\n orderRank\n }", {
131
131
  params: {
132
- id
132
+ ids
133
+ },
134
+ options: {
135
+ apiVersion: API_VERSION
133
136
  }
134
137
  });
135
- if (metadata == null ? void 0 : metadata.state) {
136
- return {
137
- data: {
138
- metadata,
139
- state: states.find(s => s.id === metadata.state)
140
- },
141
- loading,
142
- error
143
- };
144
- }
138
+ const keyedMetadata = useMemo(() => {
139
+ if (!rawData || rawData.length === 0) return {};
140
+ return rawData.reduce((acc, cur) => {
141
+ return {
142
+ ...acc,
143
+ [cur.documentId]: cur
144
+ };
145
+ }, {});
146
+ }, [rawData]);
145
147
  return {
146
- data: {},
148
+ data: keyedMetadata,
147
149
  loading,
148
150
  error
149
151
  };
150
152
  }
151
- function AssignWorkflow(props, states) {
152
- var _a, _b;
153
+ const WorkflowContext = createContext({
154
+ data: {},
155
+ loading: false,
156
+ error: false,
157
+ ids: [],
158
+ addId: () => null,
159
+ removeId: () => null,
160
+ ...DEFAULT_CONFIG
161
+ });
162
+ function useWorkflowContext(id) {
163
+ const current = useContext(WorkflowContext);
164
+ return {
165
+ ...current,
166
+ metadata: id ? current.data[id] : null
167
+ };
168
+ }
169
+ function WorkflowProvider(props) {
170
+ const [ids, setIds] = useState([]);
171
+ const addId = useCallback(id => setIds(current => current.includes(id) ? current : [...current, id]), []);
172
+ const removeId = useCallback(id => setIds(current => current.filter(i => i !== id)), []);
173
+ const {
174
+ data,
175
+ loading,
176
+ error
177
+ } = useWorkflowMetadata(ids);
178
+ return /* @__PURE__ */jsx(WorkflowContext.Provider, {
179
+ value: {
180
+ data,
181
+ loading,
182
+ error,
183
+ ids,
184
+ addId,
185
+ removeId,
186
+ states: props.workflow.states,
187
+ schemaTypes: props.workflow.schemaTypes
188
+ },
189
+ children: props.renderDefault(props)
190
+ });
191
+ }
192
+ function AssignWorkflow(props) {
193
+ var _a;
153
194
  const {
154
195
  id
155
196
  } = props;
197
+ const {
198
+ metadata,
199
+ loading,
200
+ error
201
+ } = useWorkflowContext(id);
156
202
  const [isDialogOpen, setDialogOpen] = useState(false);
157
203
  const userList = useProjectUsers({
158
204
  apiVersion: API_VERSION
159
205
  });
160
- const {
161
- data,
162
- loading,
163
- error
164
- } = useWorkflowMetadata(id, states);
165
206
  if (error) {
166
207
  console.error(error);
167
208
  }
168
- if (!(data == null ? void 0 : data.metadata)) {
209
+ if (!metadata) {
169
210
  return null;
170
211
  }
171
212
  return {
172
213
  icon: UsersIcon,
173
214
  type: "dialog",
174
- disabled: !data || loading || error,
215
+ disabled: !metadata || loading || error,
175
216
  label: "Assign",
176
- title: data ? null : "Document is not in Workflow",
217
+ title: metadata ? null : "Document is not in Workflow",
177
218
  dialog: isDialogOpen && {
178
219
  type: "popover",
179
220
  onClose: () => {
@@ -181,7 +222,7 @@ function AssignWorkflow(props, states) {
181
222
  },
182
223
  content: /* @__PURE__ */jsx(UserAssignment, {
183
224
  userList,
184
- assignees: (_b = (_a = data.metadata) == null ? void 0 : _a.assignees) != null ? _b : [],
225
+ assignees: ((_a = metadata == null ? void 0 : metadata.assignees) == null ? void 0 : _a.length) > 0 ? metadata.assignees : [],
185
226
  documentId: id
186
227
  })
187
228
  },
@@ -190,16 +231,17 @@ function AssignWorkflow(props, states) {
190
231
  }
191
232
  };
192
233
  }
193
- function BeginWorkflow(props, states) {
234
+ function BeginWorkflow(props) {
194
235
  const {
195
236
  id,
196
237
  draft
197
238
  } = props;
198
239
  const {
199
- data,
240
+ metadata,
200
241
  loading,
201
- error
202
- } = useWorkflowMetadata(id, states);
242
+ error,
243
+ states
244
+ } = useWorkflowContext(id);
203
245
  const client = useClient({
204
246
  apiVersion: API_VERSION
205
247
  });
@@ -220,10 +262,6 @@ function BeginWorkflow(props, states) {
220
262
  documentId: id,
221
263
  state: states[0].id,
222
264
  orderRank: lowestOrderFirstState ? LexoRank.parse(lowestOrderFirstState).genNext().toString() : LexoRank.min().toString()
223
- },
224
- // Faster!
225
- {
226
- visibility: "async"
227
265
  }).then(() => {
228
266
  toast.push({
229
267
  status: "success",
@@ -234,29 +272,29 @@ function BeginWorkflow(props, states) {
234
272
  setComplete(true);
235
273
  });
236
274
  }, [id, states, client, toast]);
237
- if (!draft || complete || data.metadata) {
275
+ if (!draft || complete || metadata) {
238
276
  return null;
239
277
  }
240
278
  return {
241
279
  icon: SplitVerticalIcon,
242
280
  type: "dialog",
243
- disabled: (data == null ? void 0 : data.metadata) || loading || error || beginning || complete,
281
+ disabled: metadata || loading || error || beginning || complete,
244
282
  label: beginning ? "Beginning..." : "Begin Workflow",
245
283
  onHandle: () => {
246
284
  handle();
247
285
  }
248
286
  };
249
287
  }
250
- function CompleteWorkflow(props, states) {
251
- var _a;
288
+ function CompleteWorkflow(props) {
252
289
  const {
253
290
  id
254
291
  } = props;
255
292
  const {
256
- data,
293
+ metadata,
257
294
  loading,
258
- error
259
- } = useWorkflowMetadata(id, states);
295
+ error,
296
+ states
297
+ } = useWorkflowContext(id);
260
298
  const client = useClient({
261
299
  apiVersion: API_VERSION
262
300
  });
@@ -266,10 +304,11 @@ function CompleteWorkflow(props, states) {
266
304
  const handle = useCallback(() => {
267
305
  client.delete("workflow-metadata.".concat(id));
268
306
  }, [id, client]);
269
- const isLastState = ((_a = data == null ? void 0 : data.state) == null ? void 0 : _a.id) === states[states.length - 1].id;
270
- if (!data.metadata) {
307
+ if (!metadata) {
271
308
  return null;
272
309
  }
310
+ const state = states.find(s => s.id === metadata.state);
311
+ const isLastState = (state == null ? void 0 : state.id) === states[states.length - 1].id;
273
312
  return {
274
313
  icon: CheckmarkIcon,
275
314
  type: "dialog",
@@ -285,7 +324,7 @@ function CompleteWorkflow(props, states) {
285
324
  function arraysContainMatchingString(one, two) {
286
325
  return one.some(item => two.includes(item));
287
326
  }
288
- function UpdateWorkflow(props, allStates, actionState) {
327
+ function UpdateWorkflow(props, actionState) {
289
328
  var _a, _b, _c, _d;
290
329
  const {
291
330
  id,
@@ -298,16 +337,15 @@ function UpdateWorkflow(props, allStates, actionState) {
298
337
  const toast = useToast();
299
338
  const currentUser = useCurrentUser();
300
339
  const {
301
- data,
340
+ metadata,
302
341
  loading,
303
- error
304
- } = useWorkflowMetadata(id, allStates);
305
- const {
306
- state: currentState
307
- } = data;
342
+ error,
343
+ states
344
+ } = useWorkflowContext(id);
345
+ const currentState = states.find(s => s.id === (metadata == null ? void 0 : metadata.state));
308
346
  const {
309
347
  assignees = []
310
- } = (_a = data == null ? void 0 : data.metadata) != null ? _a : {};
348
+ } = metadata != null ? metadata : {};
311
349
  const {
312
350
  validation,
313
351
  isValidating
@@ -334,21 +372,21 @@ function UpdateWorkflow(props, allStates, actionState) {
334
372
  });
335
373
  });
336
374
  };
337
- if (!data.metadata || currentState && currentState.id === actionState.id) {
375
+ if (!metadata || currentState && currentState.id === actionState.id) {
338
376
  return null;
339
377
  }
340
- const currentStateIndex = allStates.findIndex(s => s.id === (currentState == null ? void 0 : currentState.id));
341
- const actionStateIndex = allStates.findIndex(s => s.id === actionState.id);
378
+ const currentStateIndex = states.findIndex(s => s.id === (currentState == null ? void 0 : currentState.id));
379
+ const actionStateIndex = states.findIndex(s => s.id === actionState.id);
342
380
  const direction = actionStateIndex > currentStateIndex ? "promote" : "demote";
343
381
  const DirectionIcon = direction === "promote" ? ArrowRightIcon : ArrowLeftIcon;
344
382
  const directionLabel = direction === "promote" ? "Promote" : "Demote";
345
- const userRoleCanUpdateState = ((_b = user == null ? void 0 : user.roles) == null ? void 0 : _b.length) && ((_c = actionState == null ? void 0 : actionState.roles) == null ? void 0 : _c.length) ?
383
+ const userRoleCanUpdateState = ((_a = user == null ? void 0 : user.roles) == null ? void 0 : _a.length) && ((_b = actionState == null ? void 0 : actionState.roles) == null ? void 0 : _b.length) ?
346
384
  // If the Action state is limited to specific roles
347
385
  // check that the current user has one of those roles
348
386
  arraysContainMatchingString(user.roles.map(r => r.name), actionState.roles) :
349
387
  // No roles specified on the next state, so anyone can update
350
- ((_d = actionState == null ? void 0 : actionState.roles) == null ? void 0 : _d.length) !== 0;
351
- const actionStateIsAValidTransition = (currentState == null ? void 0 : currentState.id) && currentState.transitions.length ?
388
+ ((_c = actionState == null ? void 0 : actionState.roles) == null ? void 0 : _c.length) !== 0;
389
+ const actionStateIsAValidTransition = (currentState == null ? void 0 : currentState.id) && ((_d = currentState == null ? void 0 : currentState.transitions) == null ? void 0 : _d.length) ?
352
390
  // If the Current State limits transitions to specific States
353
391
  // Check that the Action State is in Current State's transitions array
354
392
  currentState.transitions.includes(actionState.id) :
@@ -357,7 +395,7 @@ function UpdateWorkflow(props, allStates, actionState) {
357
395
  const userAssignmentCanUpdateState = actionState.requireAssignment ?
358
396
  // If the Action State requires assigned users
359
397
  // Check the current user ID is in the assignees array
360
- currentUser && assignees.length && assignees.includes(currentUser.id) :
398
+ currentUser && (assignees == null ? void 0 : assignees.length) && assignees.includes(currentUser.id) :
361
399
  // Otherwise this isn't a problem
362
400
  true;
363
401
  let title = "".concat(directionLabel, " State to \"").concat(actionState.title, "\"");
@@ -380,16 +418,13 @@ function UpdateWorkflow(props, allStates, actionState) {
380
418
  onHandle: () => onHandle(id, actionState)
381
419
  };
382
420
  }
383
- function AssigneesBadge(states, documentId, currentUser) {
421
+ function AssigneesBadge(documentId, currentUser) {
384
422
  var _a;
385
423
  const {
386
- data,
424
+ metadata,
387
425
  loading,
388
426
  error
389
- } = useWorkflowMetadata(documentId, states);
390
- const {
391
- metadata
392
- } = data;
427
+ } = useWorkflowContext(documentId);
393
428
  const userList = useProjectUsers({
394
429
  apiVersion: API_VERSION
395
430
  });
@@ -425,15 +460,14 @@ function AssigneesBadge(states, documentId, currentUser) {
425
460
  color: "primary"
426
461
  };
427
462
  }
428
- function StateBadge(states, documentId) {
463
+ function StateBadge(documentId) {
429
464
  const {
430
- data,
465
+ metadata,
431
466
  loading,
432
- error
433
- } = useWorkflowMetadata(documentId, states);
434
- const {
435
- state
436
- } = data;
467
+ error,
468
+ states
469
+ } = useWorkflowContext(documentId);
470
+ const state = states.find(s => s.id === (metadata == null ? void 0 : metadata.state));
437
471
  if (loading || error) {
438
472
  if (error) {
439
473
  console.error(error);
@@ -449,6 +483,25 @@ function StateBadge(states, documentId) {
449
483
  color: state == null ? void 0 : state.color
450
484
  };
451
485
  }
486
+ function WorkflowSignal(props) {
487
+ var _a;
488
+ const documentId = ((_a = props == null ? void 0 : props.value) == null ? void 0 : _a._id) ? props.value._id.replace("drafts.", "") : null;
489
+ const {
490
+ addId,
491
+ removeId
492
+ } = useWorkflowContext();
493
+ useEffect(() => {
494
+ if (documentId) {
495
+ addId(documentId);
496
+ }
497
+ return () => {
498
+ if (documentId) {
499
+ removeId(documentId);
500
+ }
501
+ };
502
+ }, [documentId, addId, removeId]);
503
+ return props.renderDefault(props);
504
+ }
452
505
  function EditButton(props) {
453
506
  const {
454
507
  id,
@@ -1988,7 +2041,10 @@ const workflow = definePlugin(function () {
1988
2041
  ...config
1989
2042
  };
1990
2043
  if (!(states == null ? void 0 : states.length)) {
1991
- throw new Error("Workflow: Missing states in config");
2044
+ throw new Error("Workflow plugin: Missing \"states\" in config");
2045
+ }
2046
+ if (!(schemaTypes == null ? void 0 : schemaTypes.length)) {
2047
+ throw new Error("Workflow plugin: Missing \"schemaTypes\" in config");
1992
2048
  }
1993
2049
  return {
1994
2050
  name: "sanity-plugin-workflow",
@@ -1997,12 +2053,33 @@ const workflow = definePlugin(function () {
1997
2053
  },
1998
2054
  // TODO: Remove 'workflow.metadata' from list of new document types
1999
2055
  // ...
2056
+ studio: {
2057
+ components: {
2058
+ layout: props => WorkflowProvider({
2059
+ ...props,
2060
+ workflow: {
2061
+ schemaTypes,
2062
+ states
2063
+ }
2064
+ })
2065
+ }
2066
+ },
2067
+ form: {
2068
+ components: {
2069
+ input: props => {
2070
+ if (props.id === "root" && isObjectInputProps(props) && schemaTypes.includes(props.schemaType.name)) {
2071
+ return WorkflowSignal(props);
2072
+ }
2073
+ return props.renderDefault(props);
2074
+ }
2075
+ }
2076
+ },
2000
2077
  document: {
2001
2078
  actions: (prev, context) => {
2002
2079
  if (!schemaTypes.includes(context.schemaType)) {
2003
2080
  return prev;
2004
2081
  }
2005
- return [props => BeginWorkflow(props, states), props => AssignWorkflow(props, states), ...states.map(state => props => UpdateWorkflow(props, states, state)), props => CompleteWorkflow(props, states), ...prev];
2082
+ return [props => BeginWorkflow(props), props => AssignWorkflow(props), ...states.map(state => props => UpdateWorkflow(props, state)), props => CompleteWorkflow(props), ...prev];
2006
2083
  },
2007
2084
  badges: (prev, context) => {
2008
2085
  if (!schemaTypes.includes(context.schemaType)) {
@@ -2015,10 +2092,12 @@ const workflow = definePlugin(function () {
2015
2092
  if (!documentId) {
2016
2093
  return prev;
2017
2094
  }
2018
- return [() => StateBadge(states, documentId), () => AssigneesBadge(states, documentId, currentUser), ...prev];
2095
+ return [() => StateBadge(documentId), () => AssigneesBadge(documentId, currentUser), ...prev];
2019
2096
  }
2020
2097
  },
2021
- tools: [workflowTool({
2098
+ tools: [
2099
+ // TODO: These configs could be read from Context
2100
+ workflowTool({
2022
2101
  schemaTypes,
2023
2102
  states
2024
2103
  })]