@stoker-platform/web-app 0.5.144 → 0.5.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @stoker-platform/web-app
2
2
 
3
+ ## 0.5.146
4
+
5
+ ### Patch Changes
6
+
7
+ - fix: improve mobile relation picker
8
+
9
+ ## 0.5.145
10
+
11
+ ### Patch Changes
12
+
13
+ - feat: allow bulk update of restricted fields
14
+
3
15
  ## 0.5.144
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stoker-platform/web-app",
3
- "version": "0.5.144",
3
+ "version": "0.5.146",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "scripts": {
package/src/Filters.tsx CHANGED
@@ -28,6 +28,9 @@ import { useRouteLoading } from "./providers/LoadingProvider"
28
28
  import { useLocation } from "react-router"
29
29
  import { useStokerState } from "./providers/StateProvider"
30
30
  import { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover"
31
+ import { Sheet, SheetContent, SheetTrigger } from "./components/ui/sheet"
32
+ import { useIsMobile } from "./hooks/use-mobile"
33
+ import { useKeyboardOffset } from "./hooks/use-keyboard-offset"
31
34
  import { Button } from "./components/ui/button"
32
35
  import { Check, ChevronsUpDown } from "lucide-react"
33
36
  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./components/ui/command"
@@ -80,6 +83,11 @@ export function Filters({ collection, excluded, relationList }: FiltersProps) {
80
83
 
81
84
  const preventChange = isRouteLoadingImmediate.has(location.pathname)
82
85
 
86
+ const isMobile = useIsMobile()
87
+ const someFilterOpen = Object.values(open).some(Boolean)
88
+ const { offset: keyboardOffset, viewportHeight } = useKeyboardOffset(isMobile && someFilterOpen)
89
+ const sheetHeight = Math.max(240, Math.min(viewportHeight - 16, viewportHeight * 0.9))
90
+
83
91
  const hasRelationFilterAccess = useCallback((filter: Filter) => {
84
92
  if (filter.type === "status" || filter.type === "range") return false
85
93
  const field = getField(fields, filter.field)
@@ -551,162 +559,171 @@ export function Filters({ collection, excluded, relationList }: FiltersProps) {
551
559
  if (window.innerHeight < 600) {
552
560
  popoverHeight = "h-48"
553
561
  }
562
+ const isFilterOpen = !!open[filter.field]
563
+ const handleFilterOpenChange = (nextOpen: boolean) => {
564
+ setOpen((prev) => ({ ...prev, [filter.field]: nextOpen }))
565
+ if (nextOpen) {
566
+ startTransition(() => {
567
+ getData(searchValue[filter.field], filter.field, field.collection, filter.constraints)
568
+ })
569
+ }
570
+ }
571
+ const filterTriggerButton = (
572
+ <Button
573
+ variant="outline"
574
+ role="combobox"
575
+ aria-expanded={isFilterOpen}
576
+ className="w-full justify-between"
577
+ disabled={disabled}
578
+ >
579
+ <span className="w-[150px] sm:w-[250px] overflow-hidden text-ellipsis whitespace-nowrap text-left">
580
+ {inputValue[filter.field] ? display[filter.field] : "----"}
581
+ </span>
582
+ <ChevronsUpDown className="opacity-50 h-4 w-4" />
583
+ </Button>
584
+ )
585
+ const filterCommandBody = (
586
+ <Command
587
+ filter={() => {
588
+ return 1
589
+ }}
590
+ className={isMobile ? "flex flex-col h-full" : undefined}
591
+ >
592
+ <CommandInput
593
+ placeholder={`Search ${collectionTitle[filter.field]}...`}
594
+ className="h-9"
595
+ value={searchValue[filter.field]}
596
+ onValueChange={(value) => {
597
+ setSearchValue((prev) => ({
598
+ ...prev,
599
+ [filter.field]: value,
600
+ }))
601
+ }}
602
+ />
603
+ <CommandList className={isMobile ? "flex-1 max-h-none" : undefined}>
604
+ <CommandEmpty>
605
+ {isFilterOpen &&
606
+ (isLoading[filter.field] ? (
607
+ <LoadingSpinner size={7} className="m-auto" />
608
+ ) : !isLoadingImmediate[filter.field] ? (
609
+ `No ${collectionTitle[filter.field]} found.`
610
+ ) : null)}
611
+ </CommandEmpty>
612
+ {(!isLoading[filter.field] || isCollectionPreloadCacheEnabled) && (
613
+ <CommandGroup>
614
+ {data[filter.field] && (
615
+ <CommandItem
616
+ key="no_selection"
617
+ value="no_selection"
618
+ onSelect={(currentValue) => {
619
+ setOpen((prev) => {
620
+ return {
621
+ ...prev,
622
+ [filter.field]: false,
623
+ }
624
+ })
625
+ if (currentValue !== inputValue[filter.field]) {
626
+ setValue((prev) => {
627
+ return {
628
+ ...prev,
629
+ [filter.field]: currentValue,
630
+ }
631
+ })
632
+ setDisplay((prev) => {
633
+ return {
634
+ ...prev,
635
+ [filter.field]: "----",
636
+ }
637
+ })
638
+ startTransition(() => {
639
+ handleChange(filter, "no_selection", field.type)
640
+ })
641
+ }
642
+ }}
643
+ >
644
+ ----
645
+ </CommandItem>
646
+ )}
647
+ {data[filter.field]?.map((record: StokerRecord) => (
648
+ <CommandItem
649
+ key={record.id}
650
+ value={record.id}
651
+ onSelect={(currentValue) => {
652
+ setOpen((prev) => {
653
+ return {
654
+ ...prev,
655
+ [filter.field]: false,
656
+ }
657
+ })
658
+ if (currentValue !== inputValue[filter.field]) {
659
+ setValue((prev) => {
660
+ return {
661
+ ...prev,
662
+ [filter.field]: currentValue,
663
+ }
664
+ })
665
+ setDisplay((prev) => {
666
+ return {
667
+ ...prev,
668
+ [filter.field]:
669
+ record[recordTitleField[filter.field] || "id"],
670
+ }
671
+ })
672
+ startTransition(() => {
673
+ handleChange(filter, record.id, field.type)
674
+ })
675
+ }
676
+ }}
677
+ >
678
+ {record[recordTitleField[filter.field] || "id"]}
679
+ <Check
680
+ className={cn(
681
+ "ml-auto",
682
+ inputValue[filter.field] === record.id
683
+ ? "opacity-100"
684
+ : "opacity-0",
685
+ )}
686
+ />
687
+ </CommandItem>
688
+ ))}
689
+ </CommandGroup>
690
+ )}
691
+ </CommandList>
692
+ </Command>
693
+ )
554
694
  return (
555
695
  <div key={filter.field}>
556
696
  <Label htmlFor={title}>{title}:</Label>
557
697
  <div className="mt-2">
558
- <Popover
559
- modal={true}
560
- open={!!open[filter.field]}
561
- onOpenChange={() => {
562
- setOpen({
563
- ...open,
564
- [filter.field]: !open[filter.field],
565
- })
566
- startTransition(() => {
567
- getData(
568
- searchValue[filter.field],
569
- filter.field,
570
- field.collection,
571
- filter.constraints,
572
- )
573
- })
574
- }}
575
- >
576
- <PopoverTrigger asChild>
577
- <Button
578
- variant="outline"
579
- role="combobox"
580
- aria-expanded={open[filter.field]}
581
- className="w-full justify-between"
582
- disabled={disabled}
583
- >
584
- <span className="w-[150px] sm:w-[250px] overflow-hidden text-ellipsis whitespace-nowrap text-left">
585
- {inputValue[filter.field] ? display[filter.field] : "----"}
586
- </span>
587
- <ChevronsUpDown className="opacity-50 h-4 w-4" />
588
- </Button>
589
- </PopoverTrigger>
590
- <PopoverContent
591
- className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
592
- align="start"
593
- >
594
- <Command
595
- filter={() => {
596
- return 1
698
+ {isMobile ? (
699
+ <Sheet open={isFilterOpen} onOpenChange={handleFilterOpenChange}>
700
+ <SheetTrigger asChild>{filterTriggerButton}</SheetTrigger>
701
+ <SheetContent
702
+ side="bottom"
703
+ className="p-0 pt-8 flex flex-col gap-0 rounded-t-lg"
704
+ style={{
705
+ bottom: keyboardOffset,
706
+ height: sheetHeight,
707
+ maxHeight: sheetHeight,
708
+ }}
709
+ onOpenAutoFocus={(event) => {
710
+ event.preventDefault()
597
711
  }}
598
712
  >
599
- <CommandInput
600
- placeholder={`Search ${collectionTitle[filter.field]}...`}
601
- className="h-9"
602
- value={searchValue[filter.field]}
603
- onValueChange={(value) => {
604
- setSearchValue((prev) => ({
605
- ...prev,
606
- [filter.field]: value,
607
- }))
608
- }}
609
- />
610
- <CommandList>
611
- <CommandEmpty>
612
- {open[filter.field] &&
613
- (isLoading[filter.field] ? (
614
- <LoadingSpinner size={7} className="m-auto" />
615
- ) : !isLoadingImmediate[filter.field] ? (
616
- `No ${collectionTitle[filter.field]} found.`
617
- ) : null)}
618
- </CommandEmpty>
619
- {(!isLoading[filter.field] || isCollectionPreloadCacheEnabled) && (
620
- <CommandGroup>
621
- {data[filter.field] && (
622
- <CommandItem
623
- key="no_selection"
624
- value="no_selection"
625
- onSelect={(currentValue) => {
626
- setOpen((prev) => {
627
- return {
628
- ...prev,
629
- [filter.field]: false,
630
- }
631
- })
632
- if (currentValue !== inputValue[filter.field]) {
633
- setValue((prev) => {
634
- return {
635
- ...prev,
636
- [filter.field]: currentValue,
637
- }
638
- })
639
- setDisplay((prev) => {
640
- return {
641
- ...prev,
642
- [filter.field]: "----",
643
- }
644
- })
645
- startTransition(() => {
646
- handleChange(
647
- filter,
648
- "no_selection",
649
- field.type,
650
- )
651
- })
652
- }
653
- }}
654
- >
655
- ----
656
- </CommandItem>
657
- )}
658
- {data[filter.field]?.map((record: StokerRecord) => (
659
- <CommandItem
660
- key={record.id}
661
- value={record.id}
662
- onSelect={(currentValue) => {
663
- setOpen((prev) => {
664
- return {
665
- ...prev,
666
- [filter.field]: false,
667
- }
668
- })
669
- if (currentValue !== inputValue[filter.field]) {
670
- setValue((prev) => {
671
- return {
672
- ...prev,
673
- [filter.field]: currentValue,
674
- }
675
- })
676
- setDisplay((prev) => {
677
- return {
678
- ...prev,
679
- [filter.field]:
680
- record[
681
- recordTitleField[
682
- filter.field
683
- ] || "id"
684
- ],
685
- }
686
- })
687
- startTransition(() => {
688
- handleChange(filter, record.id, field.type)
689
- })
690
- }
691
- }}
692
- >
693
- {record[recordTitleField[filter.field] || "id"]}
694
- <Check
695
- className={cn(
696
- "ml-auto",
697
- inputValue[filter.field] === record.id
698
- ? "opacity-100"
699
- : "opacity-0",
700
- )}
701
- />
702
- </CommandItem>
703
- ))}
704
- </CommandGroup>
705
- )}
706
- </CommandList>
707
- </Command>
708
- </PopoverContent>
709
- </Popover>
713
+ {filterCommandBody}
714
+ </SheetContent>
715
+ </Sheet>
716
+ ) : (
717
+ <Popover modal={true} open={isFilterOpen} onOpenChange={handleFilterOpenChange}>
718
+ <PopoverTrigger asChild>{filterTriggerButton}</PopoverTrigger>
719
+ <PopoverContent
720
+ className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
721
+ align="start"
722
+ >
723
+ {filterCommandBody}
724
+ </PopoverContent>
725
+ </Popover>
726
+ )}
710
727
  </div>
711
728
  </div>
712
729
  )
package/src/Form.tsx CHANGED
@@ -133,6 +133,9 @@ import { DateTime } from "luxon"
133
133
  import { useGoToRecord } from "./utils/goToRecord"
134
134
  import { preloadCacheEnabled } from "./utils/preloadCacheEnabled"
135
135
  import { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover"
136
+ import { Sheet, SheetContent, SheetTrigger } from "./components/ui/sheet"
137
+ import { useIsMobile } from "./hooks/use-mobile"
138
+ import { useKeyboardOffset } from "./hooks/use-keyboard-offset"
136
139
  import { getFilterDisjunctions } from "./utils/getFilterDisjunctions"
137
140
  import { performFullTextSearch } from "./utils/performFullTextSearch"
138
141
  import { localFullTextSearch } from "./utils/localFullTextSearch"
@@ -409,7 +412,7 @@ const RecordFormField = (props: FieldProps) => {
409
412
  if (operation === "update-many") {
410
413
  if ("unique" in field && field.unique) return null
411
414
  if (customization?.admin && "readOnly" in customization.admin) return null
412
- if ("restrictUpdate" in field) return null
415
+ if ("restrictUpdate" in field && !restrictUpdateAccess(field, permissions)) return null
413
416
 
414
417
  if (collection.auth && field.name === "Role") return null
415
418
  if (connectionStatus === "offline" && collection.auth) return null
@@ -2108,6 +2111,125 @@ function RelationField({
2108
2111
  popoverHeight = "h-48"
2109
2112
  }
2110
2113
 
2114
+ const isMobile = useIsMobile()
2115
+ const { offset: keyboardOffset, viewportHeight } = useKeyboardOffset(isMobile && isOpen)
2116
+ const sheetHeight = Math.max(240, Math.min(viewportHeight - 16, viewportHeight * 0.9))
2117
+
2118
+ const handlePickerOpenChange = (nextOpen: boolean) => {
2119
+ setIsOpen(nextOpen)
2120
+ if (nextOpen) {
2121
+ startTransition(() => {
2122
+ getData(searchValue, (field as RelationFieldType).constraints)
2123
+ })
2124
+ }
2125
+ }
2126
+
2127
+ const triggerButton = (
2128
+ <Button
2129
+ variant="outline"
2130
+ role="combobox"
2131
+ aria-expanded={isOpen}
2132
+ className="w-full justify-between"
2133
+ disabled={isDisabled || readOnly}
2134
+ >
2135
+ <span className="break-all whitespace-pre-wrap line-clamp-1 text-left">{inputValue ? display : ""}</span>
2136
+ <ChevronsUpDown className="opacity-50 h-4 w-4" />
2137
+ </Button>
2138
+ )
2139
+
2140
+ const commandBody = (
2141
+ <Command
2142
+ filter={() => {
2143
+ return 1
2144
+ }}
2145
+ className={isMobile ? "flex flex-col h-full" : undefined}
2146
+ >
2147
+ <CommandInput
2148
+ placeholder={`Search ${collectionTitle}...`}
2149
+ className="h-9"
2150
+ value={searchValue}
2151
+ onValueChange={(value) => {
2152
+ setSearchValue(value)
2153
+ }}
2154
+ />
2155
+ <CommandList className={isMobile ? "flex-1 max-h-none" : undefined}>
2156
+ <CommandEmpty>
2157
+ {isOpen &&
2158
+ (isLoading ? (
2159
+ <LoadingSpinner size={7} className="m-auto" />
2160
+ ) : !isLoadingImmediate ? (
2161
+ `No ${collectionTitle} found.`
2162
+ ) : null)}
2163
+ </CommandEmpty>
2164
+ {(!isLoading || isCollectionPreloadCacheEnabled) && (
2165
+ <CommandGroup>
2166
+ {data && ["OneToOne", "OneToMany"].includes(field.type) && (
2167
+ <CommandItem
2168
+ key="no_selection"
2169
+ value="no_selection"
2170
+ onSelect={(currentValue: string) => {
2171
+ setIsOpen(false)
2172
+ if (currentValue !== inputValue) {
2173
+ setValue(currentValue)
2174
+ setDisplay("----")
2175
+ startTransition(() => {
2176
+ handleOneToChange()
2177
+ })
2178
+ }
2179
+ }}
2180
+ >
2181
+ ----
2182
+ </CommandItem>
2183
+ )}
2184
+ {data?.map((relationRecord: StokerRecord) => (
2185
+ <CommandItem
2186
+ key={relationRecord.id}
2187
+ value={relationRecord.id}
2188
+ onSelect={(currentValue) => {
2189
+ setIsOpen(false)
2190
+ if (currentValue !== inputValue) {
2191
+ if (["OneToOne", "OneToMany"].includes(field.type)) {
2192
+ setValue(currentValue)
2193
+ if (fieldCustomization.admin?.modifyResultTitle) {
2194
+ setDisplay(
2195
+ fieldCustomization.admin.modifyResultTitle(
2196
+ relationRecord,
2197
+ collection,
2198
+ record,
2199
+ ),
2200
+ )
2201
+ } else {
2202
+ setDisplay(relationRecord[recordTitleField || "id"])
2203
+ }
2204
+ }
2205
+ startTransition(() => {
2206
+ handleOneToChange(
2207
+ data?.find((relationRecord) => relationRecord.id === currentValue),
2208
+ )
2209
+ if (["ManyToOne", "ManyToMany"].includes(field.type)) {
2210
+ handleManyToChange(currentValue)
2211
+ }
2212
+ })
2213
+ }
2214
+ }}
2215
+ >
2216
+ {fieldCustomization.admin?.modifyResultTitle
2217
+ ? fieldCustomization.admin.modifyResultTitle(relationRecord, collection, record)
2218
+ : relationRecord[recordTitleField || "id"]}
2219
+ <Check
2220
+ className={cn(
2221
+ "ml-auto",
2222
+ inputValue === relationRecord.id ? "opacity-100" : "opacity-0",
2223
+ )}
2224
+ />
2225
+ </CommandItem>
2226
+ ))}
2227
+ </CommandGroup>
2228
+ )}
2229
+ </CommandList>
2230
+ </Command>
2231
+ )
2232
+
2111
2233
  return (
2112
2234
  <FormField
2113
2235
  control={form.control}
@@ -2124,148 +2246,37 @@ function RelationField({
2124
2246
  form={form}
2125
2247
  />
2126
2248
  <FormControl>
2127
- <Popover
2128
- modal={true}
2129
- open={isOpen}
2130
- onOpenChange={() => {
2131
- setIsOpen(!isOpen)
2132
- startTransition(() => {
2133
- getData(searchValue, (field as RelationFieldType).constraints)
2134
- })
2135
- }}
2136
- >
2137
- <div className="flex items-center gap-2">
2138
- <PopoverTrigger asChild>
2139
- <Button
2140
- variant="outline"
2141
- role="combobox"
2142
- aria-expanded={isOpen}
2143
- className="w-full justify-between"
2144
- disabled={isDisabled || readOnly}
2145
- >
2146
- <span className="break-all whitespace-pre-wrap line-clamp-1 text-left">
2147
- {inputValue ? display : ""}
2148
- </span>
2149
- <ChevronsUpDown className="opacity-50 h-4 w-4" />
2150
- </Button>
2151
- </PopoverTrigger>
2152
- </div>
2153
- <PopoverContent
2154
- className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
2155
- align="start"
2156
- >
2157
- <Command
2158
- filter={() => {
2159
- return 1
2249
+ {isMobile ? (
2250
+ <Sheet open={isOpen} onOpenChange={handlePickerOpenChange}>
2251
+ <SheetTrigger asChild>{triggerButton}</SheetTrigger>
2252
+ <SheetContent
2253
+ side="bottom"
2254
+ className="p-0 pt-8 flex flex-col gap-0 rounded-t-lg"
2255
+ style={{
2256
+ bottom: keyboardOffset,
2257
+ height: sheetHeight,
2258
+ maxHeight: sheetHeight,
2259
+ }}
2260
+ onOpenAutoFocus={(event) => {
2261
+ event.preventDefault()
2160
2262
  }}
2161
2263
  >
2162
- <CommandInput
2163
- placeholder={`Search ${collectionTitle}...`}
2164
- className="h-9"
2165
- value={searchValue}
2166
- onValueChange={(value) => {
2167
- setSearchValue(value)
2168
- }}
2169
- />
2170
- <CommandList>
2171
- <CommandEmpty>
2172
- {isOpen &&
2173
- (isLoading ? (
2174
- <LoadingSpinner size={7} className="m-auto" />
2175
- ) : !isLoadingImmediate ? (
2176
- `No ${collectionTitle} found.`
2177
- ) : null)}
2178
- </CommandEmpty>
2179
- {(!isLoading || isCollectionPreloadCacheEnabled) && (
2180
- <CommandGroup>
2181
- {data && ["OneToOne", "OneToMany"].includes(field.type) && (
2182
- <CommandItem
2183
- key="no_selection"
2184
- value="no_selection"
2185
- onSelect={(currentValue: string) => {
2186
- setIsOpen(false)
2187
- if (currentValue !== inputValue) {
2188
- setValue(currentValue)
2189
- setDisplay("----")
2190
- startTransition(() => {
2191
- handleOneToChange()
2192
- })
2193
- }
2194
- }}
2195
- >
2196
- ----
2197
- </CommandItem>
2198
- )}
2199
- {data?.map((relationRecord: StokerRecord) => (
2200
- <CommandItem
2201
- key={relationRecord.id}
2202
- value={relationRecord.id}
2203
- onSelect={(currentValue) => {
2204
- setIsOpen(false)
2205
- if (currentValue !== inputValue) {
2206
- if (
2207
- ["OneToOne", "OneToMany"].includes(field.type)
2208
- ) {
2209
- setValue(currentValue)
2210
- if (
2211
- fieldCustomization.admin?.modifyResultTitle
2212
- ) {
2213
- setDisplay(
2214
- fieldCustomization.admin.modifyResultTitle(
2215
- relationRecord,
2216
- collection,
2217
- record,
2218
- ),
2219
- )
2220
- } else {
2221
- setDisplay(
2222
- relationRecord[
2223
- recordTitleField || "id"
2224
- ],
2225
- )
2226
- }
2227
- }
2228
- startTransition(() => {
2229
- handleOneToChange(
2230
- data?.find(
2231
- (relationRecord) =>
2232
- relationRecord.id === currentValue,
2233
- ),
2234
- )
2235
- if (
2236
- ["ManyToOne", "ManyToMany"].includes(
2237
- field.type,
2238
- )
2239
- ) {
2240
- handleManyToChange(currentValue)
2241
- }
2242
- })
2243
- }
2244
- }}
2245
- >
2246
- {fieldCustomization.admin?.modifyResultTitle
2247
- ? fieldCustomization.admin.modifyResultTitle(
2248
- relationRecord,
2249
- collection,
2250
- record,
2251
- )
2252
- : relationRecord[recordTitleField || "id"]}
2253
- <Check
2254
- className={cn(
2255
- "ml-auto",
2256
- inputValue === relationRecord.id
2257
- ? "opacity-100"
2258
- : "opacity-0",
2259
- )}
2260
- />
2261
- </CommandItem>
2262
- ))}
2263
- </CommandGroup>
2264
- )}
2265
- </CommandList>
2266
- </Command>
2267
- </PopoverContent>
2268
- </Popover>
2264
+ {commandBody}
2265
+ </SheetContent>
2266
+ </Sheet>
2267
+ ) : (
2268
+ <Popover modal={true} open={isOpen} onOpenChange={handlePickerOpenChange}>
2269
+ <div className="flex items-center gap-2">
2270
+ <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
2271
+ </div>
2272
+ <PopoverContent
2273
+ className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
2274
+ align="start"
2275
+ >
2276
+ {commandBody}
2277
+ </PopoverContent>
2278
+ </Popover>
2279
+ )}
2269
2280
  </FormControl>
2270
2281
  {description && <FormDescription>{description}</FormDescription>}
2271
2282
  <FormMessage className="bg-destructive p-4 rounded-md text-background dark:text-primary" />
@@ -8,7 +8,7 @@ import {
8
8
  } from "@stoker-platform/types"
9
9
  import { preloadCacheEnabled } from "./utils/preloadCacheEnabled"
10
10
  import { serverReadOnly } from "./utils/serverReadOnly"
11
- import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"
11
+ import { type ReactNode, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"
12
12
  import { QueryConstraint, where, WhereFilterOp } from "firebase/firestore"
13
13
  import { getFilterDisjunctions } from "./utils/getFilterDisjunctions"
14
14
  import { performFullTextSearch } from "./utils/performFullTextSearch"
@@ -16,6 +16,9 @@ import { localFullTextSearch } from "./utils/localFullTextSearch"
16
16
  import { getOne, getSome } from "@stoker-platform/web-client"
17
17
  import { FormControl, FormItem, FormLabel } from "./components/ui/form"
18
18
  import { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover"
19
+ import { Sheet, SheetContent, SheetTrigger } from "./components/ui/sheet"
20
+ import { useIsMobile } from "./hooks/use-mobile"
21
+ import { useKeyboardOffset } from "./hooks/use-keyboard-offset"
19
22
  import { Button } from "./components/ui/button"
20
23
  import { Check, ChevronsUpDown, Plus, XCircle } from "lucide-react"
21
24
  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./components/ui/command"
@@ -330,126 +333,155 @@ export const PermissionPicker = ({
330
333
  popoverHeight = "h-48"
331
334
  }
332
335
 
336
+ const isMobile = useIsMobile()
337
+ const { offset: keyboardOffset, viewportHeight } = useKeyboardOffset(isMobile && isOpen)
338
+ const sheetHeight = Math.max(240, Math.min(viewportHeight - 16, viewportHeight * 0.9))
339
+
340
+ const handlePickerOpenChange = (nextOpen: boolean) => {
341
+ setIsOpen(nextOpen)
342
+ if (nextOpen) {
343
+ startTransition(() => {
344
+ getData(searchValue, constraints)
345
+ })
346
+ }
347
+ }
348
+
349
+ const triggerButton = (
350
+ <Button
351
+ variant="outline"
352
+ role="combobox"
353
+ aria-expanded={isOpen}
354
+ className="w-full justify-between"
355
+ disabled={isDisabled}
356
+ >
357
+ <span className="break-all whitespace-pre-wrap line-clamp-1 text-left">{inputValue ? display : ""}</span>
358
+ <ChevronsUpDown className="opacity-50 h-4 w-4" />
359
+ </Button>
360
+ )
361
+
362
+ const commandBody = (
363
+ <Command
364
+ filter={() => {
365
+ return 1
366
+ }}
367
+ className={isMobile ? "flex flex-col h-full" : undefined}
368
+ >
369
+ <CommandInput
370
+ placeholder={`Search ${title}...`}
371
+ className="h-9"
372
+ value={searchValue}
373
+ onValueChange={(value) => {
374
+ setSearchValue(value)
375
+ }}
376
+ />
377
+ <CommandList className={isMobile ? "flex-1 max-h-none" : undefined}>
378
+ <CommandEmpty>
379
+ {isOpen &&
380
+ (isLoading ? (
381
+ <LoadingSpinner size={7} className="m-auto" />
382
+ ) : !isLoadingImmediate ? (
383
+ `No ${title} found.`
384
+ ) : null)}
385
+ </CommandEmpty>
386
+ {(!isLoading || isCollectionPreloadCacheEnabled) && (
387
+ <CommandGroup>
388
+ {data && (
389
+ <CommandItem
390
+ key="no_selection"
391
+ value="no_selection"
392
+ onSelect={(currentValue) => {
393
+ setIsOpen(false)
394
+ if (currentValue !== inputValue) {
395
+ setValue(currentValue)
396
+ setDisplay("----")
397
+ }
398
+ }}
399
+ >
400
+ ----
401
+ </CommandItem>
402
+ )}
403
+ {data?.map((record: StokerRecord) => (
404
+ <CommandItem
405
+ key={record.id}
406
+ value={record.id}
407
+ onSelect={(currentValue) => {
408
+ setIsOpen(false)
409
+ if (currentValue !== inputValue) {
410
+ setValue(currentValue)
411
+ setDisplay(record[recordTitleField || "id"])
412
+ }
413
+ }}
414
+ >
415
+ {record[recordTitleField || "id"]}
416
+ <Check
417
+ className={cn("ml-auto", inputValue === record.id ? "opacity-100" : "opacity-0")}
418
+ />
419
+ </CommandItem>
420
+ ))}
421
+ </CommandGroup>
422
+ )}
423
+ </CommandList>
424
+ </Command>
425
+ )
426
+
427
+ const renderPicker = (children: ReactNode) =>
428
+ isMobile ? (
429
+ <Sheet open={isOpen} onOpenChange={handlePickerOpenChange}>
430
+ <div className="flex items-center gap-2">
431
+ <SheetTrigger asChild>{triggerButton}</SheetTrigger>
432
+ {children}
433
+ </div>
434
+ <SheetContent
435
+ side="bottom"
436
+ className="p-0 pt-8 flex flex-col gap-0 rounded-t-lg"
437
+ style={{
438
+ bottom: keyboardOffset,
439
+ height: sheetHeight,
440
+ maxHeight: sheetHeight,
441
+ }}
442
+ onOpenAutoFocus={(event) => {
443
+ event.preventDefault()
444
+ }}
445
+ >
446
+ {commandBody}
447
+ </SheetContent>
448
+ </Sheet>
449
+ ) : (
450
+ <Popover modal={true} open={isOpen} onOpenChange={handlePickerOpenChange}>
451
+ <div className="flex items-center gap-2">
452
+ <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
453
+ {children}
454
+ </div>
455
+ <PopoverContent className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")} align="start">
456
+ {commandBody}
457
+ </PopoverContent>
458
+ </Popover>
459
+ )
460
+
333
461
  if (type === "Parent_Property" && restriction) {
334
462
  return (
335
463
  <div className="w-full max-w-[750px]">
336
464
  <FormItem>
337
465
  <FormLabel>{`Accessible ${title}`}</FormLabel>
338
466
  <FormControl>
339
- <Popover
340
- modal={true}
341
- open={isOpen}
342
- onOpenChange={() => {
343
- setIsOpen(!isOpen)
344
- startTransition(() => {
345
- getData(searchValue, constraints)
346
- })
347
- }}
348
- >
349
- <div className="flex items-center gap-2">
350
- <PopoverTrigger asChild>
351
- <Button
352
- variant="outline"
353
- role="combobox"
354
- aria-expanded={isOpen}
355
- className="w-full justify-between"
356
- disabled={isDisabled}
357
- >
358
- <span className="break-all whitespace-pre-wrap line-clamp-1 text-left">
359
- {inputValue ? display : ""}
360
- </span>
361
- <ChevronsUpDown className="opacity-50 h-4 w-4" />
362
- </Button>
363
- </PopoverTrigger>
364
- {
365
- <Button
366
- type="button"
367
- disabled={isDisabled || !inputValue || inputValue === "no_selection"}
368
- variant="outline"
369
- size="icon"
370
- onClick={() => {
371
- if (hasUser) {
372
- form.setValue("operation", "update")
373
- } else {
374
- form.setValue("operation", "create")
375
- }
376
- handleAddEntity()
377
- }}
378
- >
379
- <Plus className="h-4 w-4" />
380
- </Button>
381
- }
382
- </div>
383
- <PopoverContent
384
- className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
385
- align="start"
467
+ {renderPicker(
468
+ <Button
469
+ type="button"
470
+ disabled={isDisabled || !inputValue || inputValue === "no_selection"}
471
+ variant="outline"
472
+ size="icon"
473
+ onClick={() => {
474
+ if (hasUser) {
475
+ form.setValue("operation", "update")
476
+ } else {
477
+ form.setValue("operation", "create")
478
+ }
479
+ handleAddEntity()
480
+ }}
386
481
  >
387
- <Command
388
- filter={() => {
389
- return 1
390
- }}
391
- >
392
- <CommandInput
393
- placeholder={`Search ${title}...`}
394
- className="h-9"
395
- value={searchValue}
396
- onValueChange={(value) => {
397
- setSearchValue(value)
398
- }}
399
- />
400
- <CommandList>
401
- <CommandEmpty>
402
- {isOpen &&
403
- (isLoading ? (
404
- <LoadingSpinner size={7} className="m-auto" />
405
- ) : !isLoadingImmediate ? (
406
- `No ${title} found.`
407
- ) : null)}
408
- </CommandEmpty>
409
- {(!isLoading || isCollectionPreloadCacheEnabled) && (
410
- <CommandGroup>
411
- {data && (
412
- <CommandItem
413
- key="no_selection"
414
- value="no_selection"
415
- onSelect={(currentValue) => {
416
- setIsOpen(false)
417
- if (currentValue !== inputValue) {
418
- setValue(currentValue)
419
- setDisplay("----")
420
- }
421
- }}
422
- >
423
- ----
424
- </CommandItem>
425
- )}
426
- {data?.map((record: StokerRecord) => (
427
- <CommandItem
428
- key={record.id}
429
- value={record.id}
430
- onSelect={(currentValue) => {
431
- setIsOpen(false)
432
- if (currentValue !== inputValue) {
433
- setValue(currentValue)
434
- setDisplay(record[recordTitleField || "id"])
435
- }
436
- }}
437
- >
438
- {record[recordTitleField || "id"]}
439
- <Check
440
- className={cn(
441
- "ml-auto",
442
- inputValue === record.id ? "opacity-100" : "opacity-0",
443
- )}
444
- />
445
- </CommandItem>
446
- ))}
447
- </CommandGroup>
448
- )}
449
- </CommandList>
450
- </Command>
451
- </PopoverContent>
452
- </Popover>
482
+ <Plus className="h-4 w-4" />
483
+ </Button>,
484
+ )}
453
485
  </FormControl>
454
486
  </FormItem>
455
487
  {selectedEntities.map((entity) => (
@@ -521,120 +553,24 @@ export const PermissionPicker = ({
521
553
  <FormItem>
522
554
  <FormLabel>{`Accessible ${title}`}</FormLabel>
523
555
  <FormControl>
524
- <Popover
525
- modal={true}
526
- open={isOpen}
527
- onOpenChange={() => {
528
- setIsOpen(!isOpen)
529
- startTransition(() => {
530
- getData(searchValue, constraints)
531
- })
532
- }}
533
- >
534
- <div className="flex items-center gap-2">
535
- <PopoverTrigger asChild>
536
- <Button
537
- variant="outline"
538
- role="combobox"
539
- aria-expanded={isOpen}
540
- className="w-full justify-between"
541
- disabled={isDisabled}
542
- >
543
- <span className="break-all whitespace-pre-wrap line-clamp-1 text-left">
544
- {inputValue ? display : ""}
545
- </span>
546
- <ChevronsUpDown className="opacity-50 h-4 w-4" />
547
- </Button>
548
- </PopoverTrigger>
549
- {
550
- <Button
551
- type="button"
552
- disabled={isDisabled || !inputValue || inputValue === "no_selection"}
553
- variant="outline"
554
- size="icon"
555
- onClick={() => {
556
- if (hasUser) {
557
- form.setValue("operation", "update")
558
- } else {
559
- form.setValue("operation", "create")
560
- }
561
- handleChange()
562
- }}
563
- >
564
- <Plus className="h-4 w-4" />
565
- </Button>
566
- }
567
- </div>
568
- <PopoverContent
569
- className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
570
- align="start"
556
+ {renderPicker(
557
+ <Button
558
+ type="button"
559
+ disabled={isDisabled || !inputValue || inputValue === "no_selection"}
560
+ variant="outline"
561
+ size="icon"
562
+ onClick={() => {
563
+ if (hasUser) {
564
+ form.setValue("operation", "update")
565
+ } else {
566
+ form.setValue("operation", "create")
567
+ }
568
+ handleChange()
569
+ }}
571
570
  >
572
- <Command
573
- filter={() => {
574
- return 1
575
- }}
576
- >
577
- <CommandInput
578
- placeholder={`Search ${title}...`}
579
- className="h-9"
580
- value={searchValue}
581
- onValueChange={(value) => {
582
- setSearchValue(value)
583
- }}
584
- />
585
- <CommandList>
586
- <CommandEmpty>
587
- {isOpen &&
588
- (isLoading ? (
589
- <LoadingSpinner size={7} className="m-auto" />
590
- ) : !isLoadingImmediate ? (
591
- `No ${title} found.`
592
- ) : null)}
593
- </CommandEmpty>
594
- {(!isLoading || isCollectionPreloadCacheEnabled) && (
595
- <CommandGroup>
596
- {data && (
597
- <CommandItem
598
- key="no_selection"
599
- value="no_selection"
600
- onSelect={(currentValue) => {
601
- setIsOpen(false)
602
- if (currentValue !== inputValue) {
603
- setValue(currentValue)
604
- setDisplay("----")
605
- }
606
- }}
607
- >
608
- ----
609
- </CommandItem>
610
- )}
611
- {data?.map((record: StokerRecord) => (
612
- <CommandItem
613
- key={record.id}
614
- value={record.id}
615
- onSelect={(currentValue) => {
616
- setIsOpen(false)
617
- if (currentValue !== inputValue) {
618
- setValue(currentValue)
619
- setDisplay(record[recordTitleField || "id"])
620
- }
621
- }}
622
- >
623
- {record[recordTitleField || "id"]}
624
- <Check
625
- className={cn(
626
- "ml-auto",
627
- inputValue === record.id ? "opacity-100" : "opacity-0",
628
- )}
629
- />
630
- </CommandItem>
631
- ))}
632
- </CommandGroup>
633
- )}
634
- </CommandList>
635
- </Command>
636
- </PopoverContent>
637
- </Popover>
571
+ <Plus className="h-4 w-4" />
572
+ </Button>,
573
+ )}
638
574
  </FormControl>
639
575
  </FormItem>
640
576
  <div className="mt-4">
@@ -0,0 +1,43 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ interface KeyboardViewport {
4
+ offset: number
5
+ viewportHeight: number
6
+ }
7
+
8
+ const getInitial = (): KeyboardViewport => {
9
+ if (typeof window === "undefined") return { offset: 0, viewportHeight: 0 }
10
+ const visualViewport = window.visualViewport
11
+ if (!visualViewport) return { offset: 0, viewportHeight: window.innerHeight }
12
+ return {
13
+ offset: Math.max(0, window.innerHeight - visualViewport.height - visualViewport.offsetTop),
14
+ viewportHeight: visualViewport.height,
15
+ }
16
+ }
17
+
18
+ export function useKeyboardOffset(enabled: boolean = true): KeyboardViewport {
19
+ const [state, setState] = useState<KeyboardViewport>(getInitial)
20
+
21
+ useEffect(() => {
22
+ if (!enabled) return
23
+ const visualViewport = window.visualViewport
24
+ if (!visualViewport) return
25
+
26
+ const update = () => {
27
+ setState({
28
+ offset: Math.max(0, window.innerHeight - visualViewport.height - visualViewport.offsetTop),
29
+ viewportHeight: visualViewport.height,
30
+ })
31
+ }
32
+ update()
33
+
34
+ visualViewport.addEventListener("resize", update)
35
+ visualViewport.addEventListener("scroll", update)
36
+ return () => {
37
+ visualViewport.removeEventListener("resize", update)
38
+ visualViewport.removeEventListener("scroll", update)
39
+ }
40
+ }, [enabled])
41
+
42
+ return state
43
+ }