@stoker-platform/web-app 0.5.207 → 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 +20 -0
- package/package.json +4 -4
- package/src/Cards.tsx +9 -6
- package/src/Collection.tsx +50 -9
- package/src/Filters.tsx +77 -15
- package/src/Form.tsx +5 -3
- package/src/Images.tsx +25 -14
- package/src/List.tsx +8 -5
- package/src/Record.tsx +11 -2
- package/src/RecordSidebar.tsx +17 -26
- package/src/SearchAllResults.tsx +6 -0
- package/src/utils/copyImageOverlay.tsx +79 -0
- package/src/utils/copyImageToClipboard.ts +26 -0
- package/src/utils/relationListFiltersState.ts +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.5.208
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- feat: reduce clear search debounce interval
|
|
22
|
+
|
|
3
23
|
## 0.5.207
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stoker-platform/web-app",
|
|
3
|
-
"version": "0.5.
|
|
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.
|
|
55
|
-
"@stoker-platform/utils": "0.5.
|
|
56
|
-
"@stoker-platform/web-client": "0.5.
|
|
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",
|
package/src/Cards.tsx
CHANGED
|
@@ -1245,12 +1245,15 @@ export function Cards({
|
|
|
1245
1245
|
}, [backToStartKey])
|
|
1246
1246
|
|
|
1247
1247
|
useEffect(() => {
|
|
1248
|
-
const timer = setTimeout(
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1248
|
+
const timer = setTimeout(
|
|
1249
|
+
() => {
|
|
1250
|
+
if (isInitialized && fullTextSearch && !isPreloadCacheEnabled && !isServerReadOnly) {
|
|
1251
|
+
const constraints = removeCardsStatusFilter(filters)
|
|
1252
|
+
backToStart(constraints as QueryConstraint[])
|
|
1253
|
+
}
|
|
1254
|
+
},
|
|
1255
|
+
search ? 750 : 250,
|
|
1256
|
+
)
|
|
1254
1257
|
return () => clearTimeout(timer)
|
|
1255
1258
|
}, [search])
|
|
1256
1259
|
|
package/src/Collection.tsx
CHANGED
|
@@ -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>(
|
|
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
|
-
|
|
729
|
-
|
|
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(
|
|
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 (
|
|
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={
|
|
518
|
+
<div key={title} className="flex items-center space-x-2">
|
|
498
519
|
<RadioGroupItem
|
|
499
520
|
value={value}
|
|
500
|
-
id={
|
|
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={
|
|
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 (
|
|
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={
|
|
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
|
-
{
|
|
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 (
|
|
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={
|
|
624
|
-
{
|
|
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]:
|
|
909
|
+
[filter.field]: resetValue,
|
|
848
910
|
}))
|
|
849
911
|
startTransition(() => {
|
|
850
|
-
handleChange(filter,
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
288
|
-
className="
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
306
|
+
</button>
|
|
307
|
+
)}
|
|
300
308
|
</div>
|
|
301
309
|
</CardContent>
|
|
302
310
|
</Card>
|
|
@@ -514,11 +522,14 @@ export const Images = memo(
|
|
|
514
522
|
setCursor({})
|
|
515
523
|
infiniteLoaderRef.current?.resetloadMoreItemsCache()
|
|
516
524
|
}
|
|
517
|
-
const timer = setTimeout(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
525
|
+
const timer = setTimeout(
|
|
526
|
+
() => {
|
|
527
|
+
if (fullTextSearch && !isPreloadCacheEnabled && !isServerReadOnly) {
|
|
528
|
+
backToStart()
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
search ? 750 : 250,
|
|
532
|
+
)
|
|
522
533
|
return () => clearTimeout(timer)
|
|
523
534
|
}, [search])
|
|
524
535
|
|
package/src/List.tsx
CHANGED
|
@@ -867,11 +867,14 @@ export function List({
|
|
|
867
867
|
}, [])
|
|
868
868
|
|
|
869
869
|
useEffect(() => {
|
|
870
|
-
const timer = setTimeout(
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
870
|
+
const timer = setTimeout(
|
|
871
|
+
() => {
|
|
872
|
+
if (isInitialized && fullTextSearch && !isPreloadCacheEnabled && !isServerReadOnly) {
|
|
873
|
+
backToStart()
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
search ? 750 : 250,
|
|
877
|
+
)
|
|
875
878
|
|
|
876
879
|
if (isInitialized && (isPreloadCacheEnabled || isServerReadOnly || isServerFullTextSearch)) {
|
|
877
880
|
setPageIndex(0)
|
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}
|
package/src/RecordSidebar.tsx
CHANGED
|
@@ -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 ===
|
|
139
|
+
if (location.pathname === relationListPath(page)) {
|
|
137
140
|
return
|
|
138
141
|
}
|
|
139
|
-
runViewTransition(() => navigate(
|
|
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" />
|
package/src/SearchAllResults.tsx
CHANGED
|
@@ -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
|
+
}
|