StoragePoliciesBucketsSection.tsx228 lines · main
| 1 | import { useVirtualizer } from '@tanstack/react-virtual' |
| 2 | import { ChevronUp, Search, X } from 'lucide-react' |
| 3 | import { forwardRef, useEffect, useState, type HTMLAttributes, type ReactNode } from 'react' |
| 4 | import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' |
| 5 | import { ShimmeringLoader } from 'ui-patterns' |
| 6 | import { Input } from 'ui-patterns/DataInputs/Input' |
| 7 | import { |
| 8 | PageSection, |
| 9 | PageSectionContent, |
| 10 | PageSectionDescription, |
| 11 | PageSectionMeta, |
| 12 | PageSectionSummary, |
| 13 | PageSectionTitle, |
| 14 | } from 'ui-patterns/PageSection' |
| 15 | |
| 16 | import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow' |
| 17 | import StoragePoliciesPlaceholder from './StoragePoliciesPlaceholder' |
| 18 | import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' |
| 19 | import { useMainScrollContainer } from '@/components/layouts/MainScrollContainerContext' |
| 20 | import { NoSearchResults } from '@/components/ui/NoSearchResults' |
| 21 | import { type Bucket } from '@/data/storage/buckets-query' |
| 22 | import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent' |
| 23 | |
| 24 | export type SelectBucketPolicyForAction = { |
| 25 | addPolicy: (bucketName?: string, table?: string) => void |
| 26 | editPolicy: (policy: Policy, bucketName?: string, table?: string) => void |
| 27 | deletePolicy: (policy: Policy) => void |
| 28 | } |
| 29 | |
| 30 | type BucketsPoliciesProps = { |
| 31 | buckets: { bucket: Bucket; policies: Policy[] }[] |
| 32 | search?: string |
| 33 | debouncedSearch?: string |
| 34 | setSearch: (search: string) => void |
| 35 | actions: SelectBucketPolicyForAction |
| 36 | pagination: { |
| 37 | hasNextPage: boolean |
| 38 | isFetchingNextPage: boolean |
| 39 | fetchNextPage: () => void |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | export const BucketsPolicies = ({ |
| 44 | buckets, |
| 45 | search, |
| 46 | debouncedSearch, |
| 47 | setSearch, |
| 48 | actions, |
| 49 | pagination, |
| 50 | }: BucketsPoliciesProps): ReactNode => { |
| 51 | const [expanded, setExpanded] = useState(true) |
| 52 | |
| 53 | const showEmptyState = buckets.length === 0 && (!debouncedSearch || debouncedSearch.length === 0) |
| 54 | |
| 55 | return ( |
| 56 | <PageSection> |
| 57 | <Collapsible open={expanded} onOpenChange={setExpanded}> |
| 58 | <PageSectionMeta> |
| 59 | <PageSectionSummary> |
| 60 | <PageSectionTitle>Buckets</PageSectionTitle> |
| 61 | <PageSectionDescription> |
| 62 | Write policies for each bucket to control access to the bucket and its contents |
| 63 | </PageSectionDescription> |
| 64 | </PageSectionSummary> |
| 65 | <CollapsibleTrigger asChild> |
| 66 | <button> |
| 67 | <span className="sr-only">Toggle bucket list</span> |
| 68 | <ChevronUp |
| 69 | size={14} |
| 70 | className={cn( |
| 71 | !expanded && 'rotate-180', |
| 72 | 'transition', |
| 73 | 'text-foreground-light hover:text-foreground' |
| 74 | )} |
| 75 | /> |
| 76 | </button> |
| 77 | </CollapsibleTrigger> |
| 78 | </PageSectionMeta> |
| 79 | <CollapsibleContent> |
| 80 | <PageSectionContent className="mt-6"> |
| 81 | {showEmptyState && <StoragePoliciesPlaceholder />} |
| 82 | |
| 83 | {buckets.length > 0 && ( |
| 84 | <div className="mb-4"> |
| 85 | <Input |
| 86 | size="tiny" |
| 87 | placeholder="Filter buckets" |
| 88 | className="block" |
| 89 | containerClassName="w-full lg:w-52" |
| 90 | value={search || ''} |
| 91 | onChange={(e) => { |
| 92 | const str = e.target.value |
| 93 | setSearch(str) |
| 94 | }} |
| 95 | icon={<Search />} |
| 96 | actions={ |
| 97 | search ? ( |
| 98 | <Button |
| 99 | size="tiny" |
| 100 | type="text" |
| 101 | className="p-0 h-5 w-5" |
| 102 | icon={<X />} |
| 103 | onClick={() => setSearch('')} |
| 104 | /> |
| 105 | ) : null |
| 106 | } |
| 107 | /> |
| 108 | </div> |
| 109 | )} |
| 110 | |
| 111 | {!!search && search.length > 0 && buckets.length === 0 && ( |
| 112 | <NoSearchResults searchString={search} onResetFilter={() => setSearch('')} /> |
| 113 | )} |
| 114 | |
| 115 | <BucketsPoliciesVirtualizedList |
| 116 | items={buckets} |
| 117 | actions={actions} |
| 118 | pagination={pagination} |
| 119 | /> |
| 120 | </PageSectionContent> |
| 121 | </CollapsibleContent> |
| 122 | </Collapsible> |
| 123 | </PageSection> |
| 124 | ) |
| 125 | } |
| 126 | |
| 127 | type BucketsPoliciesVirtualizedListProps = { |
| 128 | items: { bucket: Bucket; policies: Policy[] }[] |
| 129 | actions: SelectBucketPolicyForAction |
| 130 | pagination: BucketsPoliciesProps['pagination'] |
| 131 | } |
| 132 | |
| 133 | const BucketsPoliciesVirtualizedList = ({ |
| 134 | items, |
| 135 | actions, |
| 136 | pagination, |
| 137 | }: BucketsPoliciesVirtualizedListProps) => { |
| 138 | const { hasNextPage, isFetchingNextPage, fetchNextPage } = pagination |
| 139 | |
| 140 | const itemCount = hasNextPage ? items.length + 1 : items.length |
| 141 | |
| 142 | const scrollElement = useMainScrollContainer() |
| 143 | const virtualizer = useVirtualizer({ |
| 144 | count: itemCount, |
| 145 | estimateSize: () => 129, |
| 146 | overscan: 5, |
| 147 | getItemKey: (index) => items[index]?.bucket.name ?? `bucket-${index}`, |
| 148 | getScrollElement: () => scrollElement, |
| 149 | }) |
| 150 | |
| 151 | const virtualItems = virtualizer.getVirtualItems() |
| 152 | const lastItem = virtualItems[virtualItems.length - 1] |
| 153 | |
| 154 | const fetchNext = useStaticEffectEvent(() => { |
| 155 | if (lastItem && lastItem.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) { |
| 156 | fetchNextPage() |
| 157 | } |
| 158 | }) |
| 159 | useEffect(fetchNext, [lastItem, fetchNext]) |
| 160 | |
| 161 | return ( |
| 162 | <div |
| 163 | style={{ |
| 164 | height: `${virtualizer.getTotalSize()}px`, |
| 165 | width: '100%', |
| 166 | position: 'relative', |
| 167 | }} |
| 168 | > |
| 169 | {virtualItems.map((virtualRow) => { |
| 170 | const isLoaderRow = virtualRow.index > items.length - 1 |
| 171 | const commonStyle = { |
| 172 | position: 'absolute' as const, |
| 173 | top: 0, |
| 174 | left: 0, |
| 175 | width: '100%', |
| 176 | transform: `translateY(${virtualRow.start}px)`, |
| 177 | } |
| 178 | |
| 179 | if (isLoaderRow) { |
| 180 | return ( |
| 181 | <BucketsPoliciesLoader |
| 182 | key={`loader-${virtualRow.index}`} |
| 183 | data-index={virtualRow.index} |
| 184 | ref={virtualizer.measureElement} |
| 185 | className="pb-4" |
| 186 | style={commonStyle} |
| 187 | /> |
| 188 | ) |
| 189 | } |
| 190 | |
| 191 | const item = items[virtualRow.index] |
| 192 | if (!item) return null |
| 193 | |
| 194 | return ( |
| 195 | <div |
| 196 | key={virtualRow.key} |
| 197 | data-index={virtualRow.index} |
| 198 | ref={virtualizer.measureElement} |
| 199 | className="pb-4" |
| 200 | style={commonStyle} |
| 201 | > |
| 202 | <StoragePoliciesBucketRow |
| 203 | table="objects" |
| 204 | label={item.bucket.name} |
| 205 | bucket={item.bucket} |
| 206 | policies={item.policies} |
| 207 | onSelectPolicyAdd={actions.addPolicy} |
| 208 | onSelectPolicyEdit={actions.editPolicy} |
| 209 | onSelectPolicyDelete={actions.deletePolicy} |
| 210 | /> |
| 211 | </div> |
| 212 | ) |
| 213 | })} |
| 214 | </div> |
| 215 | ) |
| 216 | } |
| 217 | |
| 218 | type BucketsPoliciesLoaderProps = HTMLAttributes<HTMLDivElement> |
| 219 | |
| 220 | const BucketsPoliciesLoader = forwardRef<HTMLDivElement, BucketsPoliciesLoaderProps>( |
| 221 | (props: BucketsPoliciesLoaderProps, ref) => ( |
| 222 | <div ref={ref} {...props}> |
| 223 | <p className="sr-only">Loading more...</p> |
| 224 | <ShimmeringLoader /> |
| 225 | </div> |
| 226 | ) |
| 227 | ) |
| 228 | BucketsPoliciesLoader.displayName = 'BucketsPoliciesLoader' |