@sanity/orderable-document-list 1.5.0 → 2.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.
@@ -1,316 +0,0 @@
1
- import {useEffect, useState, useMemo, useCallback, type CSSProperties} from 'react'
2
- import {DragDropContext, Draggable, Droppable, type DropResult} from '@hello-pangea/dnd'
3
- import {Box, Card, useToast} from '@sanity/ui'
4
- import type {PatchOperations} from 'sanity'
5
- import {usePaneRouter} from 'sanity/structure'
6
-
7
- import {Document} from './Document'
8
- import {reorderDocuments} from './helpers/reorderDocuments'
9
- import {ORDER_FIELD_NAME} from './helpers/constants'
10
- import {useSanityClient} from './helpers/client'
11
- import type {SanityDocumentWithOrder} from './types'
12
-
13
- interface ListSetting {
14
- isDuplicate: boolean
15
- isGhosting: boolean
16
- isDragging: boolean
17
- isSelected: boolean
18
- }
19
-
20
- export interface DraggableListProps {
21
- data: SanityDocumentWithOrder[]
22
- listIsUpdating: boolean
23
- setListIsUpdating: (val: boolean) => void
24
- }
25
-
26
- const getItemStyle = (
27
- draggableStyle: CSSProperties | undefined,
28
- itemIsUpdating: boolean,
29
- ): CSSProperties => ({
30
- userSelect: 'none',
31
- transition: 'opacity 500ms ease-in-out',
32
- opacity: itemIsUpdating ? 0.2 : 1,
33
- pointerEvents: itemIsUpdating ? 'none' : undefined,
34
- ...draggableStyle,
35
- })
36
-
37
- const cardTone = (settings: ListSetting) => {
38
- const {isDuplicate, isGhosting, isDragging, isSelected} = settings
39
-
40
- if (isGhosting) return 'transparent'
41
- if (isDragging || isSelected) return 'primary'
42
- if (isDuplicate) return 'caution'
43
-
44
- return undefined
45
- }
46
-
47
- export function DraggableList({data, listIsUpdating, setListIsUpdating}: DraggableListProps) {
48
- const toast = useToast()
49
- const router = usePaneRouter()
50
- const {groupIndex, routerPanesState} = router
51
-
52
- const currentDoc = routerPanesState[groupIndex + 1]?.[0]?.id || false
53
-
54
- // Maintains local state order before transaction completes
55
- const [orderedData, setOrderedData] = useState<SanityDocumentWithOrder[]>(data)
56
-
57
- // Update local state when documents change from an outside source
58
- useEffect(() => {
59
- if (!listIsUpdating) setOrderedData(data)
60
- /* eslint-disable-next-line react-hooks/exhaustive-deps */
61
- }, [data])
62
-
63
- const [draggingId, setDraggingId] = useState('')
64
- const [selectedIds, setSelectedIds] = useState<string[]>(currentDoc ? [currentDoc] : [])
65
-
66
- const clearSelected = useCallback(() => setSelectedIds([]), [setSelectedIds])
67
-
68
- const handleSelect = useCallback(
69
- (clickedId: string, index: number, nativeEvent: MouseEvent) => {
70
- const isSelected = selectedIds.includes(clickedId)
71
- const selectMultiple = nativeEvent.shiftKey
72
- const isUsingWindows = navigator.appVersion.indexOf('Win') !== -1
73
- const selectAdditional = isUsingWindows ? nativeEvent.ctrlKey : nativeEvent.metaKey
74
-
75
- let updatedIds = []
76
-
77
- // No modifier keys pressed during click:
78
- // - update selected to just this one
79
- // - open document
80
- if (!selectMultiple && !selectAdditional) {
81
- return setSelectedIds([clickedId])
82
- }
83
-
84
- // If shift key was held, prevent default to avoid new window opening
85
- if (selectMultiple) {
86
- nativeEvent.preventDefault()
87
- }
88
-
89
- // Shift key was held, add id's between last selected and this one
90
- // ...before adding this one
91
- if (selectMultiple && !isSelected) {
92
- const lastSelectedId = selectedIds[selectedIds.length - 1]
93
- const lastSelectedIndex = orderedData.findIndex((item) => item._id === lastSelectedId)
94
-
95
- const firstSelected = index < lastSelectedIndex ? index : lastSelectedIndex
96
- const lastSelected = index > lastSelectedIndex ? index : lastSelectedIndex
97
-
98
- const betweenIds = orderedData
99
- .filter((item, itemIndex) => itemIndex > firstSelected && itemIndex < lastSelected)
100
- .map((item) => item._id)
101
-
102
- updatedIds = [...selectedIds, ...betweenIds, clickedId]
103
- } else if (isSelected) {
104
- // Toggle off a single id
105
- updatedIds = selectedIds.filter((id) => id !== clickedId)
106
- } else {
107
- // Toggle on a single id
108
- updatedIds = [...selectedIds, clickedId]
109
- }
110
-
111
- return setSelectedIds(updatedIds)
112
- },
113
- [setSelectedIds, orderedData, selectedIds],
114
- )
115
-
116
- const client = useSanityClient()
117
-
118
- const transactPatches = useCallback(
119
- async (patches: [string, PatchOperations][], message: string) => {
120
- const transaction = client.transaction()
121
-
122
- patches.forEach(([docId, ops]) => transaction.patch(docId, ops))
123
-
124
- try {
125
- const updated = await transaction.commit({
126
- visibility: 'async',
127
- tag: 'orderable-document-list.reorder',
128
- })
129
- clearSelected()
130
- setDraggingId('')
131
- setListIsUpdating(false)
132
- toast.push({
133
- title: `${
134
- updated.results.length === 1 ? '1 document' : `${updated.results.length} documents`
135
- } reordered`,
136
- status: 'success',
137
- description: message,
138
- })
139
- } catch (err) {
140
- setDraggingId('')
141
- setListIsUpdating(false)
142
- toast.push({
143
- title: 'Reordering failed',
144
- status: 'error',
145
- })
146
- }
147
- },
148
- [client, setDraggingId, clearSelected, setListIsUpdating, toast],
149
- )
150
-
151
- const handleDragEnd = useCallback(
152
- (result: DropResult | undefined, entities: SanityDocumentWithOrder[]) => {
153
- setDraggingId('')
154
-
155
- const {source, destination, draggableId} = result ?? {}
156
-
157
- // Don't do anything if nothing changed
158
- if (source?.index === destination?.index) return
159
-
160
- // Don't do anything if we don't have the entitites
161
- if (!entities?.length || !draggableId) return
162
-
163
- // A document can be dragged without being one-of-many-selected
164
- const effectedIds = selectedIds?.length ? selectedIds : [draggableId]
165
-
166
- // Don't do anything if we don't have ids to effect
167
- if (!effectedIds?.length) return
168
-
169
- // Update state to update styles + prevent data refetching
170
- setListIsUpdating(true)
171
- setSelectedIds(effectedIds)
172
-
173
- const {newOrder, patches, message} = reorderDocuments({
174
- entities,
175
- selectedIds: effectedIds,
176
- source,
177
- destination,
178
- })
179
-
180
- // Update local state
181
- if (newOrder?.length) {
182
- setOrderedData(newOrder)
183
- }
184
-
185
- // Transact new order patches
186
- if (patches?.length) {
187
- transactPatches(patches, message)
188
- }
189
- },
190
- [selectedIds, setDraggingId, setSelectedIds, transactPatches, setListIsUpdating],
191
- )
192
-
193
- const handleDragStart = useCallback(
194
- (start: {draggableId: string}) => {
195
- const id = start.draggableId
196
- const selected = selectedIds.includes(id)
197
-
198
- // if dragging an item that is not selected - unselect all items
199
- if (!selected) clearSelected()
200
-
201
- setDraggingId(id)
202
- },
203
- [selectedIds, clearSelected, setDraggingId],
204
- )
205
-
206
- // Move one document up or down one place, by fake invoking the drag function
207
- const incrementIndex = useCallback(
208
- (shiftFrom: number, shiftTo: number, id: string, entities: SanityDocumentWithOrder[]) => {
209
- const result = {
210
- draggableId: id,
211
- source: {index: shiftFrom},
212
- destination: {index: shiftTo},
213
- }
214
-
215
- return handleDragEnd(result as DropResult, entities)
216
- },
217
- [handleDragEnd],
218
- )
219
-
220
- const onWindowKeyDown = useCallback(
221
- (event: KeyboardEvent) => {
222
- if (event.key === 'Escape') {
223
- clearSelected()
224
- }
225
- },
226
- [clearSelected],
227
- )
228
-
229
- useEffect(() => {
230
- window.addEventListener('keydown', onWindowKeyDown)
231
-
232
- return () => {
233
- window.removeEventListener('keydown', onWindowKeyDown)
234
- }
235
- }, [onWindowKeyDown])
236
-
237
- // Find all items with duplicate order field
238
- const duplicateOrders = useMemo(() => {
239
- if (!orderedData.length) return []
240
-
241
- const orderField = orderedData.map((item) => item[ORDER_FIELD_NAME])
242
-
243
- return orderField.filter((item, index) => orderField.indexOf(item) !== index)
244
- }, [orderedData])
245
-
246
- const onDragEnd = useCallback(
247
- (result: DropResult) => handleDragEnd(result, orderedData),
248
- [orderedData, handleDragEnd],
249
- )
250
-
251
- return (
252
- <DragDropContext onDragStart={handleDragStart} onDragEnd={onDragEnd}>
253
- <Droppable droppableId="documentSortZone">
254
- {(provided) => (
255
- <div {...provided.droppableProps} ref={provided.innerRef}>
256
- {orderedData.map((item, index) => (
257
- <Draggable
258
- key={`${item._id}-${item[ORDER_FIELD_NAME]}`}
259
- draggableId={item._id}
260
- index={index}
261
- // onClick={(event) => handleDraggableClick(event, provided, snapshot)}
262
- >
263
- {(innerProvided, innerSnapshot) => {
264
- const isSelected = selectedIds.includes(item._id)
265
- const isDragging = innerSnapshot.isDragging
266
- const isGhosting = Boolean(!isDragging && draggingId && isSelected)
267
- const isUpdating = listIsUpdating && isSelected
268
- const isDisabled = Boolean(!item[ORDER_FIELD_NAME])
269
- const isDuplicate = duplicateOrders.includes(item[ORDER_FIELD_NAME])
270
- const tone = cardTone({isDuplicate, isGhosting, isDragging, isSelected})
271
- const selectedCount = selectedIds.length
272
-
273
- const dragBadge = isDragging && selectedCount > 1 ? selectedCount : false
274
-
275
- return (
276
- <div
277
- ref={innerProvided.innerRef}
278
- {...innerProvided.draggableProps}
279
- {...innerProvided.dragHandleProps}
280
- style={
281
- isDisabled
282
- ? {opacity: 0.2, pointerEvents: 'none'}
283
- : getItemStyle(innerProvided.draggableProps.style, isUpdating)
284
- }
285
- >
286
- <Box paddingBottom={1}>
287
- <Card
288
- tone={tone}
289
- shadow={isDragging ? 2 : undefined}
290
- radius={2}
291
- // eslint-disable-next-line react/jsx-no-bind
292
- onClick={(e) => handleSelect(item._id, index, e.nativeEvent)}
293
- >
294
- <Document
295
- doc={item}
296
- entities={orderedData}
297
- increment={incrementIndex}
298
- index={index}
299
- isFirst={index === 0}
300
- isLast={index === orderedData.length - 1}
301
- dragBadge={dragBadge}
302
- />
303
- </Card>
304
- </Box>
305
- </div>
306
- )
307
- }}
308
- </Draggable>
309
- ))}
310
- {provided.placeholder}
311
- </div>
312
- )}
313
- </Droppable>
314
- </DragDropContext>
315
- )
316
- }
@@ -1,7 +0,0 @@
1
- import {createContext} from 'react'
2
-
3
- export interface OrderableContextValue {
4
- showIncrements?: boolean
5
- }
6
-
7
- export const OrderableContext = createContext<OrderableContextValue>({})
@@ -1,81 +0,0 @@
1
- import {Component} from 'react'
2
-
3
- import type {SanityClient} from '@sanity/client'
4
- import type {ToastParams} from '@sanity/ui'
5
- import {DocumentListWrapper} from './DocumentListWrapper'
6
- import {resetOrder} from './helpers/resetOrder'
7
-
8
- export interface OrderableDocumentListProps {
9
- options: {
10
- type: string
11
- client: SanityClient
12
- filter?: string
13
- params?: Record<string, unknown>
14
- currentVersion?: string
15
- }
16
- }
17
-
18
- interface State {
19
- showIncrements: boolean
20
- resetOrderTransaction: ToastParams
21
- }
22
-
23
- // Must use a Class Component here so the actionHandlers can be called
24
- export class OrderableDocumentList extends Component<OrderableDocumentListProps, State> {
25
- constructor(props: OrderableDocumentListProps) {
26
- super(props)
27
- this.state = {
28
- showIncrements: false,
29
- resetOrderTransaction: {},
30
- }
31
- }
32
-
33
- actionHandlers = {
34
- showIncrements: () => {
35
- this.setState((state) => ({
36
- showIncrements: !state.showIncrements,
37
- }))
38
- },
39
-
40
- resetOrder: async () => {
41
- this.setState(() => ({
42
- resetOrderTransaction: {
43
- status: `info`,
44
- title: `Reordering started...`,
45
- closable: true,
46
- },
47
- }))
48
-
49
- const update = await resetOrder(this.props.options)
50
-
51
- const reorderWasSuccessful = update?.results?.length
52
-
53
- this.setState(() => ({
54
- resetOrderTransaction: {
55
- status: reorderWasSuccessful ? `success` : `info`,
56
- title: reorderWasSuccessful
57
- ? `Reordered ${update.results.length === 1 ? `Document` : `Documents`}`
58
- : `Reordering failed`,
59
- closable: true,
60
- },
61
- }))
62
- },
63
- }
64
-
65
- render() {
66
- const type = this?.props?.options?.type
67
- if (!type) {
68
- return null
69
- }
70
- return (
71
- <DocumentListWrapper
72
- filter={this?.props?.options?.filter}
73
- params={this?.props?.options?.params}
74
- type={type}
75
- showIncrements={this.state.showIncrements}
76
- resetOrderTransaction={this.state.resetOrderTransaction}
77
- currentVersion={this?.props?.options?.currentVersion}
78
- />
79
- )
80
- }
81
- }
@@ -1,84 +0,0 @@
1
- import {GenerateIcon, SortIcon} from '@sanity/icons'
2
- import type {ConfigContext} from 'sanity'
3
-
4
- import type {ComponentType} from 'react'
5
- import {StructureBuilder, type ListItem, type MenuItem} from 'sanity/structure'
6
- import {OrderableDocumentList} from '../OrderableDocumentList'
7
- import {API_VERSION} from '../helpers/constants'
8
-
9
- export interface OrderableListConfig {
10
- type: string
11
- id?: string
12
- title?: string
13
- icon?: ComponentType
14
- params?: Record<string, unknown>
15
- filter?: string
16
- menuItems?: MenuItem[]
17
- createIntent?: boolean
18
- context: ConfigContext
19
- S: StructureBuilder
20
- }
21
-
22
- export function orderableDocumentListDeskItem(config: OrderableListConfig): ListItem {
23
- if (!config?.type || !config.context || !config.S) {
24
- throw new Error(`
25
- type, context and S (StructureBuilder) must be provided.
26
- context and S are available when configuring structure.
27
- Example: orderableDocumentListDeskItem({type: 'category'})
28
- `)
29
- }
30
-
31
- const {type, filter, menuItems = [], createIntent, params, title, icon, id, context, S} = config
32
- const {schema, getClient} = context
33
- // 'perspectiveStack' may not exist on ConfigContext in some versions
34
- const perspectiveStack = (context as any).perspectiveStack || []
35
- const client = getClient({apiVersion: API_VERSION})
36
- // the first position in the perspective stack is the current version
37
- const currentVersion = perspectiveStack[0]
38
-
39
- const listTitle = title ?? `Orderable ${type}`
40
- const listId = id ?? `orderable-${type}`
41
- const listIcon = icon ?? SortIcon
42
- const typeTitle = schema.get(type)?.title ?? type
43
-
44
- if (createIntent !== false) {
45
- menuItems.push(
46
- S.menuItem()
47
- .title(`Create new ${typeTitle}`)
48
- .intent({type: 'create', params: {type}})
49
- .serialize(),
50
- )
51
- }
52
- return S.listItem()
53
- .title(listTitle)
54
- .id(listId)
55
- .icon(listIcon)
56
- .schemaType(type)
57
- .child(
58
- Object.assign(
59
- S.documentTypeList(type)
60
- .canHandleIntent(() => !!createIntent)
61
- .serialize(),
62
- {
63
- // Prevents the component from re-rendering when switching documents
64
- __preserveInstance: true,
65
- // Prevents the component from NOT re-rendering when switching listItems
66
- key: listId,
67
-
68
- type: 'component',
69
- component: OrderableDocumentList,
70
- options: {type, filter, params, client, currentVersion},
71
- menuItems: [
72
- ...menuItems,
73
- S.menuItem().title(`Reset Order`).icon(GenerateIcon).action(`resetOrder`).serialize(),
74
- S.menuItem()
75
- .title(`Toggle Increments`)
76
- .icon(SortIcon)
77
- .action(`showIncrements`)
78
- .serialize(),
79
- ],
80
- },
81
- ),
82
- )
83
- .serialize()
84
- }
@@ -1,43 +0,0 @@
1
- import {type ConfigContext, defineField, FieldDefinition, type StringDefinition} from 'sanity'
2
- import {API_VERSION, ORDER_FIELD_NAME} from '../helpers/constants'
3
- import {initialRank} from '../helpers/initialRank'
4
- import type {NewItemPosition} from '../types'
5
-
6
- export type SchemaContext = Omit<ConfigContext, 'schema' | 'currentUser' | 'client'>
7
-
8
- export interface RankFieldConfig
9
- extends Partial<Omit<StringDefinition, 'name' | 'type' | 'initialValue'>> {
10
- type: string
11
- newItemPosition?: NewItemPosition
12
- }
13
-
14
- export const orderRankField = (config: RankFieldConfig): FieldDefinition<'string'> => {
15
- if (!config?.type) {
16
- throw new Error(
17
- `
18
- type must be provided.
19
- Example: orderRankField({type: 'category'})
20
- `,
21
- )
22
- }
23
-
24
- const {type, newItemPosition = 'after', ...rest} = config
25
- return defineField({
26
- title: 'Order Rank',
27
- readOnly: true,
28
- hidden: true,
29
- ...rest,
30
- name: ORDER_FIELD_NAME,
31
- type: 'string',
32
- initialValue: async (p, {getClient}) => {
33
- const direction = newItemPosition === 'before' ? 'asc' : 'desc'
34
-
35
- const lastDocOrderRank = await getClient({apiVersion: API_VERSION}).fetch(
36
- `*[_type == $type]|order(@[$order] ${direction})[0][$order]`,
37
- {type, order: ORDER_FIELD_NAME},
38
- {tag: 'orderable-document-list.last-doc-order-rank'},
39
- )
40
- return initialRank(lastDocOrderRank, newItemPosition)
41
- },
42
- })
43
- }
@@ -1,8 +0,0 @@
1
- import type {SortOrdering} from 'sanity'
2
- import {ORDER_FIELD_NAME} from '../helpers/constants'
3
-
4
- export const orderRankOrdering: SortOrdering = {
5
- title: 'Ordered',
6
- name: 'ordered',
7
- by: [{field: ORDER_FIELD_NAME, direction: 'asc'}],
8
- }