@stoker-platform/web-app 0.5.208 → 0.5.209

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,19 @@
1
1
  # @stoker-platform/web-app
2
2
 
3
+ ## 0.5.209
4
+
5
+ ### Patch Changes
6
+
7
+ - feat: improve default filters
8
+ - feat: add image copy-to-clipboard button
9
+ - feat: add temporary search all solution
10
+ - feat: restore relation list state
11
+ - feat: add support for many-to loadAll relation lists
12
+ - fix: fix record page loading issues
13
+ - @stoker-platform/node-client@0.5.81
14
+ - @stoker-platform/utils@0.5.72
15
+ - @stoker-platform/web-client@0.5.85
16
+
3
17
  ## 0.5.208
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stoker-platform/web-app",
3
- "version": "0.5.208",
3
+ "version": "0.5.209",
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.80",
55
- "@stoker-platform/utils": "0.5.71",
56
- "@stoker-platform/web-client": "0.5.84",
54
+ "@stoker-platform/node-client": "0.5.81",
55
+ "@stoker-platform/utils": "0.5.72",
56
+ "@stoker-platform/web-client": "0.5.85",
57
57
  "@tanstack/react-table": "^8.21.3",
58
58
  "@types/react": "18.3.13",
59
59
  "@types/react-dom": "18.3.1",
@@ -60,6 +60,7 @@ import cloneDeep from "lodash/cloneDeep.js"
60
60
  import { Map as StokerMap } from "./Map"
61
61
  import { Calendar } from "./Calendar"
62
62
  import { useStokerState } from "./providers/StateProvider"
63
+ import { loadFilters } from "./utils/relationListFiltersState"
63
64
  import { Filters } from "./Filters"
64
65
  import { FirestoreError, QueryConstraint, Timestamp, where, WhereFilterOp } from "firebase/firestore"
65
66
  import { useFilters } from "./providers/FiltersProvider"
