@stoker-platform/web-app 0.5.183 → 0.5.185

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,17 @@
1
1
  # @stoker-platform/web-app
2
2
 
3
+ ## 0.5.185
4
+
5
+ ### Patch Changes
6
+
7
+ - feat: add temporary year picker
8
+
9
+ ## 0.5.184
10
+
11
+ ### Patch Changes
12
+
13
+ - fix: perform bulk writes in batches
14
+
3
15
  ## 0.5.183
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stoker-platform/web-app",
3
- "version": "0.5.183",
3
+ "version": "0.5.185",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "scripts": {
package/src/Form.tsx CHANGED
@@ -4323,49 +4323,58 @@ function RecordForm({
4323
4323
  return
4324
4324
  }
4325
4325
 
4326
- const updatePromises: Promise<StokerRecord>[] = []
4326
+ const recordsToUpdate = rowSelection.filter((selectedRecord) => selectedRecord.id)
4327
4327
 
4328
- rowSelection.forEach((selectedRecord) => {
4329
- if (selectedRecord.id) {
4330
- setOptimisticUpdate(labels.collection, {
4331
- ...selectedRecord,
4332
- ...cloneDeep(recordToSave),
4333
- } as StokerRecord)
4334
- }
4328
+ recordsToUpdate.forEach((selectedRecord) => {
4329
+ setOptimisticUpdate(labels.collection, {
4330
+ ...selectedRecord,
4331
+ ...cloneDeep(recordToSave),
4332
+ } as StokerRecord)
4335
4333
  })
4336
4334
 
4337
- rowSelection.forEach((selectedRecord) => {
4338
- if (!selectedRecord.id) return
4339
-
4340
- const serverWrite = isServerUpdate(collection, { ...recordToSave, id: selectedRecord.id })
4341
- setGlobalLoading("+", selectedRecord.id, serverWrite, !(serverWrite || isServerReadOnly))
4335
+ const BATCH_SIZE = 5
4336
+ const runUpdates = async () => {
4337
+ for (let i = 0; i < recordsToUpdate.length; i += BATCH_SIZE) {
4338
+ const batch = recordsToUpdate.slice(i, i + BATCH_SIZE)
4339
+ const batchPromises = batch.map((selectedRecord) => {
4340
+ const serverWrite = isServerUpdate(collection, {
4341
+ ...recordToSave,
4342
+ id: selectedRecord.id,
4343
+ })
4344
+ setGlobalLoading("+", selectedRecord.id, serverWrite, !(serverWrite || isServerReadOnly))
4342
4345
 
4343
- const updatePromise = updateRecord(path, selectedRecord.id, recordToSave, {
4344
- originalRecord: selectedRecord,
4345
- })
4346
- .catch((error) => {
4347
- console.error(`Failed to update record ${selectedRecord.id}:`, error)
4348
- toast({
4349
- // eslint-disable-next-line security/detect-object-injection
4350
- description: `${recordTitle} ${recordTitleField ? selectedRecord[recordTitleField] : selectedRecord.id} failed to update.`,
4351
- variant: "destructive",
4346
+ return updateRecord(path, selectedRecord.id, recordToSave, {
4347
+ originalRecord: selectedRecord,
4352
4348
  })
4349
+ .catch((error) => {
4350
+ console.error(`Failed to update record ${selectedRecord.id}:`, error)
4351
+ toast({
4352
+ // eslint-disable-next-line security/detect-object-injection
4353
+ description: `${recordTitle} ${recordTitleField ? selectedRecord[recordTitleField] : selectedRecord.id} failed to update.`,
4354
+ variant: "destructive",
4355
+ })
4356
+ })
4357
+ .finally(() => {
4358
+ removeOptimisticUpdate(labels.collection, selectedRecord.id)
4359
+ setGlobalLoading(
4360
+ "-",
4361
+ selectedRecord.id,
4362
+ undefined,
4363
+ !(serverWrite || isServerReadOnly),
4364
+ )
4365
+ if (serverWrite || isServerReadOnly) {
4366
+ if (onSaveRecord) onSaveRecord()
4367
+ }
4368
+ })
4353
4369
  })
4354
- .finally(() => {
4355
- removeOptimisticUpdate(labels.collection, selectedRecord.id)
4356
- setGlobalLoading("-", selectedRecord.id, undefined, !(serverWrite || isServerReadOnly))
4357
- if (serverWrite || isServerReadOnly) {
4358
- if (onSaveRecord) onSaveRecord()
4359
- }
4360
- })
4361
-
4362
- updatePromises.push(updatePromise as Promise<StokerRecord>)
4363
- })
4370
+ await Promise.all(batchPromises)
4371
+ }
4372
+ }
4364
4373
 
4365
4374
  if (isServerReadOnly) {
4366
- await Promise.all(updatePromises)
4375
+ await runUpdates()
4367
4376
  } else {
4368
- Promise.all(updatePromises)
4377
+ runUpdates()
4369
4378
  }
4370
4379
 
4371
4380
  rowSelection.forEach((selectedRecord) => {
package/src/List.tsx CHANGED
@@ -1232,46 +1232,68 @@ export function List({
1232
1232
  const titles = await getCachedConfigValue(customization, ["collections", labels.collection, "admin", "titles"])
1233
1233
  const recordTitle = titles?.record || labels.record
1234
1234
 
1235
- selectedRecords.forEach((record) => {
1235
+ const recordsToDelete = [...selectedRecords]
1236
+
1237
+ for (const record of recordsToDelete) {
1236
1238
  if (isGlobalLoading.get(record.id)?.server) {
1237
1239
  alert(
1238
1240
  `Record ${record.id} is currently being written to the server. Please wait for it to finish before deleting.`,
1239
1241
  )
1240
1242
  return
1241
1243
  }
1242
- const serverWrite = isServerDelete(collection, record)
1244
+ }
1243
1245
 
1246
+ recordsToDelete.forEach((record) => {
1244
1247
  setOptimisticDelete(labels.collection, record.id)
1248
+ })
1245
1249
 
1246
- setGlobalLoading("+", record.id, serverWrite, !(serverWrite || isServerReadOnly))
1250
+ const BATCH_SIZE = 5
1251
+ const runDeletes = async () => {
1252
+ for (let i = 0; i < recordsToDelete.length; i += BATCH_SIZE) {
1253
+ const batch = recordsToDelete.slice(i, i + BATCH_SIZE)
1254
+ const batchPromises = batch.map((record) => {
1255
+ const serverWrite = isServerDelete(collection, record)
1247
1256
 
1248
- deleteRecord(record.Collection_Path, record.id)
1249
- .then(() => {
1250
- if (isServerReadOnly) {
1251
- backToStart()
1252
- }
1253
- })
1254
- .catch((error) => {
1255
- console.error(error)
1256
- toast({
1257
- // eslint-disable-next-line security/detect-object-injection
1258
- description: `${recordTitle} ${recordTitleField ? record[recordTitleField] : record.id} failed to delete.`,
1259
- variant: "destructive",
1260
- })
1261
- })
1262
- .finally(() => {
1263
- if (serverWrite || isServerReadOnly) {
1257
+ setGlobalLoading("+", record.id, serverWrite, !(serverWrite || isServerReadOnly))
1258
+
1259
+ const deletePromise = deleteRecord(record.Collection_Path, record.id)
1260
+ .then(() => {
1261
+ if (isServerReadOnly) {
1262
+ backToStart()
1263
+ }
1264
+ })
1265
+ .catch((error) => {
1266
+ console.error(error)
1267
+ toast({
1268
+ // eslint-disable-next-line security/detect-object-injection
1269
+ description: `${recordTitle} ${recordTitleField ? record[recordTitleField] : record.id} failed to delete.`,
1270
+ variant: "destructive",
1271
+ })
1272
+ })
1273
+ .finally(() => {
1274
+ if (serverWrite || isServerReadOnly) {
1275
+ removeOptimisticDelete(labels.collection, record.id)
1276
+ }
1277
+ setGlobalLoading("-", record.id, undefined, !(serverWrite || isServerReadOnly))
1278
+ })
1279
+ if (!serverWrite && !isServerReadOnly) {
1264
1280
  removeOptimisticDelete(labels.collection, record.id)
1265
1281
  }
1266
- setGlobalLoading("-", record.id, undefined, !(serverWrite || isServerReadOnly))
1282
+ return deletePromise
1267
1283
  })
1268
- if (!serverWrite && !isServerReadOnly) {
1269
- removeOptimisticDelete(labels.collection, record.id)
1284
+ await Promise.all(batchPromises)
1270
1285
  }
1271
- })
1286
+ }
1287
+
1288
+ if (isServerReadOnly) {
1289
+ await runDeletes()
1290
+ } else {
1291
+ runDeletes()
1292
+ }
1293
+
1272
1294
  setRowSelection({})
1273
1295
  toast({
1274
- description: `Deleting ${selectedRecords.length} ${selectedRecords.length > 1 ? "records" : "record"}.`,
1296
+ description: `Deleting ${recordsToDelete.length} ${recordsToDelete.length > 1 ? "records" : "record"}.`,
1275
1297
  })
1276
1298
  }, [
1277
1299
  collection,
@@ -1,17 +1,67 @@
1
1
  import * as React from "react"
2
- import { DayPicker } from "react-day-picker"
2
+ import { format } from "date-fns"
3
+ import { DayPicker, type CaptionLabelProps } from "react-day-picker"
3
4
 
4
5
  import { cn } from "@/lib/utils"
5
6
  import { buttonVariants } from "@/components/ui/button"
6
7
  import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
8
+ import YearPicker from "@/components/ui/year-picker"
7
9
 
8
10
  /* eslint-disable react/prop-types */
9
11
 
10
12
  export type CalendarProps = React.ComponentProps<typeof DayPicker>
11
13
 
12
- function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
14
+ function CalendarCaptionLabel({ displayMonth, id, onYearClick }: CaptionLabelProps & { onYearClick: () => void }) {
15
+ return (
16
+ <div className="text-sm font-medium" aria-live="polite" role="presentation" id={id}>
17
+ <button
18
+ type="button"
19
+ onClick={onYearClick}
20
+ className="rounded-sm p-2 hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
21
+ >
22
+ {format(displayMonth, "MMMM ")}
23
+ {format(displayMonth, "yyyy")}
24
+ </button>
25
+ </div>
26
+ )
27
+ }
28
+
29
+ function Calendar({
30
+ className,
31
+ classNames,
32
+ showOutsideDays = true,
33
+ month: monthProp,
34
+ onMonthChange: onMonthChangeProp,
35
+ defaultMonth,
36
+ fromYear,
37
+ toYear,
38
+ components,
39
+ ...props
40
+ }: CalendarProps) {
41
+ const [showYearPicker, setShowYearPicker] = React.useState(false)
42
+ const [internalMonth, setInternalMonth] = React.useState<Date>(defaultMonth ?? new Date())
43
+
44
+ const month = monthProp ?? internalMonth
45
+ const onMonthChange = onMonthChangeProp ?? setInternalMonth
46
+
47
+ if (showYearPicker) {
48
+ return (
49
+ <YearPicker
50
+ currentMonth={month}
51
+ onYearChange={(newMonth) => {
52
+ onMonthChange(newMonth)
53
+ setShowYearPicker(false)
54
+ }}
55
+ fromYear={fromYear}
56
+ toYear={toYear}
57
+ />
58
+ )
59
+ }
60
+
13
61
  return (
14
62
  <DayPicker
63
+ month={month}
64
+ onMonthChange={onMonthChange}
15
65
  showOutsideDays={showOutsideDays}
16
66
  className={cn("p-3", className)}
17
67
  classNames={{
@@ -52,7 +102,13 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
52
102
  components={{
53
103
  IconLeft: () => <ChevronLeftIcon className="h-4 w-4" />,
54
104
  IconRight: () => <ChevronRightIcon className="h-4 w-4" />,
105
+ ...components,
106
+ CaptionLabel: (captionProps) => (
107
+ <CalendarCaptionLabel {...captionProps} onYearClick={() => setShowYearPicker(true)} />
108
+ ),
55
109
  }}
110
+ fromYear={fromYear}
111
+ toYear={toYear}
56
112
  {...props}
57
113
  />
58
114
  )
@@ -0,0 +1,248 @@
1
+ import { format, getYear, isSameYear, setYear, startOfToday } from "date-fns"
2
+ import { ChevronLeft, ChevronRight } from "lucide-react"
3
+ import * as React from "react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+ import { buttonVariants } from "./button"
7
+
8
+ const YEARS_PER_PAGE = 12
9
+ const GRID_COLUMNS = 3
10
+
11
+ interface YearPickerProps {
12
+ currentMonth: Date
13
+ onYearChange: (newMonth: Date) => void
14
+ disabled?: boolean
15
+ fromYear?: number
16
+ toYear?: number
17
+ }
18
+
19
+ export default function YearPicker({
20
+ currentMonth,
21
+ onYearChange,
22
+ disabled,
23
+ fromYear = 1900,
24
+ toYear = 2100,
25
+ }: YearPickerProps) {
26
+ const currentYear = getYear(currentMonth)
27
+ const [startYear, setStartYear] = React.useState(() => Math.floor(currentYear / YEARS_PER_PAGE) * YEARS_PER_PAGE)
28
+ const yearButtonRefs = React.useRef<(HTMLButtonElement | null)[]>([])
29
+
30
+ const years = Array.from({ length: YEARS_PER_PAGE }, (_, index) => startYear + index)
31
+
32
+ const isYearDisabled = React.useCallback(
33
+ (year: number) => disabled || year < fromYear || year > toYear,
34
+ [disabled, fromYear, toYear],
35
+ )
36
+
37
+ const getFirstFocusableIndex = React.useCallback(() => {
38
+ const selectedIndex = years.indexOf(currentYear)
39
+ // eslint-disable-next-line security/detect-object-injection
40
+ if (selectedIndex >= 0 && !isYearDisabled(years[selectedIndex])) {
41
+ return selectedIndex
42
+ }
43
+ return years.findIndex((year) => !isYearDisabled(year))
44
+ }, [years, currentYear, isYearDisabled])
45
+
46
+ const [focusedIndex, setFocusedIndex] = React.useState(0)
47
+
48
+ React.useEffect(() => {
49
+ setStartYear(Math.floor(getYear(currentMonth) / YEARS_PER_PAGE) * YEARS_PER_PAGE)
50
+ }, [currentMonth])
51
+
52
+ React.useEffect(() => {
53
+ setFocusedIndex(getFirstFocusableIndex())
54
+ }, [startYear, getFirstFocusableIndex])
55
+
56
+ React.useEffect(() => {
57
+ // eslint-disable-next-line security/detect-object-injection
58
+ yearButtonRefs.current[focusedIndex]?.focus()
59
+ }, [focusedIndex])
60
+
61
+ const today = startOfToday()
62
+
63
+ function previousPage() {
64
+ setStartYear((year) => Math.max(fromYear, year - YEARS_PER_PAGE))
65
+ }
66
+
67
+ function nextPage() {
68
+ setStartYear((year) => Math.min(toYear - YEARS_PER_PAGE + 1, year + YEARS_PER_PAGE))
69
+ }
70
+
71
+ function getNextFocusableIndex(index: number, step: number) {
72
+ let nextIndex = index + step
73
+ while (nextIndex >= 0 && nextIndex < years.length) {
74
+ // eslint-disable-next-line security/detect-object-injection
75
+ if (!isYearDisabled(years[nextIndex])) {
76
+ return nextIndex
77
+ }
78
+ nextIndex += step
79
+ }
80
+ return index
81
+ }
82
+
83
+ function handleYearKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, index: number, year: number) {
84
+ const yearDate = setYear(currentMonth, year)
85
+
86
+ switch (event.key) {
87
+ case "ArrowRight":
88
+ event.preventDefault()
89
+ setFocusedIndex(getNextFocusableIndex(index, 1))
90
+ break
91
+ case "ArrowLeft":
92
+ event.preventDefault()
93
+ setFocusedIndex(getNextFocusableIndex(index, -1))
94
+ break
95
+ case "ArrowDown": {
96
+ event.preventDefault()
97
+ const column = index % GRID_COLUMNS
98
+ let nextRow = Math.floor(index / GRID_COLUMNS) + 1
99
+ const totalRows = Math.ceil(years.length / GRID_COLUMNS)
100
+ while (nextRow < totalRows) {
101
+ const nextIndex = nextRow * GRID_COLUMNS + column
102
+ // eslint-disable-next-line security/detect-object-injection
103
+ if (nextIndex < years.length && !isYearDisabled(years[nextIndex])) {
104
+ setFocusedIndex(nextIndex)
105
+ return
106
+ }
107
+ nextRow++
108
+ }
109
+ break
110
+ }
111
+ case "ArrowUp": {
112
+ event.preventDefault()
113
+ const column = index % GRID_COLUMNS
114
+ let nextIndex = index - GRID_COLUMNS
115
+ while (nextIndex >= 0) {
116
+ const alignedIndex = Math.floor(nextIndex / GRID_COLUMNS) * GRID_COLUMNS + column
117
+ // eslint-disable-next-line security/detect-object-injection
118
+ if (!isYearDisabled(years[alignedIndex])) {
119
+ setFocusedIndex(alignedIndex)
120
+ return
121
+ }
122
+ nextIndex -= GRID_COLUMNS
123
+ }
124
+ break
125
+ }
126
+ case "Home":
127
+ event.preventDefault()
128
+ setFocusedIndex(getFirstFocusableIndex())
129
+ break
130
+ case "End": {
131
+ event.preventDefault()
132
+ for (let endIndex = years.length - 1; endIndex >= 0; endIndex--) {
133
+ // eslint-disable-next-line security/detect-object-injection
134
+ if (!isYearDisabled(years[endIndex])) {
135
+ setFocusedIndex(endIndex)
136
+ break
137
+ }
138
+ }
139
+ break
140
+ }
141
+ case "PageUp":
142
+ event.preventDefault()
143
+ previousPage()
144
+ break
145
+ case "PageDown":
146
+ event.preventDefault()
147
+ nextPage()
148
+ break
149
+ case "Enter":
150
+ case " ":
151
+ event.preventDefault()
152
+ if (!isYearDisabled(year)) {
153
+ onYearChange(yearDate)
154
+ }
155
+ break
156
+ }
157
+ }
158
+
159
+ return (
160
+ <div className="w-fit p-3">
161
+ <div className="flex w-fit flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0">
162
+ <div className="space-y-4">
163
+ <div className="relative flex w-56 items-center justify-center pt-1">
164
+ <div className="text-sm font-medium" aria-live="polite" role="presentation" id="year-picker">
165
+ {startYear} – {startYear + YEARS_PER_PAGE - 1}
166
+ </div>
167
+ <div className="flex items-center space-x-1">
168
+ <button
169
+ name="previous-years"
170
+ aria-label="Go to previous years"
171
+ className={cn(
172
+ buttonVariants({ variant: "outline" }),
173
+ "absolute left-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
174
+ )}
175
+ type="button"
176
+ onClick={previousPage}
177
+ disabled={disabled || startYear <= fromYear}
178
+ >
179
+ <ChevronLeft className="h-4 w-4" />
180
+ </button>
181
+ <button
182
+ name="next-years"
183
+ aria-label="Go to next years"
184
+ className={cn(
185
+ buttonVariants({ variant: "outline" }),
186
+ "absolute right-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
187
+ )}
188
+ type="button"
189
+ onClick={nextPage}
190
+ disabled={disabled || startYear + YEARS_PER_PAGE - 1 >= toYear}
191
+ >
192
+ <ChevronRight className="h-4 w-4" />
193
+ </button>
194
+ </div>
195
+ </div>
196
+ <div className="grid w-56 grid-cols-3 gap-2" role="grid" aria-labelledby="year-picker">
197
+ {years.map((year, index) => {
198
+ const yearDate = setYear(currentMonth, year)
199
+ const isSelected = isSameYear(yearDate, currentMonth)
200
+ const isCurrentYear = isSameYear(yearDate, today)
201
+ const isDisabled = isYearDisabled(year)
202
+
203
+ return (
204
+ <div
205
+ key={year}
206
+ className="relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent rounded-md"
207
+ role="presentation"
208
+ >
209
+ <button
210
+ ref={(element) => {
211
+ // eslint-disable-next-line security/detect-object-injection
212
+ yearButtonRefs.current[index] = element
213
+ }}
214
+ name="year"
215
+ className={cn(
216
+ buttonVariants({ variant: "ghost" }),
217
+ "inline-flex h-9 w-full items-center justify-center p-0 text-sm font-normal aria-selected:opacity-100",
218
+ isSelected &&
219
+ "bg-blue-500 text-white hover:bg-blue-600 hover:text-white focus:bg-blue-600 focus:text-white",
220
+ !isSelected && isCurrentYear && "bg-accent text-accent-foreground",
221
+ )}
222
+ disabled={isDisabled}
223
+ role="gridcell"
224
+ tabIndex={index === focusedIndex ? 0 : -1}
225
+ type="button"
226
+ aria-selected={isSelected}
227
+ aria-label={
228
+ isSelected
229
+ ? `${year}, selected`
230
+ : isCurrentYear
231
+ ? `${year}, current year`
232
+ : String(year)
233
+ }
234
+ onClick={() => onYearChange(yearDate)}
235
+ onFocus={() => setFocusedIndex(index)}
236
+ onKeyDown={(event) => handleYearKeyDown(event, index, year)}
237
+ >
238
+ <time dateTime={format(yearDate, "yyyy-MM-dd")}>{year}</time>
239
+ </button>
240
+ </div>
241
+ )
242
+ })}
243
+ </div>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ )
248
+ }