@stoker-platform/web-app 0.5.48 → 0.5.50

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,20 @@
1
1
  # @stoker-platform/web-app
2
2
 
3
+ ## 0.5.50
4
+
5
+ ### Patch Changes
6
+
7
+ - fix: prevent clearing relation list filter
8
+
9
+ ## 0.5.49
10
+
11
+ ### Patch Changes
12
+
13
+ - feat: add relation record assignment feature
14
+ - @stoker-platform/node-client@0.5.32
15
+ - @stoker-platform/utils@0.5.26
16
+ - @stoker-platform/web-client@0.5.28
17
+
3
18
  ## 0.5.48
4
19
 
5
20
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stoker-platform/web-app",
3
- "version": "0.5.48",
3
+ "version": "0.5.50",
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.38.0",
54
- "@stoker-platform/node-client": "0.5.31",
55
- "@stoker-platform/utils": "0.5.25",
56
- "@stoker-platform/web-client": "0.5.27",
54
+ "@stoker-platform/node-client": "0.5.32",
55
+ "@stoker-platform/utils": "0.5.26",
56
+ "@stoker-platform/web-client": "0.5.28",
57
57
  "@tanstack/react-table": "^8.21.3",
58
58
  "@types/react": "18.3.13",
59
59
  "@types/react-dom": "18.3.1",
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Assignable,
2
3
  CalendarConfig,
3
4
  CardsConfig,
4
5
  CollectionField,