@@ -238,7 +239,9 @@ function Collection({
238
239
  const [tab, setTab] = useState<string | undefined>("list")
239
240
  const tabRef = useRef<string | undefined>(undefined)
240
241
  const prevTabRef = useRef<string | undefined>(undefined)
241
- const [statusFilter, setStatusFilter] = useState<"active" | "archived" | "all" | "trash" | undefined>("active")
242
+ const [statusFilter, setStatusFilter] = useState<"active" | "archived" | "all" | "trash" | undefined>(
243
+ relationList ? "all" : "active",
244
+ )
242
245
  const [firstTabLoadCards, setFirstTabLoadCards] = useState<boolean | undefined>(undefined)
243
246
  const [revertingStatusFilter, setRevertingStatusFilter] = useState(false)
244
247
  const [rangeSelector, setRangeSelector] = useState<"range" | "week" | "month" | undefined>(undefined)
@@ -714,6 +717,8 @@ function Collection({
714
717
  }
715
718
  }
716
719
 
720
+ const relationListField = getField(fields, relationList?.field)
721
+
717
722
  const subscribeOptions = {
718
723
  ...currentQuery.options,
719
724
  constraints: combineQueryConstraints([
@@ -724,10 +729,23 @@ function Collection({
724
729
  ]),
725
730
  tempCache:
726
731
  isPreloadCacheEnabled && relationList?.loadAll
727
- ? {
728
- label: `${labels.collection}-${relationList?.field}`,
729
- constraints: [[`${relationList?.field}_Single.id`, "==", relationParent?.id]],
730
- }
732
+ ? ["OneToOne", "OneToMany"].includes(relationListField?.type)
733
+ ? {
734
+ label: `${labels.collection}-${relationList?.field}`,
735
+ constraints: [
736
+ [`${relationList?.field}_Single.id`, "==", relationParent?.id],
737
+ ],
738
+ }
739
+ : {
740
+ label: `${labels.collection}-${relationList?.field}`,
741
+ constraints: [
742
+ [
743
+ `${relationList?.field}_Array`,
744
+ "array-contains",
745
+ relationParent?.id,
746
+ ],
747
+ ],
748
+ }
731
749
  : undefined,
732
750
  multipleQueries: multipleQueries.length > 0 ? multipleQueries : undefined,
733
751
  } as SubscribeManyOptions
@@ -996,7 +1014,7 @@ function Collection({
996
1014
  const statusField = await getCachedConfigValue(customization, [...collectionAdminPath, "statusField"])
997
1015
  setStatusField(statusField)
998
1016
  if (!statusFilterState) {
999
- if (statusField?.active) {
1017
+ if (!relationList && statusField?.active) {
1000
1018
  setStatusFilter("active")
1001
1019
  } else {
1002
1020
  setStatusFilter("all")
@@ -1057,6 +1075,7 @@ function Collection({
1057
1075
  })
1058
1076
  }
1059
1077
  } else {
1078
+ const savedFilterValues = loadFilters(location.pathname)
1060
1079
  filtersClone.forEach((filter: Filter) => {
1061
1080
  if (filter.type === "status" || filter.type === "range") {
1062
1081
  return
@@ -1068,6 +1087,24 @@ function Collection({
1068
1087
  !isAssigning
1069
1088
  ) {
1070
1089
  filter.value = relationParent.id
1090
+ return
1091
+ }
1092
+ if (savedFilterValues) {
1093
+ if (filter.type === "relation" && filter.field === relationList.field) {
1094
+ return
1095
+ }
1096
+ const filterValue = savedFilterValues.find((value) => value.split("=")[0] === filter.field)
1097
+ if (filterValue) {
1098
+ const field = getField(fields, filter.field)
1099
+ if (field.type === "Number") {
1100
+ // eslint-disable-next-line security/detect-object-injection
1101
+ filter.value = Number(filterValue.split("=")[1])
1102
+ } else {
1103
+ // eslint-disable-next-line security/detect-object-injection
1104
+ filter.value = filterValue.split("=")[1]
1105
+ }
1106
+ }
1107
+ return
1071
1108
  }
1072
1109
  if (filter.type === "select" && filter.defaultValue) {
1073
1110
  filter.value = tryFunction(filter.defaultValue, [
@@ -1091,7 +1128,7 @@ function Collection({
1091
1128
  if (statusField || softDelete) {
1092
1129
  if (!relationList && statusFilterState) {
1093
1130
  filtersClone.push({ type: "status", value: statusFilterState })
1094
- } else if (statusField && statusField.active && statusField.active.length > 0) {
1131
+ } else if (!relationList && statusField && statusField.active && statusField.active.length > 0) {
1095
1132
  filtersClone.push({ type: "status", value: "active" })
1096
1133
  } else {
1097
1134
  filtersClone.push({ type: "status", value: "all" })
@@ -2085,7 +2122,7 @@ function Collection({
2085
2122
  <ToggleGroup
2086
2123
  onValueChange={onStatusFilterChange}
2087
2124
  value={statusFilter}
2088
- defaultValue="active"
2125
+ defaultValue={relationList ? "all" : "active"}
2089
2126
  size="sm"
2090
2127
  type="single"
2091
2128
  variant="outline"
@@ -2094,12 +2131,15 @@ function Collection({
2094
2131
  {statusField?.active &&
2095
2132
  (tab !== "cards" || !autoUpdateStatusFilter) && (
2096
2133
  <ToggleGroupItem
2097
- className="h-7 bg-muted data-[state=on]:bg-background"
2134
+ className="h-7 bg-muted data-[state=on]:bg-background relative"
2098
2135
  value="active"
2099
2136
  aria-label="Toggle active"
2100
2137
  disabled={isRouteLoading.has(location.pathname)}
2101
2138
  >
2102
2139
  Active
2140
+ {relationList && statusFilter === "active" && (
2141
+ <span className="absolute top-0 right-0 transform translate-x-1/2 -translate-y-1/2 block h-3 w-3 rounded-full bg-destructive"></span>
2142
+ )}
2103
2143
  </ToggleGroupItem>
2104
2144
  )}
2105
2145
  {statusField?.archived &&
@@ -2124,6 +2164,7 @@ function Collection({
2124
2164
  >
2125
2165
  All
2126
2166
  {statusField &&
2167
+ !relationList &&
2127
2168
  statusFilter === "all" &&
2128
2169
  tab !== "cards" &&
2129
2170
  !revertingStatusFilter && (
package/src/Filters.tsx CHANGED
@@ -30,6 +30,7 @@ import { useFilters } from "./providers/FiltersProvider"
30
30
  import { useRouteLoading } from "./providers/LoadingProvider"
31
31
  import { useLocation } from "react-router"
32
32
  import { useStokerState } from "./providers/StateProvider"
33
+ import { saveFilters } from "./utils/relationListFiltersState"
33
34
  import { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover"
34
35
  import { Sheet, SheetContent, SheetTrigger } from "./components/ui/sheet"
35
36
  import { useIsMobile } from "./hooks/use-mobile"
@@ -245,7 +246,13 @@ export function Filters({
245
246
  })
246
247
  }
247
248
  const filterParam = newFilters
248
- .filter((filter: Filter) => filter.type !== "status" && filter.type !== "range" && filter.value)
249
+ .filter(
250
+ (filter: Filter) =>
251
+ filter.type !== "status" &&
252
+ filter.type !== "range" &&
253
+ filter.value &&
254
+ !(relationList && filter.type === "relation" && filter.field === relationList.field),
255
+ )
249
256
  .map((filter: Filter) => {
250
257
  if (filter.type !== "status" && filter.type !== "range" && filter.value) {
251
258
  return `${filter.field}=${filter.value.toString()}`
@@ -259,9 +266,11 @@ export function Filters({
259
266
  } else {
260
267
  setState(`collection-filters-${labels.collection.toLowerCase()}`, "filters", "DELETE_STATE")
261
268
  }
269
+ } else {
270
+ saveFilters(location.pathname, relationList, newFilters)
262
271
  }
263
272
  },
264
- [preventChange],
273
+ [preventChange, location.pathname, relationList],
265
274
  )
266
275
 
267
276
  const pickerDebounceTimeout = useRef<NodeJS.Timeout>()
@@ -492,12 +501,24 @@ export function Filters({
492
501
  <Label htmlFor={title}>{title}:</Label>
493
502
  <RadioGroup defaultValue="no_selection" className="mt-2">
494
503
  {values.map((value: string) => {
495
- if (filter.filterValues && filter.filterValues(value) === false) return null
504
+ if (
505
+ filter.filterValues &&
506
+ filter.filterValues(
507
+ value,
508
+ relationCollection,
509
+ relationParent,
510
+ isAssigning,
511
+ ) === false
512
+ )
513
+ return null
514
+ const title = filter.titles
515
+ ? filter.titles(value, relationCollection, relationParent, isAssigning)
516
+ : value
496
517
  return (
497
- <div key={value} className="flex items-center space-x-2">
518
+ <div key={title} className="flex items-center space-x-2">
498
519
  <RadioGroupItem
499
520
  value={value}
500
- id={value}
521
+ id={title}
501
522
  // eslint-disable-next-line security/detect-object-injection
502
523
  checked={inputValue[filter.field] === value}
503
524
  disabled={disabled}
@@ -511,7 +532,7 @@ export function Filters({
511
532
  })
512
533
  }}
513
534
  />
514
- <Label htmlFor={value}>{value}</Label>
535
+ <Label htmlFor={title}>{title}</Label>
515
536
  </div>
516
537
  )
517
538
  })}
@@ -543,10 +564,23 @@ export function Filters({
543
564
  <Label htmlFor={title}>{title}:</Label>
544
565
  <div className="mt-2 flex flex-col gap-2">
545
566
  {values.map((value: string) => {
546
- if (filter.filterValues && filter.filterValues(value) === false) return null
567
+ if (
568
+ filter.filterValues &&
569
+ filter.filterValues(
570
+ value,
571
+ relationCollection,
572
+ relationParent,
573
+ isAssigning,
574
+ ) === false
575
+ )
576
+ return null
577
+ const title = filter.titles
578
+ ? filter.titles(value, relationCollection, relationParent, isAssigning)
579
+ : value
547
580
  return (
548
581
  <Button
549
- key={value}
582
+ key={title}
583
+ id={title}
550
584
  // eslint-disable-next-line security/detect-object-injection
551
585
  variant={inputValue[filter.field] === value ? "default" : "outline"}
552
586
  disabled={disabled}
@@ -564,7 +598,7 @@ export function Filters({
564
598
  isFilterInactive(filter) && "disabled:opacity-50",
565
599
  )}
566
600
  >
567
- {value}
601
+ {title}
568
602
  </Button>
569
603
  )
570
604
  })}
@@ -618,10 +652,27 @@ export function Filters({
618
652
  <SelectContent>
619
653
  <SelectItem value="no_selection">----</SelectItem>
620
654
  {values.map((value: string) => {
621
- if (filter.filterValues && filter.filterValues(value) === false) return
655
+ if (
656
+ filter.filterValues &&
657
+ filter.filterValues(
658
+ value,
659
+ relationCollection,
660
+ relationParent,
661
+ isAssigning,
662
+ ) === false
663
+ )
664
+ return
665
+ const title = filter.titles
666
+ ? filter.titles(
667
+ value,
668
+ relationCollection,
669
+ relationParent,
670
+ isAssigning,
671
+ )
672
+ : value
622
673
  return (
623
- <SelectItem key={value} value={value}>
624
- {value}
674
+ <SelectItem key={title} value={value}>
675
+ {title}
625
676
  </SelectItem>
626
677
  )
627
678
  })}
@@ -842,12 +893,23 @@ export function Filters({
842
893
  const field = getField(fields, filter.field)
843
894
  if (!field) return
844
895
  if (filter.type === "select") {
896
+ let resetValue = "no_selection"
897
+ if (filter.defaultValue) {
898
+ const defaultValue = tryFunction(filter.defaultValue, [
899
+ relationCollection,
900
+ relationParent,
901
+ isAssigning,
902
+ ])
903
+ if (defaultValue !== undefined && defaultValue !== null) {
904
+ resetValue = defaultValue.toString()
905
+ }
906
+ }
845
907
  setValue((prev) => ({
846
908
  ...prev,
847
- [filter.field]: "no_selection",
909
+ [filter.field]: resetValue,
848
910
  }))
849
911
  startTransition(() => {
850
- handleChange(filter, "no_selection", field.type)
912
+ handleChange(filter, resetValue, field.type)
851
913
  })
852
914
  }
853
915
  if (filter.type === "relation") {
@@ -866,7 +928,7 @@ export function Filters({
866
928
  })
867
929
  }}
868
930
  >
869
- Clear Filters
931
+ Reset Filters
870
932
  </Button>
871
933
  )}
872
934
  </div>
package/src/Form.tsx CHANGED
@@ -150,6 +150,7 @@ import { Slider } from "./components/ui/slider"
150
150
  import { RadioGroup, RadioGroupItem } from "./components/ui/radio-group"
151
151
  import { getFormattedFieldValue } from "./utils/getFormattedFieldValue"
152
152
  import { getSafeUrl } from "./utils/isSafeUrl"
153
+ import { CopyImageOverlay } from "./utils/copyImageOverlay"
153
154
  import { useConnection } from "./providers/ConnectionProvider"
154
155
  import { getAuth } from "firebase/auth"
155
156
  import Quill, { Delta, type Op } from "quill"
@@ -916,10 +917,11 @@ function ImageField({
916
917
  <FormControl>
917
918
  <div className="flex flex-col gap-2">
918
919
  {formField.value && typeof formField.value === "string" && (
919
- <div
920
+ <CopyImageOverlay
921
+ src={formField.value}
920
922
  className={cn(
921
923
  isDisabled || (formField.value && !imageLoaded) ? "h-[300px]" : "max-h-[300px]",
922
- "max-w-full",
924
+ "max-w-full w-fit",
923
925
  )}
924
926
  >
925
927
  <img
@@ -929,7 +931,7 @@ function ImageField({
929
931
  onLoad={() => setImageLoaded(true)}
930
932
  onError={() => setImageLoaded(false)}
931
933
  />
932
- </div>
934
+ </CopyImageOverlay>
933
935
  )}
934
936
  <input
935
937
  type="file"
package/src/Images.tsx CHANGED
@@ -38,6 +38,7 @@ import { localFullTextSearch } from "./utils/localFullTextSearch"
38
38
  import { Helmet } from "react-helmet"
39
39
  import { useConnection } from "./providers/ConnectionProvider"
40
40
  import { getSafeUrl } from "./utils/isSafeUrl"
41
+ import { CopyImageOverlay } from "./utils/copyImageOverlay"
41
42
  import { Switch } from "./components/ui/switch"
42
43
  import { Label } from "./components/ui/label"
43
44
  import { Badge } from "./components/ui/badge"
@@ -284,19 +285,26 @@ const Row = ({ index, style, data }: RowProps) => {
284
285
  </div>
285
286
  )}
286
287
  <div className={cn("grid", "gap-4", size)}>
287
- <button
288
- className="relative w-full h-full flex items-center justify-center overflow-hidden"
289
- onClick={() => goToRecord(collection, record)}
290
- >
291
- {record[imagesConfig.imageField] ? (
292
- <RowImage alt={title} src={record[imagesConfig.imageField]} />
293
- ) : (
288
+ {record[imagesConfig.imageField] ? (
289
+ <CopyImageOverlay src={record[imagesConfig.imageField]} className="w-full h-full">
290
+ <button
291
+ className="relative w-full h-full flex items-center justify-center overflow-hidden"
292
+ onClick={() => goToRecord(collection, record)}
293
+ >
294
+ <RowImage alt={title} src={record[imagesConfig.imageField]} />
295
+ </button>
296
+ </CopyImageOverlay>
297
+ ) : (
298
+ <button
299
+ className="relative w-full h-full flex items-center justify-center overflow-hidden"
300
+ onClick={() => goToRecord(collection, record)}
301
+ >
294
302
  <Image
295
303
  size={imagesConfig.size === "sm" ? 30 : 100}
296
304
  className="text-muted-foreground stroke-1 opacity-50"
297
305
  />
298
- )}
299
- </button>
306
+ </button>
307
+ )}
300
308
  </div>
301
309
  </CardContent>
302
310
  </Card>
package/src/Record.tsx CHANGED
@@ -34,6 +34,7 @@ import { SidebarFilters } from "./SidebarFilters"
34
34
  import Collection from "./Collection"
35
35
  import { Breadcrumbs } from "./Breadcrumbs"
36
36
  import { Separator } from "./components/ui/separator"
37
+ import { loadAssigning } from "./utils/relationListFiltersState"
37
38
 
38
39
  export const Record = ({ collection }: { collection: CollectionSchema }) => {
39
40
  const { labels, fields, recordTitleField } = collection
@@ -81,7 +82,12 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
81
82
  const [breadcrumbs, setBreadcrumbs] = useState<string[] | undefined>(undefined)
82
83
  const [customRecordPages, setCustomRecordPages] = useState<CustomRecordPage[] | undefined>(undefined)
83
84
 
84
- const [isAssigning, setIsAssigning] = useState<Record<string, boolean>>({})
85
+ const [isAssigning, setIsAssigning] = useState<Record<string, boolean>>(() => {
86
+ const saved = loadAssigning(location.pathname)
87
+ if (saved === undefined) return {}
88
+ const page = location.pathname.split("/").filter(Boolean).pop()
89
+ return page ? { [page]: saved } : {}
90
+ })
85
91
  const [assignable, setAssignable] = useState<Assignable[] | undefined>(undefined)
86
92
  const [sidebarFiltersContainer, setSidebarFiltersContainer] = useState<HTMLDivElement | null>(null)
87
93
 
@@ -151,6 +157,7 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
151
157
  }
152
158
  },
153
159
  {
160
+ only: "default",
154
161
  noEmbeddingFields: true,
155
162
  relations: { fields: relationFields },
156
163
  },
@@ -206,7 +213,7 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
206
213
  </header>
207
214
  <main className="grid flex-1 items-start gap-4 p-4 lg:px-6 lg:py-0 md:gap-8">
208
215
  <Card className="min-h-screen xl:min-h-full xl:h-[calc(100vh-160px)]">
209
- {record && (
216
+ {record && record.id === id && (
210
217
  <CardContent className="px-0">
211
218
  <SidebarProvider defaultOpen={true} open={true} className="flex flex-col lg:flex-row">
212
219
  <RecordSidebar
@@ -223,6 +230,7 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
223
230
  element={
224
231
  <main className="p-4 w-full overflow-y-auto min-h-screen xl:min-h-full xl:h-[calc(100vh-160px)]">
225
232
  <RecordForm
233
+ key={record.id}
226
234
  collection={collection}
227
235
  operation="update"
228
236
  path={path}
@@ -354,6 +362,7 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
354
362
  element={
355
363
  <main className="p-4 w-full overflow-y-auto min-h-screen xl:min-h-full xl:h-[calc(100vh-160px)]">
356
364
  <RecordForm
365
+ key={record.id}
357
366
  collection={collection}
358
367
  operation="update"
359
368
  path={path}
@@ -20,6 +20,7 @@ import {
20
20
  import { collectionAccess, getField, isRelationField, tryFunction, tryPromise } from "@stoker-platform/utils"
21
21
  import { getCurrentUserPermissions, getCollectionConfigModule, getSchema } from "@stoker-platform/web-client"
22
22
  import { runViewTransition } from "./utils/runViewTransition"
23
+ import { saveAssigning } from "./utils/relationListFiltersState"
23
24
  import { useEffect, useState } from "react"
24
25
 
25
26
  interface SidebarItem {
@@ -132,11 +133,21 @@ export const RecordSidebar = ({
132
133
  })
133
134
  }
134
135
 
136
+ const relationListPath = (page: string) => `/${labels.record.toLowerCase()}/${path}/${id}/${page}`
137
+
135
138
  const goToRecordPage = (page: string) => {
136
- if (location.pathname === `/${labels.record.toLowerCase()}/${path}/${id}/${page}`) {
139
+ if (location.pathname === relationListPath(page)) {
137
140
  return
138
141
  }
139
- runViewTransition(() => navigate(`/${labels.record.toLowerCase()}/${path}/${id}/${page}`))
142
+ runViewTransition(() => navigate(relationListPath(page)))
143
+ }
144
+
145
+ const setAssigning = (page: string, assigning: boolean) => {
146
+ setIsAssigning({
147
+ ...isAssigning,
148
+ [page]: assigning,
149
+ })
150
+ saveAssigning(relationListPath(page), assigning)
140
151
  }
141
152
 
142
153
  const anyCustomActive = customItems.some((item) => location.pathname.includes(item.page))
@@ -166,12 +177,7 @@ export const RecordSidebar = ({
166
177
  {item.assignable && isActive && !isAssigning?.[item.page] && (
167
178
  <button
168
179
  className="ml-auto"
169
- onClick={() =>
170
- setIsAssigning({
171
- ...isAssigning,
172
- [item.page]: true,
173
- })
174
- }
180
+ onClick={() => setAssigning(item.page, true)}
175
181
  type="button"
176
182
  >
177
183
  <Pencil className="w-4 h-4" />
@@ -180,12 +186,7 @@ export const RecordSidebar = ({
180
186
  {item.assignable && isActive && isAssigning?.[item.page] && (
181
187
  <button
182
188
  className="ml-auto"
183
- onClick={() =>
184
- setIsAssigning({
185
- ...isAssigning,
186
- [item.page]: false,
187
- })
188
- }
189
+ onClick={() => setAssigning(item.page, false)}
189
190
  type="button"
190
191
  >
191
192
  <List className="w-4 h-4" />
@@ -237,12 +238,7 @@ export const RecordSidebar = ({
237
238
  {item.assignable && !isAssigning?.[item.page] && (
238
239
  <button
239
240
  className="ml-auto"
240
- onClick={() =>
241
- setIsAssigning({
242
- ...isAssigning,
243
- [item.page]: true,
244
- })
245
- }
241
+ onClick={() => setAssigning(item.page, true)}
246
242
  type="button"
247
243
  >
248
244
  <Pencil className="w-4 h-4" />
@@ -251,12 +247,7 @@ export const RecordSidebar = ({
251
247
  {item.assignable && isAssigning?.[item.page] && (
252
248
  <button
253
249
  className="ml-auto"
254
- onClick={() =>
255
- setIsAssigning({
256
- ...isAssigning,
257
- [item.page]: false,
258
- })
259
- }
250
+ onClick={() => setAssigning(item.page, false)}
260
251
  type="button"
261
252
  >
262
253
  <List className="w-4 h-4" />
@@ -8,6 +8,7 @@ import {
8
8
  getCachedConfigValue,
9
9
  getCollectionConfigModule,
10
10
  getCurrentUserPermissions,
11
+ getGlobalConfigModule,
11
12
  getLoadingState,
12
13
  getSome,
13
14
  subscribeMany,
@@ -22,9 +23,12 @@ import { performFullTextSearch } from "./utils/performFullTextSearch"
22
23
  import { localFullTextSearch } from "./utils/localFullTextSearch"
23
24
  import { useConnection } from "./providers/ConnectionProvider"
24
25
  import { SearchResult } from "minisearch"
26
+ import { tryFunction } from "@stoker-platform/utils"
25
27
 
26
28
  export function SearchAllResults({ collection, search }: { collection: CollectionSchema; search: string }) {
27
29
  const { labels, fullTextSearch, recordTitleField, softDelete } = collection
30
+ const globalConfig = getGlobalConfigModule()
31
+ const searchAll = tryFunction(globalConfig.admin?.searchAll)
28
32
  const customization = getCollectionConfigModule(labels.collection)
29
33
  const permissions = getCurrentUserPermissions()
30
34
  if (!permissions?.Role) throw new Error("PERMISSION_DENIED")
@@ -131,6 +135,8 @@ export function SearchAllResults({ collection, search }: { collection: Collectio
131
135
  {
132
136
  constraints: currentQuery.constraints as QueryConstraint[],
133
137
  pagination: isPreloadCacheEnabled ? undefined : { number: MAX_RESULTS },
138
+ // Temporary solution for searching all records, including those outside the preloaded range
139
+ only: searchAll ? "default" : undefined,
134
140
  },
135
141
  )
136
142
  const { unsubscribe: newUnsubscribe } = result
@@ -0,0 +1,79 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react"
2
+ import { Copy } from "lucide-react"
3
+ import { Button } from "../components/ui/button"
4
+ import { cn } from "../lib/utils"
5
+ import { copyImageToClipboard } from "./copyImageToClipboard"
6
+ import { useToast } from "../hooks/use-toast"
7
+
8
+ const DELAY_MS = 1500
9
+
10
+ export const CopyImageOverlay = ({
11
+ src,
12
+ className,
13
+ children,
14
+ }: {
15
+ src: string
16
+ className?: string
17
+ children: React.ReactNode
18
+ }) => {
19
+ const { toast } = useToast()
20
+ const [revealed, setRevealed] = useState(false)
21
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
22
+
23
+ const clearTimer = useCallback(() => {
24
+ if (timerRef.current) {
25
+ clearTimeout(timerRef.current)
26
+ timerRef.current = null
27
+ }
28
+ }, [])
29
+
30
+ useEffect(() => () => clearTimer(), [clearTimer])
31
+
32
+ const handleMouseEnter = useCallback(() => {
33
+ clearTimer()
34
+ timerRef.current = setTimeout(() => setRevealed(true), DELAY_MS)
35
+ }, [clearTimer])
36
+
37
+ const handleMouseLeave = useCallback(() => {
38
+ clearTimer()
39
+ setRevealed(false)
40
+ }, [clearTimer])
41
+
42
+ const handleCopy = useCallback(
43
+ async (event: React.MouseEvent) => {
44
+ event.stopPropagation()
45
+ event.preventDefault()
46
+ try {
47
+ await copyImageToClipboard(src)
48
+ toast({ title: "Image copied", description: "The image has been copied to the clipboard" })
49
+ } catch {
50
+ toast({
51
+ title: "Copy failed",
52
+ description: "Could not copy the image to the clipboard",
53
+ variant: "destructive",
54
+ })
55
+ }
56
+ },
57
+ [src, toast],
58
+ )
59
+
60
+ return (
61
+ <div className={cn("relative", className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
62
+ {children}
63
+ <Button
64
+ type="button"
65
+ variant="secondary"
66
+ size="icon"
67
+ title="Copy image"
68
+ tabIndex={revealed ? 0 : -1}
69
+ className={cn(
70
+ "absolute top-1 right-1 z-10 transition-opacity",
71
+ revealed ? "opacity-100" : "opacity-0 pointer-events-none",
72
+ )}
73
+ onClick={handleCopy}
74
+ >
75
+ <Copy className="w-4 h-4" />
76
+ </Button>
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,26 @@
1
+ import { getSafeUrl } from "./isSafeUrl"
2
+
3
+ export const copyImageToClipboard = async (src: string) => {
4
+ const safeSrc = getSafeUrl(src)
5
+ if (!safeSrc) throw new Error("Invalid image URL")
6
+ const pngBlob = (async () => {
7
+ const response = await fetch(safeSrc)
8
+ if (!response.ok) throw new Error("Failed to fetch image")
9
+ const blob = await response.blob()
10
+ if (blob.type === "image/png") return blob
11
+ const bitmap = await createImageBitmap(blob)
12
+ const canvas = document.createElement("canvas")
13
+ canvas.width = bitmap.width
14
+ canvas.height = bitmap.height
15
+ const context = canvas.getContext("2d")
16
+ if (!context) throw new Error("Failed to get canvas context")
17
+ context.drawImage(bitmap, 0, 0)
18
+ return new Promise<Blob>((resolve, reject) => {
19
+ canvas.toBlob(
20
+ (result) => (result ? resolve(result) : reject(new Error("Failed to convert image"))),
21
+ "image/png",
22
+ )
23
+ })
24
+ })()
25
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": pngBlob })])
26
+ }
@@ -0,0 +1,53 @@
1
+ import { Filter, RelationList } from "@stoker-platform/types"
2
+ import { getState } from "./getState"
3
+ import { saveState } from "./saveState"
4
+
5
+ const FILTERS_KEY = "relation-list-filters"
6
+ const ASSIGNING_KEY = "relation-list-assigning"
7
+
8
+ export const serializeFilters = (relationList: RelationList, filters: Filter[]) =>
9
+ filters
10
+ .filter(
11
+ (filter) =>
12
+ filter.type !== "status" &&
13
+ filter.type !== "range" &&
14
+ !!filter.value &&
15
+ relationList.showFilters?.includes(filter.field),
16
+ )
17
+ .map((filter) => {
18
+ if (filter.type === "status" || filter.type === "range" || !filter.value) return ""
19
+ return `${filter.field}=${filter.value.toString()}`
20
+ })
21
+ .filter(Boolean)
22
+ .join(",")
23
+
24
+ export const saveFilters = (pathname: string, relationList: RelationList, filters: Filter[]) => {
25
+ const filterParam = serializeFilters(relationList, filters)
26
+ if (!filterParam) return
27
+ saveState(FILTERS_KEY, `${pathname}|${filterParam}`)
28
+ }
29
+
30
+ export const loadFilters = (pathname: string): string[] | undefined => {
31
+ // eslint-disable-next-line security/detect-object-injection
32
+ const state = getState()[FILTERS_KEY] as string | undefined
33
+ if (!state) return undefined
34
+ const separatorIndex = state.indexOf("|")
35
+ if (separatorIndex < 0) return undefined
36
+ if (state.slice(0, separatorIndex) !== pathname) return undefined
37
+ const savedFilters = state.slice(separatorIndex + 1)
38
+ return savedFilters ? savedFilters.split(",") : []
39
+ }
40
+
41
+ export const saveAssigning = (pathname: string, isAssigning: boolean) => {
42
+ saveState(ASSIGNING_KEY, `${pathname}|${isAssigning ? "true" : "false"}`)
43
+ }
44
+
45
+ export const loadAssigning = (pathname: string): boolean | undefined => {
46
+ // eslint-disable-next-line security/detect-object-injection
47
+ const state = getState()[ASSIGNING_KEY] as string | undefined
48
+ if (!state) return undefined
49
+ const separatorIndex = state.indexOf("|")
50
+ if (separatorIndex < 0) return undefined
51
+ if (state.slice(0, separatorIndex) !== pathname) return undefined
52
+ return state.slice(separatorIndex + 1) === "true"
53
+ }