@stoker-platform/web-app 0.5.209 → 0.5.211

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @stoker-platform/web-app
2
2
 
3
+ ## 0.5.211
4
+
5
+ ### Patch Changes
6
+
7
+ - perf: improve Record page performance
8
+
9
+ ## 0.5.210
10
+
11
+ ### Patch Changes
12
+
13
+ - feat: improve assigment behaviour
14
+ - feat: enable clipboard write access in CSP
15
+ - Updated dependencies
16
+ - @stoker-platform/web-client@0.5.86
17
+ - @stoker-platform/node-client@0.5.82
18
+ - @stoker-platform/utils@0.5.73
19
+
3
20
  ## 0.5.209
4
21
 
5
22
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stoker-platform/web-app",
3
- "version": "0.5.209",
3
+ "version": "0.5.211",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "scripts": {
@@ -51,9 +51,9 @@
51
51
  "@radix-ui/react-tooltip": "^1.2.8",
52
52
  "@react-google-maps/api": "^2.20.8",
53
53
  "@sentry/react": "^10.56.0",
54
- "@stoker-platform/node-client": "0.5.81",
55
- "@stoker-platform/utils": "0.5.72",
56
- "@stoker-platform/web-client": "0.5.85",
54
+ "@stoker-platform/node-client": "0.5.82",
55
+ "@stoker-platform/utils": "0.5.73",
56
+ "@stoker-platform/web-client": "0.5.86",
57
57
  "@tanstack/react-table": "^8.21.3",
58
58
  "@types/react": "18.3.13",
59
59
  "@types/react-dom": "18.3.1",
package/src/Images.tsx CHANGED
@@ -15,9 +15,10 @@ import {
15
15
  getCollectionConfigModule,
16
16
  getCurrentUserPermissions,
17
17
  onStokerPermissionsChange,
18
+ onStokerSignOut,
18
19
  subscribeOne,
19
20
  } from "@stoker-platform/web-client"
20
- import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
21
+ import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"
21
22
  import { Card, CardContent, CardHeader, CardTitle } from "./components/ui/card"
22
23
  import { useGoToRecord } from "./utils/goToRecord"
23
24
  import { LoadingSpinner } from "./components/ui/loading-spinner"
@@ -118,6 +119,54 @@ interface RowProps {
118
119
  data: RowData
119
120
  }
120
121
 
122
+ const pendingAssign = new Map<string, boolean>()
123
+ const assignQueue = new Map<string, Promise<unknown>>()
124
+ const assignSettledAt = new Map<string, number>()
125
+ const assignListeners = new Set<() => void>()
126
+ let assignVersion = 0
127
+
128
+ const ASSIGN_SETTLE_MS = 3000
129
+
130
+ const notifyAssign = () => {
131
+ assignVersion++
132
+ assignListeners.forEach((listener) => listener())
133
+ }
134
+
135
+ const subscribeAssign = (listener: () => void) => {
136
+ assignListeners.add(listener)
137
+ return () => {
138
+ assignListeners.delete(listener)
139
+ }
140
+ }
141
+
142
+ let setAssignGlobalLoading: (operation: "+" | "-", id: string, server?: boolean, cache?: boolean) => void | undefined
143
+
144
+ onStokerSignOut(() => {
145
+ pendingAssign.clear()
146
+ assignQueue.clear()
147
+ assignSettledAt.clear()
148
+ assignListeners.clear()
149
+ assignVersion = 0
150
+ })
151
+
152
+ const assignKey = (recordId: string, parentId: string) => `${recordId}:${parentId}`
153
+
154
+ const enqueueAssign = (recordId: string, call: () => Promise<unknown>) => {
155
+ const wasIdle = !assignQueue.has(recordId)
156
+ if (wasIdle) setAssignGlobalLoading?.("+", recordId)
157
+ const next = (assignQueue.get(recordId) ?? Promise.resolve()).catch(() => undefined).then(call)
158
+ assignQueue.set(recordId, next)
159
+ next.finally(() => {
160
+ if (assignQueue.get(recordId) === next) {
161
+ assignQueue.delete(recordId)
162
+ assignSettledAt.set(recordId, Date.now())
163
+ setAssignGlobalLoading?.("-", recordId)
164
+ setTimeout(notifyAssign, ASSIGN_SETTLE_MS + 50)
165
+ }
166
+ })
167
+ return next
168
+ }
169
+
121
170
  const Row = ({ index, style, data }: RowProps) => {
122
171
  const goToRecord = useGoToRecord()
123
172
  const {
@@ -138,42 +187,85 @@ const Row = ({ index, style, data }: RowProps) => {
138
187
  const group = groupedRecords[index]
139
188
  const customization = getCollectionConfigModule(collection.labels.collection)
140
189
  const { setGlobalLoading } = useGlobalLoading()
190
+ setAssignGlobalLoading = setGlobalLoading
141
191
  const { toast } = useToast()
142
-
143
- const [checkedDisabled, setCheckedDisabled] = useState(false)
144
-
145
- const handleCheckedChange = useCallback(async (checked: boolean, record: StokerRecord) => {
146
- if (!relationCollection) return
147
- setCheckedDisabled(true)
148
- setGlobalLoading("+", record.id, true)
149
- await callFunction(
150
- `stoker-assign${relationCollection.labels.record.toLowerCase()}${collection.labels.collection.toLowerCase()}`,
151
- {
152
- operation: checked ? "add" : "remove",
153
- parentId: relationParent?.id,
154
- recordId: record.id,
155
- },
156
- ).catch(() => {
157
- toast({
158
- title: "Error",
159
- description: `Error assigning ${collection.labels.record} to ${relationCollection.labels.record}`,
160
- variant: "destructive",
161
- duration: 10000000,
162
- })
163
- })
164
- setGlobalLoading("-", record.id, true)
165
- setCheckedDisabled(false)
166
- }, [])
192
+ useSyncExternalStore(subscribeAssign, () => assignVersion)
193
+
194
+ useEffect(() => {
195
+ if (!relationParent || !relationList?.field) return
196
+ let changed = false
197
+ for (const record of group) {
198
+ const key = assignKey(record.id, relationParent.id)
199
+ const pending = pendingAssign.get(key)
200
+ if (pending === undefined || assignQueue.has(record.id)) continue
201
+ const settledAt = assignSettledAt.get(record.id) ?? 0
202
+ if (Date.now() - settledAt < ASSIGN_SETTLE_MS) continue
203
+ // eslint-disable-next-line security/detect-object-injection
204
+ const serverChecked = !!record[relationList.field]?.[relationParent.id]
205
+ if (pending === serverChecked) {
206
+ pendingAssign.delete(key)
207
+ assignSettledAt.delete(record.id)
208
+ changed = true
209
+ }
210
+ }
211
+ if (changed) notifyAssign()
212
+ }, [group, relationParent, relationList?.field, assignVersion])
213
+
214
+ const handleCheckedChange = useCallback(
215
+ (checked: boolean, record: StokerRecord) => {
216
+ if (!relationCollection || !relationList?.field || !relationParent) return
217
+
218
+ const key = assignKey(record.id, relationParent.id)
219
+ pendingAssign.set(key, checked)
220
+ notifyAssign()
221
+
222
+ enqueueAssign(record.id, () =>
223
+ callFunction(
224
+ `stoker-assign${relationCollection.labels.record.toLowerCase()}${collection.labels.collection.toLowerCase()}`,
225
+ {
226
+ operation: checked ? "add" : "remove",
227
+ parentId: relationParent.id,
228
+ recordId: record.id,
229
+ },
230
+ ).catch(() => {
231
+ if (pendingAssign.get(key) === checked) {
232
+ pendingAssign.delete(key)
233
+ notifyAssign()
234
+ }
235
+ toast({
236
+ title: "Error",
237
+ description: `Error assigning ${collection.labels.record} to ${relationCollection.labels.record}`,
238
+ variant: "destructive",
239
+ duration: 10000000,
240
+ })
241
+ }),
242
+ )
243
+ },
244
+ [
245
+ relationCollection,
246
+ relationList?.field,
247
+ relationParent,
248
+ collection.labels.collection,
249
+ collection.labels.record,
250
+ toast,
251
+ ],
252
+ )
167
253
 
168
254
  return (
169
255
  <div style={style} className={cn("grid", "gap-4", "pb-4", cols)}>
170
256
  {group.map((record) => {
171
257
  // eslint-disable-next-line security/detect-object-injection
172
258
  const title = recordTitleField ? record[recordTitleField] : record.id
259
+ const pending =
260
+ isAssigning && relationParent
261
+ ? pendingAssign.get(assignKey(record.id, relationParent.id))
262
+ : undefined
173
263
  const checked =
174
264
  isAssigning && relationList?.field && relationParent
175
- ? !!record[relationList.field]?.[relationParent.id]
265
+ ? // eslint-disable-next-line security/detect-object-injection
266
+ (pending ?? !!record[relationList.field]?.[relationParent.id])
176
267
  : undefined
268
+ const hasPending = pending !== undefined
177
269
  let unavailable
178
270
  if (isAssigning) {
179
271
  if (assignable?.unavailableField) {
@@ -258,17 +350,17 @@ const Row = ({ index, style, data }: RowProps) => {
258
350
  }),
259
351
  hooks: import.meta.glob("./hooks/*.{ts,tsx}", { eager: true }),
260
352
  utils: import.meta.glob("./lib/*.{ts,tsx}", { eager: true }),
353
+ setGlobalLoading,
261
354
  })}
262
355
  </div>
263
356
  )}
264
- {isAssigning && assignable && (assignable.isAvailable(record) || checked) && (
357
+ {isAssigning && assignable && (assignable.isAvailable(record) || checked || hasPending) && (
265
358
  <div className="pb-4">
266
359
  <div className="flex items-center justify-center space-x-3 min-h-8">
267
360
  <Switch
268
361
  id={`${record.id}-assigned`}
269
362
  className="data-[state=checked]:bg-blue-500"
270
363
  checked={checked}
271
- disabled={checkedDisabled}
272
364
  onCheckedChange={(checked) => handleCheckedChange(checked, record)}
273
365
  />
274
366
  {imagesConfig.size !== "sm" && (
@@ -277,13 +369,17 @@ const Row = ({ index, style, data }: RowProps) => {
277
369
  </div>
278
370
  </div>
279
371
  )}
280
- {isAssigning && assignable && !assignable.isAvailable(record) && !checked && (
281
- <div className="pb-4">
282
- <div className="flex items-center justify-center space-x-3 min-h-8">
283
- {unavailable}
372
+ {isAssigning &&
373
+ assignable &&
374
+ !assignable.isAvailable(record) &&
375
+ !checked &&
376
+ !hasPending && (
377
+ <div className="pb-4">
378
+ <div className="flex items-center justify-center space-x-3 min-h-8">
379
+ {unavailable}
380
+ </div>
284
381
  </div>
285
- </div>
286
- )}
382
+ )}
287
383
  <div className={cn("grid", "gap-4", size)}>
288
384
  {record[imagesConfig.imageField] ? (
289
385
  <CopyImageOverlay src={record[imagesConfig.imageField]} className="w-full h-full">
package/src/Record.tsx CHANGED
@@ -36,6 +36,10 @@ import { Breadcrumbs } from "./Breadcrumbs"
36
36
  import { Separator } from "./components/ui/separator"
37
37
  import { loadAssigning } from "./utils/relationListFiltersState"
38
38
 
39
+ const customPageComponents = import.meta.glob("./components/ui/*.tsx", { eager: true })
40
+ const customPageHooks = import.meta.glob("./hooks/*.{ts,tsx}", { eager: true })
41
+ const customPageUtils = import.meta.glob("./lib/*.{ts,tsx}", { eager: true })
42
+
39
43
  export const Record = ({ collection }: { collection: CollectionSchema }) => {
40
44
  const { labels, fields, recordTitleField } = collection
41
45
  const { path: pathString, id } = useParams()
@@ -207,7 +211,9 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
207
211
  </Button>
208
212
  </Card>
209
213
  <Card className="flex items-center gap-2 h-12 min-w-[300px] p-5">
210
- <div>{icon ? createElement(icon) : null}</div>
214
+ <div className="flex size-6 shrink-0 items-center justify-center">
215
+ {icon ? createElement(icon) : null}
216
+ </div>
211
217
  <h1 className="truncate">{recordTitle}</h1>
212
218
  </Card>
213
219
  </header>
@@ -346,11 +352,9 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
346
352
  {createElement(page.component, {
347
353
  record,
348
354
  collection,
349
- components: import.meta.glob("./components/ui/*.tsx", {
350
- eager: true,
351
- }),
352
- hooks: import.meta.glob("./hooks/*.{ts,tsx}", { eager: true }),
353
- utils: import.meta.glob("./lib/*.{ts,tsx}", { eager: true }),
355
+ components: customPageComponents,
356
+ hooks: customPageHooks,
357
+ utils: customPageUtils,
354
358
  ...page.props,
355
359
  })}
356
360
  </main>
@@ -30,7 +30,7 @@ export const getRelationFields = (collection: CollectionSchema) => {
30
30
  if (refs.length === 0) continue
31
31
  const titleField = field.titleField
32
32
  if (
33
- (!fieldCustomization.admin?.condition?.form || condition) &&
33
+ (fieldCustomization.admin?.condition?.form === undefined || condition) &&
34
34
  (!(titleField && field.includeFields?.includes(titleField)) || queryFullRecord)
35
35
  ) {
36
36
  relationFields.push(field.name)