@@ -28,6 +29,7 @@ import {
28
29
  GetSomeOptions,
29
30
  getTimezone,
30
31
  keepTimezone,
32
+ onStokerSignOut,
31
33
  subscribeMany,
32
34
  SubscribeManyOptions,
33
35
  updateRecord,
@@ -110,6 +112,8 @@ interface CollectionProps {
110
112
  relationList?: RelationList
111
113
  relationCollection?: CollectionSchema
112
114
  relationParent?: StokerRecord
115
+ isAssigning?: boolean
116
+ assignable?: Assignable
113
117
  }
114
118
 
115
119
  export interface Query {
@@ -120,7 +124,11 @@ export interface Query {
120
124
  }[]
121
125
  }
122
126
 
123
- const hasFirstPageLoaded: { [key: StokerCollection]: boolean } = {}
127
+ let hasFirstPageLoaded: { [key: StokerCollection]: boolean } = {}
128
+
129
+ onStokerSignOut(() => {
130
+ hasFirstPageLoaded = {}
131
+ })
124
132
 
125
133
  function Collection({
126
134
  collection,
@@ -131,6 +139,8 @@ function Collection({
131
139
  relationList,
132
140
  relationCollection,
133
141
  relationParent,
142
+ isAssigning,
143
+ assignable,
134
144
  }: CollectionProps) {
135
145
  const navigate = useNavigate()
136
146
  const location = useLocation()
@@ -216,6 +226,8 @@ function Collection({
216
226
  const { filters, setFilters, order, setOrder, getFilterConstraints } = useFilters()
217
227
  const { orderByField, orderByDirection } = useMemo(() => getOrderBy(collection, order), [order])
218
228
  const searchResults = useRef<{ [key: string | number]: string[] | undefined }>({})
229
+ const additionalConstraintsRef = useRef(additionalConstraints)
230
+ additionalConstraintsRef.current = additionalConstraints
219
231
  const { currentField: currentFieldAll } = useCache()
220
232
  const currentField = currentFieldAll[labels.collection]
221
233
  const [backToStartKey, setBackToStartKey] = useState(0)
@@ -335,6 +347,31 @@ function Collection({
335
347
  serverListRef.current = serverList
336
348
  }, [serverList])
337
349
 
350
+ useEffect(() => {
351
+ if (isAssigning !== undefined) {
352
+ setBackToStartKey((prev) => prev + 1)
353
+ }
354
+ if (!relationList || !isInitialized) return
355
+ setFilters((prev) => {
356
+ let next = prev
357
+ .filter((filter) => !(filter.type === "relation" && filter.field === relationList.field))
358
+ .map((filter: Filter) => {
359
+ if (filter.type === "status" || filter.type === "range") return filter
360
+ if (filter.type === "select" && filter.defaultValue && isAssigning !== undefined) {
361
+ return {
362
+ ...filter,
363
+ value: tryFunction(filter.defaultValue, [relationCollection, relationParent, isAssigning]),
364
+ }
365
+ }
366
+ return filter
367
+ })
368
+ if (!isAssigning) {
369
+ next = [...next, { type: "relation", field: relationList.field, value: relationParent?.id }]
370
+ }
371
+ return next
372
+ })
373
+ }, [isAssigning])
374
+
338
375
  // This is to ensure that the optimistic list is set in cases where cached documents exactly match the downloaded server documents
339
376
  // In this case, the cache-only snapshot listener does not fire a second time when the cache has loaded because there is no change to the list
340
377
  useEffect(() => {
@@ -562,7 +599,7 @@ function Collection({
562
599
  [labels.collection],
563
600
  [
564
601
  ...(currentQuery.constraints as QueryConstraint[]),
565
- ...(additionalConstraints?.map((constraint) =>
602
+ ...(additionalConstraintsRef.current?.map((constraint) =>
566
603
  where(constraint[0], constraint[1] as WhereFilterOp, constraint[2]),
567
604
  ) || []),
568
605
  ],
@@ -612,7 +649,7 @@ function Collection({
612
649
  [labels.collection],
613
650
  [
614
651
  ...(query.queries[0].constraints as [string, WhereFilterOp, unknown][]),
615
- ...(additionalConstraints || []),
652
+ ...(additionalConstraintsRef.current || []),
616
653
  ],
617
654
  options as GetSomeOptions,
618
655
  )
@@ -872,10 +909,31 @@ function Collection({
872
909
  if (filter.type === "status" || filter.type === "range") {
873
910
  return
874
911
  }
875
- if (filter.type === "select" && !hasFirstPageLoaded[labels.collection] && filter.defaultValue) {
876
- filter.value = tryFunction(filter.defaultValue, [relationCollection, relationParent])
912
+ if (
913
+ filter.type === "relation" &&
914
+ filter.field === relationList.field &&
915
+ relationParent &&
916
+ !isAssigning
917
+ ) {
918
+ filter.value = relationParent.id
919
+ }
920
+ if (filter.type === "select" && filter.defaultValue) {
921
+ filter.value = tryFunction(filter.defaultValue, [
922
+ relationCollection,
923
+ relationParent,
924
+ isAssigning,
925
+ ])
877
926
  }
878
927
  })
928
+ if (
929
+ relationParent &&
930
+ !isAssigning &&
931
+ !filtersClone.some(
932
+ (filter: Filter) => filter.type === "relation" && filter.field === relationList.field,
933
+ )
934
+ ) {
935
+ filtersClone.push({ type: "relation", field: relationList.field, value: relationParent.id })
936
+ }
879
937
  }
880
938
 
881
939
  if (statusField || softDelete) {
@@ -1058,7 +1116,10 @@ function Collection({
1058
1116
  setOrder({ field: recordTitleField, direction: "asc" })
1059
1117
  }
1060
1118
 
1061
- hasFirstPageLoaded[labels.collection] = true
1119
+ if (!relationList) {
1120
+ hasFirstPageLoaded[labels.collection] = true
1121
+ }
1122
+
1062
1123
  setIsInitialized(true)
1063
1124
  }
1064
1125
 
@@ -1130,17 +1191,19 @@ function Collection({
1130
1191
  return (
1131
1192
  filters
1132
1193
  .filter((filter) => filter.type !== "status" && filter.type !== "range")
1194
+ .filter((filter) => !relationList || filter.field !== relationList.field)
1133
1195
  .filter(
1134
1196
  (filter) =>
1135
1197
  (filter.value ||
1136
1198
  (filter.type === "select" &&
1137
1199
  filter.defaultValue &&
1138
- tryFunction(filter.defaultValue, [relationCollection, relationParent]) &&
1200
+ tryFunction(filter.defaultValue, [relationCollection, relationParent, isAssigning]) &&
1139
1201
  !filter.value)) &&
1140
1202
  !(
1141
1203
  filter.type === "select" &&
1142
1204
  filter.defaultValue &&
1143
- tryFunction(filter.defaultValue, [relationCollection, relationParent]) === filter.value
1205
+ tryFunction(filter.defaultValue, [relationCollection, relationParent, isAssigning]) ===
1206
+ filter.value
1144
1207
  ),
1145
1208
  )
1146
1209
  .filter((filter) => !excludedFilters.includes(filter.field)).length > 0
@@ -1956,7 +2019,7 @@ function Collection({
1956
2019
  <Filters
1957
2020
  collection={collection}
1958
2021
  excluded={excludedFilters}
1959
- relationList={!!relationList}
2022
+ relationList={relationList}
1960
2023
  />
1961
2024
  </SheetContent>
1962
2025
  </Sheet>
@@ -2289,8 +2352,12 @@ function Collection({
2289
2352
  unsubscribe={unsubscribe}
2290
2353
  search={search}
2291
2354
  backToStartKey={backToStartKey}
2292
- relationList={!!relationList}
2355
+ relationList={relationList}
2356
+ relationCollection={relationCollection}
2357
+ relationParent={relationParent}
2293
2358
  formList={!!formList}
2359
+ isAssigning={isAssigning}
2360
+ assignable={assignable}
2294
2361
  />
2295
2362
  </TabsContent>
2296
2363
  <TabsContent value="map">
package/src/Filters.tsx CHANGED
@@ -1,4 +1,11 @@
1
- import { CollectionField, CollectionSchema, Filter, StokerCollection, StokerRecord } from "@stoker-platform/types"
1
+ import {
2
+ CollectionField,
3
+ CollectionSchema,
4
+ Filter,
5
+ RelationList,
6
+ StokerCollection,
7
+ StokerRecord,
8
+ } from "@stoker-platform/types"
2
9
  import {
3
10
  collectionAccess,
4
11
  getCachedConfigValue,
@@ -40,7 +47,7 @@ import { useConnection } from "./providers/ConnectionProvider"
40
47
  interface FiltersProps {
41
48
  collection: CollectionSchema
42
49
  excluded: string[]
43
- relationList?: boolean
50
+ relationList?: RelationList
44
51
  }
45
52
 
46
53
  export function Filters({ collection, excluded, relationList }: FiltersProps) {
@@ -83,6 +90,7 @@ export function Filters({ collection, excluded, relationList }: FiltersProps) {
83
90
  const fullCollectionAccess = collectionPermissions && collectionAccess("Read", collectionPermissions)
84
91
  const dependencyAccess = hasDependencyAccess(relationCollection, schema, permissions)
85
92
  if (!fullCollectionAccess && dependencyAccess.length === 0) return false
93
+ if (relationList && relationList.field === filter.field) return false
86
94
  return true
87
95
  }, [])
88
96
 
@@ -719,6 +727,7 @@ export function Filters({ collection, excluded, relationList }: FiltersProps) {
719
727
  }
720
728
  filters.forEach((filter) => {
721
729
  if (filter.type === "status" || filter.type === "range") return
730
+ if (relationList && relationList.field === filter.field) return
722
731
  const field = getField(fields, filter.field)
723
732
  if (!field) return
724
733
  if (filter.type === "select") {
package/src/Images.tsx CHANGED
@@ -1,13 +1,16 @@
1
1
  import {
2
+ Assignable,
2
3
  CollectionMeta,
3
4
  CollectionSchema,
4
5
  ImagesConfig,
6
+ RelationList,
5
7
  StokerCollection,
6
8
  StokerPermissions,
7
9
  StokerRecord,
8
10
  } from "@stoker-platform/types"
9
- import { getCachedConfigValue } from "@stoker-platform/utils"
11
+ import { getCachedConfigValue, getField, getFieldCustomization, tryFunction } from "@stoker-platform/utils"
10
12
  import {
13
+ callFunction,
11
14
  Cursor,
12
15
  getCollectionConfigModule,
13
16
  getCurrentUserPermissions,
@@ -35,6 +38,11 @@ import { localFullTextSearch } from "./utils/localFullTextSearch"
35
38
  import { Helmet } from "react-helmet"
36
39
  import { useConnection } from "./providers/ConnectionProvider"
37
40
  import { getSafeUrl } from "./utils/isSafeUrl"
41
+ import { Switch } from "./components/ui/switch"
42
+ import { Label } from "./components/ui/label"
43
+ import { Badge } from "./components/ui/badge"
44
+ import { useGlobalLoading } from "./providers/LoadingProvider"
45
+ import { useToast } from "./hooks/use-toast"
38
46
 
39
47
  export const description = "A list of records as cards. The content area has a search bar in the header."
40
48
 
@@ -91,6 +99,11 @@ interface RowData {
91
99
  lineClamp: string
92
100
  recordTitleField: string | undefined
93
101
  imagesConfig: ImagesConfig
102
+ isAssigning: boolean | undefined
103
+ assignable: Assignable | undefined
104
+ relationList: RelationList | undefined
105
+ relationCollection: CollectionSchema | undefined
106
+ relationParent: StokerRecord | undefined
94
107
  }
95
108
 
96
109
  interface RowProps {
@@ -101,14 +114,107 @@ interface RowProps {
101
114
 
102
115
  const Row = ({ index, style, data }: RowProps) => {
103
116
  const goToRecord = useGoToRecord()
104
- const { collection, groupedRecords, size, cols, lineClamp, recordTitleField, imagesConfig } = data
117
+ const {
118
+ collection,
119
+ groupedRecords,
120
+ size,
121
+ cols,
122
+ lineClamp,
123
+ recordTitleField,
124
+ imagesConfig,
125
+ isAssigning,
126
+ assignable,
127
+ relationList,
128
+ relationCollection,
129
+ relationParent,
130
+ } = data
105
131
  // eslint-disable-next-line security/detect-object-injection
106
132
  const group = groupedRecords[index]
133
+ const customization = getCollectionConfigModule(collection.labels.collection)
134
+ const { setGlobalLoading } = useGlobalLoading()
135
+ const { toast } = useToast()
136
+
137
+ const [checkedDisabled, setCheckedDisabled] = useState(false)
138
+
139
+ const handleCheckedChange = useCallback(async (checked: boolean, record: StokerRecord) => {
140
+ if (!relationCollection) return
141
+ setCheckedDisabled(true)
142
+ setGlobalLoading("+", record.id, true)
143
+ await callFunction(
144
+ `stoker-assign${relationCollection.labels.record.toLowerCase()}${collection.labels.collection.toLowerCase()}`,
145
+ {
146
+ operation: checked ? "add" : "remove",
147
+ parentId: relationParent?.id,
148
+ recordId: record.id,
149
+ },
150
+ ).catch(() => {
151
+ toast({
152
+ title: "Error",
153
+ description: `Error assigning ${collection.labels.record} to ${relationCollection.labels.record}`,
154
+ variant: "destructive",
155
+ duration: 10000000,
156
+ })
157
+ })
158
+ setGlobalLoading("-", record.id, true)
159
+ setCheckedDisabled(false)
160
+ }, [])
161
+
107
162
  return (
108
163
  <div style={style} className={cn("grid", "gap-4", "pb-4", cols)}>
109
164
  {group.map((record) => {
110
165
  // eslint-disable-next-line security/detect-object-injection
111
166
  const title = recordTitleField ? record[recordTitleField] : record.id
167
+ const checked =
168
+ isAssigning && relationList?.field && relationParent
169
+ ? !!record[relationList.field]?.[relationParent.id]
170
+ : undefined
171
+ let unavailable
172
+ if (isAssigning) {
173
+ if (assignable?.unavailableField) {
174
+ const unavailableFieldSchema = getField(collection.fields, assignable?.unavailableField)
175
+ const unavailableFieldCustomization = getFieldCustomization(
176
+ unavailableFieldSchema,
177
+ customization,
178
+ )
179
+ const badge = tryFunction(unavailableFieldCustomization.admin?.badge, [record])
180
+ if (badge === true) {
181
+ unavailable = (
182
+ <Badge variant="outline" className="text-xs text-center">
183
+ {record[assignable.unavailableField]
184
+ ? record[assignable.unavailableField]
185
+ : "Not available"}
186
+ </Badge>
187
+ )
188
+ } else {
189
+ unavailable = (
190
+ <Badge
191
+ variant={
192
+ ["outline", "destructive", "primary", "secondary"].includes(badge)
193
+ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ (badge as any)
195
+ : "outline"
196
+ }
197
+ className={cn(
198
+ "text-xs text-center",
199
+ !["outline", "destructive", "primary", "secondary", true, false].includes(
200
+ badge,
201
+ ) && badge,
202
+ )}
203
+ >
204
+ {record[assignable.unavailableField]
205
+ ? record[assignable.unavailableField]
206
+ : "Not available"}
207
+ </Badge>
208
+ )
209
+ }
210
+ } else {
211
+ unavailable = (
212
+ <Badge variant="outline" className="text-xs text-center">
213
+ Not available
214
+ </Badge>
215
+ )
216
+ }
217
+ }
112
218
  return (
113
219
  <Card key={record.id}>
114
220
  <CardHeader
@@ -122,7 +228,30 @@ const Row = ({ index, style, data }: RowProps) => {
122
228
  </button>
123
229
  </CardHeader>
124
230
  <CardContent className="pb-3 md:pb-4">
125
- <div className={cn("grid", "gap-2", size)}>
231
+ <div className={cn("grid", "gap-4", size)}>
232
+ {isAssigning && assignable && (assignable.isAvailable(record) || checked) && (
233
+ <div>
234
+ <div className="flex items-center justify-center space-x-3 min-h-8">
235
+ <Switch
236
+ id={`${record.id}-assigned`}
237
+ className="data-[state=checked]:bg-blue-500"
238
+ checked={checked}
239
+ disabled={checkedDisabled}
240
+ onCheckedChange={(checked) => handleCheckedChange(checked, record)}
241
+ />
242
+ {imagesConfig.size !== "sm" && (
243
+ <Label htmlFor={`${record.id}-assigned`}>Assigned</Label>
244
+ )}
245
+ </div>
246
+ </div>
247
+ )}
248
+ {isAssigning && assignable && !assignable.isAvailable(record) && !checked && (
249
+ <div>
250
+ <div className="flex items-center justify-center space-x-3 min-h-8">
251
+ {unavailable}
252
+ </div>
253
+ </div>
254
+ )}
126
255
  <button
127
256
  className="relative w-full h-full flex items-center justify-center overflow-hidden"
128
257
  onClick={() => goToRecord(collection, record)}
@@ -130,7 +259,10 @@ const Row = ({ index, style, data }: RowProps) => {
130
259
  {record[imagesConfig.imageField] ? (
131
260
  <RowImage alt={title} src={record[imagesConfig.imageField]} />
132
261
  ) : (
133
- <Image size={100} className="text-muted-foreground stroke-1 opacity-50" />
262
+ <Image
263
+ size={imagesConfig.size === "sm" ? 30 : 100}
264
+ className="text-muted-foreground stroke-1 opacity-50"
265
+ />
134
266
  )}
135
267
  </button>
136
268
  </div>
@@ -158,8 +290,12 @@ interface ImagesProps {
158
290
  unsubscribe: React.MutableRefObject<{ [key: string | number]: (() => void)[] }>
159
291
  search: string | undefined
160
292
  backToStartKey: number
161
- relationList?: boolean
293
+ relationList?: RelationList
294
+ relationCollection?: CollectionSchema
295
+ relationParent?: StokerRecord
162
296
  formList?: boolean
297
+ isAssigning?: boolean
298
+ assignable?: Assignable
163
299
  }
164
300
 
165
301
  export const Images = memo(
@@ -175,7 +311,11 @@ export const Images = memo(
175
311
  search,
176
312
  backToStartKey,
177
313
  relationList,
314
+ relationCollection,
315
+ relationParent,
178
316
  formList,
317
+ isAssigning,
318
+ assignable,
179
319
  }: ImagesProps) => {
180
320
  const { labels, recordTitleField, fullTextSearch } = collection
181
321
  const customization = getCollectionConfigModule(labels.collection)
@@ -582,6 +722,7 @@ export const Images = memo(
582
722
 
583
723
  const lineClamp = imagesConfig.maxHeaderLines === 2 ? "line-clamp-2" : "line-clamp-1"
584
724
  const headerSize = imagesConfig.maxHeaderLines === 2 ? 116 : 82
725
+ const assignedHeight = isAssigning ? 40 : 0
585
726
 
586
727
  const itemData = {
587
728
  collection,
@@ -591,6 +732,11 @@ export const Images = memo(
591
732
  lineClamp,
592
733
  recordTitleField,
593
734
  imagesConfig,
735
+ isAssigning,
736
+ assignable,
737
+ relationList,
738
+ relationCollection,
739
+ relationParent,
594
740
  }
595
741
 
596
742
  const Meta = () => (
@@ -622,7 +768,7 @@ export const Images = memo(
622
768
  <List
623
769
  height={height}
624
770
  width="100%"
625
- itemSize={itemSize + headerSize}
771
+ itemSize={itemSize + headerSize + assignedHeight}
626
772
  itemCount={itemCount}
627
773
  overscanCount={5}
628
774
  itemKey={itemKey}
@@ -647,7 +793,7 @@ export const Images = memo(
647
793
  <List
648
794
  height={height}
649
795
  width="100%"
650
- itemSize={itemSize + headerSize}
796
+ itemSize={itemSize + headerSize + assignedHeight}
651
797
  itemCount={itemCount}
652
798
  overscanCount={5}
653
799
  itemKey={itemKey}
@@ -663,6 +809,10 @@ export const Images = memo(
663
809
  )
664
810
  }
665
811
  },
666
- (prevProps, nextProps) => prevProps.list === nextProps.list && prevProps.search === nextProps.search,
812
+ (prevProps, nextProps) =>
813
+ prevProps.list === nextProps.list &&
814
+ prevProps.search === nextProps.search &&
815
+ prevProps.isAssigning === nextProps.isAssigning &&
816
+ prevProps.backToStartKey === nextProps.backToStartKey,
667
817
  )
668
818
  Images.displayName = "Images"
package/src/Record.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Assignable,
2
3
  CollectionSchema,
3
4
  CustomRecordPage,
4
5
  RelationField,
@@ -79,6 +80,9 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
79
80
  const [breadcrumbs, setBreadcrumbs] = useState<string[] | undefined>(undefined)
80
81
  const [customRecordPages, setCustomRecordPages] = useState<CustomRecordPage[] | undefined>(undefined)
81
82
 
83
+ const [isAssigning, setIsAssigning] = useState<Record<string, boolean>>({})
84
+ const [assignable, setAssignable] = useState<Assignable[] | undefined>(undefined)
85
+
82
86
  useEffect(() => {
83
87
  if (id && record && record.id !== id) {
84
88
  setRecord(location?.state?.relationField?.includeFields ? undefined : recordFromState)
@@ -108,6 +112,10 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
108
112
  | CustomRecordPage[]
109
113
  | undefined
110
114
  setCustomRecordPages(pages || [])
115
+ const assignable = (await getCachedConfigValue(customization, [...collectionAdminPath, "assignable"])) as
116
+ | Assignable[]
117
+ | undefined
118
+ setAssignable(assignable)
111
119
 
112
120
  setIsRouteLoading("+", location.pathname)
113
121
 
@@ -195,7 +203,12 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
195
203
  {record && (
196
204
  <CardContent className="px-0">
197
205
  <SidebarProvider defaultOpen={true} open={true} className="flex flex-col lg:flex-row">
198
- <RecordSidebar collection={collection} customRecordPages={customRecordPages} />
206
+ <RecordSidebar
207
+ collection={collection}
208
+ customRecordPages={customRecordPages}
209
+ isAssigning={isAssigning}
210
+ setIsAssigning={setIsAssigning}
211
+ />
199
212
  <Routes>
200
213
  <Route
201
214
  path="edit"
@@ -256,18 +269,13 @@ export const Record = ({ collection }: { collection: CollectionSchema }) => {
256
269
  relationList={relationList}
257
270
  relationCollection={collection}
258
271
  relationParent={record}
259
- additionalConstraints={(() => {
260
- if (record) {
261
- return [
262
- [
263
- `${relationList.field}_Array`,
264
- "array-contains",
265
- record.id,
266
- ],
267
- ]
268
- }
269
- return []
270
- })()}
272
+ isAssigning={
273
+ isAssigning?.[relationList.collection.toLowerCase()]
274
+ }
275
+ assignable={assignable?.find(
276
+ (item: Assignable) =>
277
+ item.collection === relationList.collection,
278
+ )}
271
279
  />
272
280
  </FiltersProvider>
273
281
  </main>
@@ -1,4 +1,4 @@
1
- import { FileIcon, EditIcon, List as ListIcon, Book, ArrowDown } from "lucide-react"
1
+ import { FileIcon, EditIcon, List as ListIcon, Book, ArrowDown, Pencil, List } from "lucide-react"
2
2
  import {
3
3
  Sidebar,
4
4
  SidebarContent,
@@ -10,7 +10,7 @@ import {
10
10
  } from "./components/ui/sidebar"
11
11
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./components/ui/dropdown-menu"
12
12
  import { useLocation, useNavigate, useParams } from "react-router"
13
- import { CollectionPermissions, CollectionSchema, CustomRecordPage } from "@stoker-platform/types"
13
+ import { Assignable, CollectionPermissions, CollectionSchema, CustomRecordPage } from "@stoker-platform/types"
14
14
  import { collectionAccess, getField, isRelationField, tryFunction, tryPromise } from "@stoker-platform/utils"
15
15
  import { getCurrentUserPermissions, getCollectionConfigModule, getSchema } from "@stoker-platform/web-client"
16
16
  import { runViewTransition } from "./utils/runViewTransition"
@@ -20,14 +20,19 @@ interface SidebarItem {
20
20
  title: string
21
21
  page: string
22
22
  icon: React.FC
23
+ assignable?: Assignable
23
24
  }
24
25
 
25
26
  export const RecordSidebar = ({
26
27
  collection,
27
28
  customRecordPages,
29
+ isAssigning,
30
+ setIsAssigning,
28
31
  }: {
29
32
  collection: CollectionSchema
30
33
  customRecordPages?: CustomRecordPage[]
34
+ isAssigning: Record<string, boolean>
35
+ setIsAssigning: (isAssigning: Record<string, boolean>) => void
31
36
  }) => {
32
37
  const { labels } = collection
33
38
  const { path, id } = useParams()
@@ -35,8 +40,11 @@ export const RecordSidebar = ({
35
40
  const location = useLocation()
36
41
 
37
42
  const schema = getSchema()
43
+ const customization = getCollectionConfigModule(collection.labels.collection)
38
44
  const permissions = getCurrentUserPermissions()
39
45
  const [relationTitles, setRelationTitles] = useState<Record<string, string>>({})
46
+ const [relationIcons, setRelationIcons] = useState<Record<string, React.FC>>({})
47
+ const [assignable, setAssignable] = useState<Assignable[]>([])
40
48
 
41
49
  useEffect(() => {
42
50
  ;(async () => {
@@ -51,7 +59,14 @@ export const RecordSidebar = ({
51
59
  ...prev,
52
60
  [relationList.collection]: title || relationList.collection,
53
61
  }))
62
+ const icon = await tryPromise(relationCustomization.admin?.icon)
63
+ setRelationIcons((prev) => ({
64
+ ...prev,
65
+ [relationList.collection]: icon as React.FC,
66
+ }))
54
67
  })
68
+ const assignable = await tryPromise(customization.admin?.assignable)
69
+ setAssignable(assignable)
55
70
  }
56
71
  })()
57
72
  }, [])
@@ -95,7 +110,8 @@ export const RecordSidebar = ({
95
110
  relationItems.push({
96
111
  title: relationTitles[relationList.collection],
97
112
  page: relationList.collection.toLowerCase(),
98
- icon: ListIcon,
113
+ icon: relationIcons[relationList.collection] || (() => null),
114
+ assignable: assignable?.find((item) => item.collection === relationList.collection),
99
115
  })
100
116
  })
101
117
  }
@@ -126,13 +142,38 @@ export const RecordSidebar = ({
126
142
  return (
127
143
  <SidebarMenuItem key={item.page}>
128
144
  <SidebarMenuButton asChild onClick={() => goToRecordPage(item.page)}>
129
- <button
130
- className={isActive ? "bg-sidebar-accent" : "cursor-pointer"}
131
- type="button"
132
- >
145
+ <div className={isActive ? "bg-sidebar-accent" : "cursor-pointer"}>
133
146
  <item.icon />
134
- <span>{item.title}</span>
135
- </button>
147
+ <button type="button">{item.title}</button>
148
+ {item.assignable && isActive && !isAssigning?.[item.page] && (
149
+ <button
150
+ className="ml-auto"
151
+ onClick={() =>
152
+ setIsAssigning({
153
+ ...isAssigning,
154
+ [item.page]: true,
155
+ })
156
+ }
157
+ type="button"
158
+ >
159
+ <Pencil className="w-4 h-4" />
160
+ </button>
161
+ )}
162
+ {item.assignable && isActive && isAssigning?.[item.page] && (
163
+ <button
164
+ className="ml-auto"
165
+ onClick={() =>
166
+ setIsAssigning({
167
+ ...isAssigning,
168
+ [item.page]: false,
169
+ })
170
+ }
171
+ type="button"
172
+ >
173
+ <List className="w-4 h-4" />
174
+ </button>
175
+ )}
176
+ </div>
136
177
  </SidebarMenuButton>
137
178
  </SidebarMenuItem>
138
179
  )
@@ -174,6 +215,35 @@ export const RecordSidebar = ({
174
215
  onClick={() => goToRecordPage(item.page)}
175
216
  >
176
217
  {item.title}
218
+
219
+ {item.assignable && !isAssigning?.[item.page] && (
220
+ <button
221
+ className="ml-auto"
222
+ onClick={() =>
223
+ setIsAssigning({
224
+ ...isAssigning,
225
+ [item.page]: true,
226
+ })
227
+ }
228
+ type="button"
229
+ >
230
+ <Pencil className="w-4 h-4" />
231
+ </button>
232
+ )}
233
+ {item.assignable && isAssigning?.[item.page] && (
234
+ <button
235
+ className="ml-auto"
236
+ onClick={() =>
237
+ setIsAssigning({
238
+ ...isAssigning,
239
+ [item.page]: false,
240
+ })
241
+ }
242
+ type="button"
243
+ >
244
+ <List className="w-4 h-4" />
245
+ </button>
246
+ )}
177
247
  </DropdownMenuItem>
178
248
  ))}
179
249
  </DropdownMenuContent>