Merge branch 'master' into feat/mermaid-blog-v2

This commit is contained in:
Jonathan Summers-Muir authored and GitHub committed 2025-12-03 19:24:26 +08:00
commit 80b2dceef9
150 files changed
+5294 -1706

No files matched your search

+22
View File
@@ -1831,6 +1831,28 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"form-patterns-pagelayout": {
name: "form-patterns-pagelayout",
type: "components:example",
registryDependencies: ["form","card","input","button","select","switch","checkbox","textarea","radio-group","calendar","popover","multi-select"],
component: React.lazy(() => import("@/registry/default/example/form-patterns-pagelayout")),
source: "",
files: ["registry/default/example/form-patterns-pagelayout.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"form-patterns-sidepanel": {
name: "form-patterns-sidepanel",
type: "components:example",
registryDependencies: ["form","sheet","input","button","select","switch","checkbox","textarea","radio-group","calendar","popover","multi-select"],
component: React.lazy(() => import("@/registry/default/example/form-patterns-sidepanel")),
source: "",
files: ["registry/default/example/form-patterns-sidepanel.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"form-item-layout-with-select": {
name: "form-item-layout-with-select",
type: "components:example",
+5
View File
@@ -65,6 +65,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/ui-patterns/empty-states',
items: [],
},
{
title: 'Forms',
href: '/docs/ui-patterns/forms',
items: [],
},
],
},
{
@@ -48,6 +48,24 @@ import {
</Table>
```
## Sortable Columns
Use `TableHeadSort` inside `TableHead` to enable column sorting.
| Prop | Type | Description |
| -------------- | -------------------------- | ------------------------------------------------------------------ |
| `column` | `string` | Unique identifier for the column |
| `currentSort` | `string` | Current sort state in format `"column:order"` (e.g., `"name:asc"`) |
| `onSortChange` | `(column: string) => void` | Callback fired when column header is clicked |
```tsx
<TableHead>
<TableHeadSort column="name" currentSort={sort} onSortChange={handleSortChange}>
Name
</TableHeadSort>
</TableHead>
```
## Data Table
You can use the `<Table />` component to build more complex data tables. Combine it with [@tanstack/react-table](https://tanstack.com/table/v8) to create tables with sorting, filtering and pagination.
@@ -0,0 +1,50 @@
---
title: Forms
description: Common form patterns used in Studio settings pages and side panels.
---
Forms in Supabase Studio should follow consistent patterns to ensure a cohesive user experience across settings pages and side panels. This guide covers the most common form patterns and field types.
## Page Layout
Forms in page layouts typically use `PageSection` components with `Card` containers. Fields use `FormItemLayout` with `layout="flex-row-reverse"` for horizontal alignment.
<ComponentPreview
name="form-patterns-pagelayout"
description="Complete form example with all field types in a PageLayout pattern"
peekCode
wide
/>
## Side Panel
Forms in side panels (Sheets) use `FormItemLayout` with `layout="horizontal"` on wider panels and `layout="vertical"` on panels with a size of `sm` or below. The form is typically wrapped in a `Sheet` component.
<ComponentPreview
name="form-patterns-sidepanel"
description="Complete form example with all field types in a SidePanel/Sheet pattern"
peekCode
wide
/>
## Best Practices
1. **Always use FormItemLayout**: Use `FormItemLayout` instead of manually composing `FormItem`, `FormLabel`, `FormMessage`, and `FormDescription`.
2. **Layout selection**:
- Use `layout="flex-row-reverse"` for page layouts (horizontal alignment)
- Use `layout="horizontal"` for side panels with more width
- Use `layout="vertical"` for side panels with limited width
3. **Wrap inputs in FormControl*Shadcn***: Always wrap form inputs with `FormControl_Shadcn_` to ensure proper form integration.
4. **Use Cards for grouping**: Wrap form sections in `Card` components with `CardContent` and `CardFooter` for actions.
5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`.
6. **Error handling**: Always use mutations with `onSuccess` and `onError` callbacks that show toast notifications.
7. **Loading states**: Show loading states on submit buttons using the `loading` prop.
8. **Form IDs**: When submit buttons are outside the form, use a form ID and reference it with the `form` prop on the button.
@@ -21,7 +21,7 @@ export default function DialogDemo() {
<DialogHeader padding={'small'}>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you're done.
Make changes to your profile here. Click save when you&apos;re done.
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
@@ -0,0 +1,748 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { format } from 'date-fns'
import { CalendarIcon, ExternalLink, Plus, Trash2, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import * as z from 'zod'
import {
Button,
Calendar,
Card,
CardContent,
CardFooter,
Checkbox_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
PrePostTab,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Separator,
Switch,
Textarea,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { Input } from 'ui-patterns/DataInputs/Input'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import {
PageSection,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
maxConnections: z.number().min(1).max(1000),
enableFeature: z.boolean(),
enableRls: z.boolean(),
enableNotifications: z.boolean(),
enableAnalytics: z.boolean(),
region: z.string().min(1, 'Region is required'),
schemas: z.array(z.string()).min(1, 'At least one schema is required'),
queueType: z.enum(['basic', 'partitioned']),
expiryDate: z.date().optional(),
password: z.string().min(8, 'Password must be at least 8 characters'),
duration: z.number().min(5).max(30),
redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })),
apiKey: z.string().optional(),
})
const fakeApiKey = 'sk_live_51H3x4mpl3_4nd_53cur3_k3y_1234567890'
export default function FormPatternsPageLayout() {
const uploadButtonRef = useRef<HTMLInputElement>(null)
const fileUploadRef = useRef<HTMLInputElement>(null)
const [logoFile, setLogoFile] = useState<File>()
const [logoUrl, setLogoUrl] = useState<string>()
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [isDragging, setIsDragging] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
description: '',
maxConnections: 10,
enableFeature: false,
enableRls: true,
enableNotifications: false,
enableAnalytics: true,
region: '',
schemas: ['public'],
queueType: 'basic',
expiryDate: undefined,
password: '',
duration: 10,
redirectUris: [{ value: '' }],
apiKey: fakeApiKey,
},
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'redirectUris',
})
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
}
return (
<div className="w-full">
<PageSection className="py-0">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Form Settings</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-6">
{/* Text Input */}
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Text Input"
description="Single-line text entry for short values"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Enter text" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Password Input */}
<FormField_Shadcn_
control={form.control}
name="password"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Password Input"
description="Masked input for secure text entry"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} type="password" placeholder="Enter password" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Copyable Input */}
<FormField_Shadcn_
control={form.control}
name="apiKey"
render={() => (
<FormItemLayout
layout="flex-row-reverse"
label="Copyable Input"
description="Read-only input with copy-to-clipboard functionality"
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<FormControl_Shadcn_>
<Input
copy
readOnly
value={form.getValues('apiKey') || ''}
onChange={() => {}}
onCopy={() => console.log('Copied to clipboard')}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Number Input */}
<FormField_Shadcn_
control={form.control}
name="maxConnections"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Number Input"
description="Numeric input with min/max validation"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
min={1}
max={1000}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Input with Units */}
<FormField_Shadcn_
control={form.control}
name="duration"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Input with Units"
description="Input with additional unit label"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<PrePostTab postTab="MB" className="w-full">
<Input_Shadcn_ {...field} type="number" min={5} max={30} />
</PrePostTab>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Textarea */}
<FormField_Shadcn_
control={form.control}
name="description"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Textarea"
description="Multi-line text input for longer content"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Textarea
{...field}
rows={4}
placeholder="Enter multi-line text"
className="resize-none"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Icon Upload */}
<FormField_Shadcn_
control={form.control}
name="description"
render={() => (
<FormItemLayout
layout="flex-row-reverse"
label="Icon upload"
description="For icons, avatars, or small images with preview"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<div className="flex gap-4 items-center">
<button
type="button"
onClick={() => uploadButtonRef.current?.click()}
className="flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter hover:text-foreground-light overflow-hidden rounded-full bg-cover border hover:border-strong"
style={{
backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
}}
>
{!logoUrl && <Upload size={14} />}
</button>
<div className="flex gap-2 items-center">
<Button
type="default"
size="tiny"
icon={<Upload size={14} />}
onClick={() => uploadButtonRef.current?.click()}
>
Upload
</Button>
{logoUrl && (
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
onClick={() => {
setLogoFile(undefined)
setLogoUrl(undefined)
}}
/>
)}
</div>
<input
type="file"
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) {
const file = files[0]
setLogoFile(file)
setLogoUrl(URL.createObjectURL(file))
e.target.value = ''
}
}}
/>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* File Upload */}
<FormField_Shadcn_
control={form.control}
name="description"
render={() => (
<FormItemLayout
layout="flex-row-reverse"
label="File Upload"
description="Drag-and-drop or select files for upload"
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<FormControl_Shadcn_>
<div
className={`border-2 rounded-lg p-6 text-center bg-muted transition-colors duration-300 ${
isDragging
? 'border-strong border-dashed bg-muted'
: 'border-border border-dashed'
}`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
setUploadedFiles((prev) => [...prev, ...files])
}}
>
<input
type="file"
ref={fileUploadRef}
className="hidden"
multiple
onChange={(e) => {
const files = e.target.files
if (files) {
setUploadedFiles((prev) => [...prev, ...Array.from(files)])
}
e.target.value = ''
}}
/>
<div className="flex flex-col items-center gap-y-2">
<Upload size={20} className="text-foreground-lighter" />
<p className="text-sm text-foreground-light">
{uploadedFiles.length > 0
? `${uploadedFiles.length} file${uploadedFiles.length > 1 ? 's' : ''} selected`
: 'Upload files'}
</p>
<p className="text-xs text-foreground-lighter">
Drag and drop or{' '}
<button
type="button"
onClick={() => fileUploadRef.current?.click()}
className="underline cursor-pointer hover:text-foreground-light"
>
select files
</button>{' '}
to upload
</p>
{uploadedFiles.length > 0 && (
<div className="mt-4 w-full space-y-2">
{uploadedFiles.map((file, idx) => (
<div
key={`${file.name}-${idx}`}
className="flex items-center justify-between gap-2 p-2 bg rounded border"
>
<span className="text-sm text-foreground-light truncate flex-1">
{file.name}
</span>
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
onClick={() => {
setUploadedFiles((prev) =>
prev.filter((_, i) => i !== idx)
)
}}
/>
</div>
))}
</div>
)}
</div>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Switch */}
<FormField_Shadcn_
control={form.control}
name="enableFeature"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Switch"
description="Toggle for boolean on/off states"
>
<FormControl_Shadcn_>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Checkbox */}
<FormItemLayout
layout="flex-row-reverse"
label="Checkbox"
description="Boolean values or multiple selections"
className="[&>div]:md:w-1/2"
>
<div className="w-full flex flex-col gap-4">
<FormField_Shadcn_
control={form.control}
name="enableRls"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl_Shadcn_>
<Checkbox_Shadcn_
id="enable-rls"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
<label
htmlFor="enable-rls"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable Row Level Security
</label>
</div>
)}
/>
<FormField_Shadcn_
control={form.control}
name="enableNotifications"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl_Shadcn_>
<Checkbox_Shadcn_
id="enable-notifications"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
<label
htmlFor="enable-notifications"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable email notifications
</label>
</div>
)}
/>
<FormField_Shadcn_
control={form.control}
name="enableAnalytics"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl_Shadcn_>
<Checkbox_Shadcn_
id="enable-analytics"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
<label
htmlFor="enable-analytics"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable analytics tracking
</label>
</div>
)}
/>
</div>
</FormItemLayout>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Select */}
<FormField_Shadcn_
control={form.control}
name="region"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Select (Dropdown)"
description="Single selection from a list of options"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select an option" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectItem_Shadcn_ value="us-east-1">
US East (N. Virginia)
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="us-west-2">
US West (Oregon)
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="eu-west-1">
EU West (Ireland)
</SelectItem_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Multi-Select */}
<FormField_Shadcn_
control={form.control}
name="schemas"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Multi-Select"
description="Multiple selection from a list"
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size="small"
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select options..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full"
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="public">public</MultiSelectorItem>
<MultiSelectorItem value="auth">auth</MultiSelectorItem>
<MultiSelectorItem value="storage">storage</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Radio Group */}
<FormField_Shadcn_
control={form.control}
name="queueType"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Radio Group"
description="Single selection from multiple options"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
<RadioGroupStackedItem
value="basic"
label="Option 1"
description="First option description"
/>
<RadioGroupStackedItem
value="partitioned"
label="Option 2"
description="Second option description"
/>
</RadioGroupStacked>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Date Picker */}
<FormField_Shadcn_
control={form.control}
name="expiryDate"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Date Picker"
description="Date selection with calendar popover"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="outline"
className="w-full justify-start text-left font-normal px-3 py-4"
icon={<CalendarIcon className="h-4 w-4" />}
>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Field Array */}
<FormField_Shadcn_
control={form.control}
name="redirectUris"
render={() => (
<FormItemLayout
layout="flex-row-reverse"
label="Field Array"
description="Dynamic list for adding/removing items"
className="[&>div]:md:w-1/2"
>
<div className="space-y-2 w-full">
{fields.map((field, index) => (
<FormField_Shadcn_
key={field.id}
control={form.control}
name={`redirectUris.${index}.value`}
render={({ field: inputField }) => (
<div className="flex gap-2">
<FormControl_Shadcn_>
<Input_Shadcn_
{...inputField}
placeholder="https://example.com/callback"
/>
</FormControl_Shadcn_>
{fields.length > 1 && (
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
onClick={() => remove(index)}
/>
)}
</div>
)}
/>
))}
<Button
type="default"
icon={<Plus />}
onClick={() => append({ value: '' })}
>
Add redirect URI
</Button>
</div>
</FormItemLayout>
)}
/>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Action Field */}
<FormItemLayout
layout="flex-row-reverse"
label="Action Field"
description="Button or link for navigation or performable actions"
className="[&>div]:md:w-1/2"
>
<div className="flex gap-2 items-center">
<Button
type="default"
icon={<ExternalLink size={14} />}
onClick={() => console.log('Action performed')}
>
View documentation
</Button>
<Button type="default" onClick={() => console.log('Reset action')}>
Reset API key
</Button>
</div>
</FormItemLayout>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button type="primary" htmlType="submit" disabled={!form.formState.isDirty}>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</PageSectionContent>
</PageSection>
</div>
)
}
@@ -0,0 +1,770 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { format } from 'date-fns'
import { CalendarIcon, ExternalLink, Plus, Trash2, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import * as z from 'zod'
import {
Button,
Calendar,
Checkbox_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Separator,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
Switch,
Textarea,
PrePostTab,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { Input } from 'ui-patterns/DataInputs/Input'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
maxConnections: z.number().min(1).max(1000),
enableFeature: z.boolean(),
enableRls: z.boolean(),
enableNotifications: z.boolean(),
enableAnalytics: z.boolean(),
region: z.string().min(1, 'Region is required'),
schemas: z.array(z.string()).min(1, 'At least one schema is required'),
queueType: z.enum(['basic', 'partitioned']),
expiryDate: z.date().optional(),
password: z.string().min(8, 'Password must be at least 8 characters'),
duration: z.number().min(5).max(30),
redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })),
apiKey: z.string().optional(),
})
const fakeApiKey = 'sk_live_51H3x4mpl3_4nd_53cur3_k3y_1234567890'
export default function FormPatternsSidePanel() {
const [open, setOpen] = useState(false)
const uploadButtonRef = useRef<HTMLInputElement>(null)
const fileUploadRef = useRef<HTMLInputElement>(null)
const [logoFile, setLogoFile] = useState<File>()
const [logoUrl, setLogoUrl] = useState<string>()
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [isDragging, setIsDragging] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
description: '',
maxConnections: 10,
enableFeature: false,
enableRls: true,
enableNotifications: false,
enableAnalytics: true,
region: '',
schemas: ['public'],
queueType: 'basic',
expiryDate: undefined,
password: '',
duration: 10,
redirectUris: [{ value: '' }],
apiKey: fakeApiKey,
},
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'redirectUris',
})
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
setOpen(false)
}
const formId = 'sidepanel-form'
return (
<>
<Button type="primary" onClick={() => setOpen(true)}>
Open form panel
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent size="lg" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>Create Configuration</SheetTitle>
</SheetHeader>
<Form_Shadcn_ {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="overflow-auto flex-grow px-0"
>
{/* Text Input */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Text Input"
description="Single-line text entry for short values"
>
<FormControl_Shadcn_ className="col-span-6">
<Input_Shadcn_ {...field} placeholder="Enter text" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Password Input */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="password"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Password Input"
description="Masked input for secure text entry"
>
<FormControl_Shadcn_ className="col-span-6">
<Input_Shadcn_ {...field} type="password" placeholder="Enter password" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Copyable Input */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="apiKey"
render={() => (
<FormItemLayout
layout="horizontal"
label="Copyable Input"
description="Read-only input with copy-to-clipboard functionality"
>
<FormControl_Shadcn_ className="col-span-6">
<Input
copy
readOnly
className="input-mono"
value={form.getValues('apiKey') || ''}
onChange={() => {}}
onCopy={() => console.log('Copied to clipboard')}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Number Input */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="maxConnections"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Number Input"
description="Numeric input with min/max validation"
>
<FormControl_Shadcn_ className="col-span-6">
<Input_Shadcn_
{...field}
type="number"
min={1}
max={1000}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Input with Units */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="duration"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Input with Units"
description="Input with additional unit label"
>
<FormControl_Shadcn_ className="col-span-6">
<PrePostTab postTab="MB" className="w-full">
<Input_Shadcn_ {...field} type="number" min={5} max={30} />
</PrePostTab>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Textarea */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="description"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Textarea"
description="Multi-line text input for longer content"
>
<FormControl_Shadcn_ className="col-span-6">
<Textarea
{...field}
rows={3}
placeholder="Enter multi-line text"
className="resize-none"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Icon Upload */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="description"
render={() => (
<FormItemLayout
layout="horizontal"
label="Icon upload"
description="For icons, avatars, or small images with preview"
>
<FormControl_Shadcn_ className="col-span-6">
<div className="flex gap-4 items-center">
<button
type="button"
onClick={() => uploadButtonRef.current?.click()}
className="flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter hover:text-foreground-light overflow-hidden rounded-full bg-cover border hover:border-strong"
style={{
backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
}}
>
{!logoUrl && <Upload size={14} />}
</button>
<div className="flex gap-2 items-center">
<Button
type="default"
size="tiny"
icon={<Upload size={14} />}
onClick={() => uploadButtonRef.current?.click()}
>
Upload
</Button>
{logoUrl && (
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
onClick={() => {
setLogoFile(undefined)
setLogoUrl(undefined)
}}
/>
)}
</div>
<input
type="file"
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) {
const file = files[0]
setLogoFile(file)
setLogoUrl(URL.createObjectURL(file))
e.target.value = ''
}
}}
/>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* File Upload */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="description"
render={() => (
<FormItemLayout
layout="horizontal"
label="File Upload"
description="Drag-and-drop or select files for upload"
>
<FormControl_Shadcn_ className="col-span-6">
<div
className={`border-2 rounded-lg p-6 text-center bg-muted transition-colors duration-300 ${
isDragging
? 'border-strong border-dashed bg-muted'
: 'border-border border-dashed'
}`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
setUploadedFiles((prev) => [...prev, ...files])
}}
>
<input
type="file"
ref={fileUploadRef}
className="hidden"
multiple
onChange={(e) => {
const files = e.target.files
if (files) {
setUploadedFiles((prev) => [...prev, ...Array.from(files)])
}
e.target.value = ''
}}
/>
<div className="flex flex-col items-center gap-y-2">
<Upload size={20} className="text-foreground-lighter" />
<p className="text-sm text-foreground-light">
{uploadedFiles.length > 0
? `${uploadedFiles.length} file${uploadedFiles.length > 1 ? 's' : ''} selected`
: 'Upload files'}
</p>
<p className="text-xs text-foreground-lighter">
Drag and drop or{' '}
<button
type="button"
onClick={() => fileUploadRef.current?.click()}
className="underline cursor-pointer hover:text-foreground-light"
>
select files
</button>{' '}
to upload
</p>
{uploadedFiles.length > 0 && (
<div className="mt-4 w-full space-y-2">
{uploadedFiles.map((file, idx) => (
<div
key={`${file.name}-${idx}`}
className="flex items-center justify-between gap-2 p-2 bg rounded border"
>
<span className="text-sm text-foreground-light truncate flex-1">
{file.name}
</span>
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
onClick={() => {
setUploadedFiles((prev) => prev.filter((_, i) => i !== idx))
}}
/>
</div>
))}
</div>
)}
</div>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Switch */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="enableFeature"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Switch"
description="Toggle for boolean on/off states"
>
<FormControl_Shadcn_ className="col-span-6">
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
{/* Checkbox */}
<SheetSection>
<FormItemLayout
layout="horizontal"
label="Checkbox"
description="Boolean values or multiple selections"
>
<div className="col-span-6 w-full flex flex-col gap-4">
<FormField_Shadcn_
control={form.control}
name="enableRls"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl_Shadcn_>
<Checkbox_Shadcn_
id="enable-rls"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
<label
htmlFor="enable-rls"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable Row Level Security
</label>
</div>
)}
/>
<FormField_Shadcn_
control={form.control}
name="enableNotifications"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl_Shadcn_>
<Checkbox_Shadcn_
id="enable-notifications"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
<label
htmlFor="enable-notifications"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable email notifications
</label>
</div>
)}
/>
<FormField_Shadcn_
control={form.control}
name="enableAnalytics"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl_Shadcn_>
<Checkbox_Shadcn_
id="enable-analytics"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
<label
htmlFor="enable-analytics"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable analytics tracking
</label>
</div>
)}
/>
</div>
</FormItemLayout>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Select */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="region"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Select (Dropdown)"
description="Single selection from a list of options"
>
<FormControl_Shadcn_ className="col-span-6">
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select an option" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectItem_Shadcn_ value="us-east-1">
US East (N. Virginia)
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="us-west-2">
US West (Oregon)
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="eu-west-1">
EU West (Ireland)
</SelectItem_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Multi-Select */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="schemas"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Multi-Select"
description="Multiple selection from a list"
>
<div className="col-span-6">
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size="small"
className="w-full"
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select options..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full !min-w-lg"
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="public">public</MultiSelectorItem>
<MultiSelectorItem value="auth">auth</MultiSelectorItem>
<MultiSelectorItem value="storage">storage</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Radio Group */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="queueType"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Radio Group"
description="Single selection from multiple options"
>
<div className="col-span-6">
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
<RadioGroupStackedItem
value="basic"
label="Option 1"
description="First option description"
/>
<RadioGroupStackedItem
value="partitioned"
label="Option 2"
description="Second option description"
/>
</RadioGroupStacked>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Date Picker */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="expiryDate"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Date Picker"
description="Date selection with calendar popover"
>
<FormControl_Shadcn_ className="col-span-6">
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="outline"
className="w-full justify-start text-left font-normal px-3 py-4"
icon={<CalendarIcon className="h-4 w-4" />}
>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Field Array */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="redirectUris"
render={() => (
<FormItemLayout
layout="horizontal"
label="Field Array"
description="Dynamic list for adding/removing items"
>
<div className="col-span-6 space-y-2">
{fields.map((field, index) => (
<FormField_Shadcn_
key={field.id}
control={form.control}
name={`redirectUris.${index}.value`}
render={({ field: inputField }) => (
<div className="flex gap-2">
<FormControl_Shadcn_>
<Input_Shadcn_
{...inputField}
placeholder="https://example.com/callback"
/>
</FormControl_Shadcn_>
{fields.length > 1 && (
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
onClick={() => remove(index)}
/>
)}
</div>
)}
/>
))}
<Button
type="default"
icon={<Plus />}
onClick={() => append({ value: '' })}
>
Add redirect URI
</Button>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Action Field */}
<SheetSection>
<FormItemLayout
layout="horizontal"
label="Action Field"
description="Button or link for navigation or performable actions"
>
<div className="col-span-6 flex gap-2 items-center">
<Button
type="default"
icon={<ExternalLink size={14} />}
onClick={() => console.log('Action performed')}
>
View documentation
</Button>
<Button type="default" onClick={() => console.log('Reset action')}>
Reset API key
</Button>
</div>
</FormItemLayout>
</SheetSection>
</form>
</Form_Shadcn_>
<SheetFooter>
<Button
type="default"
onClick={() => {
form.reset()
setOpen(false)
}}
>
Cancel
</Button>
<Button type="primary" form={formId} htmlType="submit">
Create
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</>
)
}
@@ -24,7 +24,7 @@ export default function PageLayoutDetail() {
<PageHeaderSummary>
<PageHeaderTitle>Billing</PageHeaderTitle>
<PageHeaderDescription>
Manage your organization's billing and subscription settings.
Manage your organization&apos;s billing and subscription settings.
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
@@ -72,7 +72,7 @@ export default function PageLayoutDetail() {
<PageSectionSummary>
<PageSectionTitle>Cost control</PageSectionTitle>
<PageSectionDescription>
Set spending limits and alerts to manage your organization's costs.
Set spending limits and alerts to manage your organization&apos;s costs.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
@@ -39,7 +39,7 @@ export default function MultiSelectDemo() {
</a>
</h2>
<p>
Before diving deep into cloud services, it's important to understand the basic
Before diving deep into cloud services, it&apos;s important to understand the basic
building blocks that make cloud computing possible.
</p>
@@ -39,7 +39,7 @@ export default function MultiSelectDemo() {
</a>
</h2>
<p>
Before diving deep into cloud services, it's important to understand the basic
Before diving deep into cloud services, it&apos;s important to understand the basic
building blocks that make cloud computing possible.
</p>
@@ -1,8 +1,8 @@
export default function TypographyBlockquote() {
return (
<blockquote className="mt-6 border-l-2 pl-6 italic">
"After all," he said, "everyone enjoys a good joke, so it's only fair that they should pay for
the privilege."
&quot;After all,&quot; he said, &quot;everyone enjoys a good joke, so it&apos;s only fair that
they should pay for the privilege.&quot;
</blockquote>
)
}
@@ -10,7 +10,7 @@ export default function TypographyDemo() {
of money.
</p>
<h2 className="mt-10 scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0">
The King's Plan
The King&apos;s Plan
</h2>
<p className="leading-7 [&:not(:first-child)]:mt-6">
The king thought long and hard, and finally came up with{' '}
@@ -20,12 +20,13 @@ export default function TypographyDemo() {
: he would tax the jokes in the kingdom.
</p>
<blockquote className="mt-6 border-l-2 pl-6 italic">
"After all," he said, "everyone enjoys a good joke, so it's only fair that they should pay
for the privilege."
&quot;After all,&quot; he said, &quot;everyone enjoys a good joke, so it&apos;s only fair
that they should pay for the privilege.&quot;
</blockquote>
<h3 className="mt-8 scroll-m-20 text-2xl font-semibold tracking-tight">The Joke Tax</h3>
<p className="leading-7 [&:not(:first-child)]:mt-6">
The king's subjects were not amused. They grumbled and complained, but the king was firm:
The king&apos;s subjects were not amused. They grumbled and complained, but the king was
firm:
</p>
<ul className="my-6 ml-6 list-disc [&>li]:mt-2">
<li>1st level of puns: 5 gold coins</li>
@@ -34,22 +35,24 @@ export default function TypographyDemo() {
</ul>
<p className="leading-7 [&:not(:first-child)]:mt-6">
As a result, people stopped telling jokes, and the kingdom fell into a gloom. But there was
one person who refused to let the king's foolishness get him down: a court jester named
one person who refused to let the king&apos;s foolishness get him down: a court jester named
Jokester.
</p>
<h3 className="mt-8 scroll-m-20 text-2xl font-semibold tracking-tight">Jokester's Revolt</h3>
<h3 className="mt-8 scroll-m-20 text-2xl font-semibold tracking-tight">
Jokester&apos;s Revolt
</h3>
<p className="leading-7 [&:not(:first-child)]:mt-6">
Jokester began sneaking into the castle in the middle of the night and leaving jokes all
over the place: under the king's pillow, in his soup, even in the royal toilet. The king was
furious, but he couldn't seem to stop Jokester.
over the place: under the king&apos;s pillow, in his soup, even in the royal toilet. The
king was furious, but he couldn&apos;t seem to stop Jokester.
</p>
<p className="leading-7 [&:not(:first-child)]:mt-6">
And then, one day, the people of the kingdom discovered that the jokes left by Jokester were
so funny that they couldn't help but laugh. And once they started laughing, they couldn't
stop.
so funny that they couldn&apos;t help but laugh. And once they started laughing, they
couldn&apos;t stop.
</p>
<h3 className="mt-8 scroll-m-20 text-2xl font-semibold tracking-tight">
The People's Rebellion
The People&apos;s Rebellion
</h3>
<p className="leading-7 [&:not(:first-child)]:mt-6">
The people of the kingdom, feeling uplifted by the laughter, started to tell jokes and puns
@@ -60,10 +63,10 @@ export default function TypographyDemo() {
<thead>
<tr className="m-0 border-t p-0 even:bg-muted">
<th className="border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right">
King's Treasury
King&apos;s Treasury
</th>
<th className="border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right">
People's happiness
People&apos;s happiness
</th>
</tr>
</thead>
@@ -5,10 +5,10 @@ export default function TypographyTable() {
<thead>
<tr className="m-0 border-t p-0 even:bg-muted">
<th className="border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right">
King's Treasury
King&apos;s Treasury
</th>
<th className="border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right">
People's happiness
People&apos;s happiness
</th>
</tr>
</thead>
+40
View File
@@ -1032,6 +1032,46 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/form-item-layout-demo.tsx'],
},
{
name: 'form-patterns-pagelayout',
type: 'components:example',
registryDependencies: [
'form',
'card',
'input',
'button',
'select',
'switch',
'checkbox',
'textarea',
'radio-group',
'calendar',
'popover',
'multi-select',
],
files: ['example/form-patterns-pagelayout.tsx'],
dependencies: ['date-fns'],
},
{
name: 'form-patterns-sidepanel',
type: 'components:example',
registryDependencies: [
'form',
'sheet',
'input',
'button',
'select',
'switch',
'checkbox',
'textarea',
'radio-group',
'calendar',
'popover',
'multi-select',
],
files: ['example/form-patterns-sidepanel.tsx'],
dependencies: ['date-fns'],
},
{
name: 'form-item-layout-with-select',
type: 'components:example',
+2
View File
@@ -1,10 +1,12 @@
import * as Sentry from '@sentry/nextjs'
export async function register() {
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config')
}
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config')
}
+3
View File
@@ -16,6 +16,7 @@ Amy Q
Andrew Valleteau
Andrey A
Angelico de los Reyes
Anna Weihl
Ant Wilson
Ariuna K
Artur Zakirov
@@ -126,6 +127,7 @@ Monica Khoury
Mykhailo Mischa Lieibenson
Nick B
Nick Littman
Nick Nowlan
Nyannyacha
Oli R
Pamela Chia
@@ -165,6 +167,7 @@ Taryn King
Terry Sutton
Thomas E
Thor Schaeff
Tim Frietas
Timothy Lim
Tom Ashley
Tom G
+575 -10
View File
@@ -1,9 +1,10 @@
{
"rules": {
"react-hooks/exhaustive-deps": 230,
"react-hooks/exhaustive-deps": 226,
"import/no-anonymous-default-export": 62,
"@tanstack/query/exhaustive-deps": 19,
"@tanstack/query/no-deprecated-options": 0
"@tanstack/query/exhaustive-deps": 18,
"@tanstack/query/no-deprecated-options": 0,
"@typescript-eslint/no-explicit-any": 1452
},
"ruleFiles": {
"react-hooks/exhaustive-deps": {
@@ -24,7 +25,6 @@
"components/interfaces/Auth/BasicAuthSettingsForm.tsx": 1,
"components/interfaces/Auth/Hooks/CreateHookSheet.tsx": 1,
"components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx": 1,
"components/interfaces/Auth/OAuthApps/CreateOAuthAppSheet.tsx": 1,
"components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx": 1,
"components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx": 1,
"components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx": 1,
@@ -101,14 +101,12 @@
"components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CopyEnvButton.tsx": 1,
"components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx": 1,
"components/interfaces/Storage/StorageSettings/StorageSettings.tsx": 1,
"components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx": 1,
"components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/QuickstartAIWidget.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/useAITableGeneration.ts": 2,
"components/interfaces/TableGridEditor/TableDefinition.tsx": 1,
@@ -168,14 +166,13 @@
"pages/project/[ref]/editor/index.tsx": 1,
"pages/project/[ref]/integrations/[id]/[pageId]/index.tsx": 1,
"pages/project/[ref]/logs/explorer/index.tsx": 1,
"pages/project/[ref]/settings/jwt/index.tsx": 1,
"pages/project/[ref]/settings/jwt/legacy.tsx": 1,
"pages/project/[ref]/sql/quickstarts.tsx": 1,
"pages/project/[ref]/sql/templates.tsx": 1,
"pages/sign-in-fly-tos.tsx": 1,
"pages/sign-in-mfa.tsx": 1,
"state/role-impersonation-state.tsx": 1,
"state/sidebar-manager-state.tsx": 1,
"state/storage-explorer.tsx": 1,
"state/table-editor-table.tsx": 1
},
"import/no-anonymous-default-export": {
@@ -245,7 +242,7 @@
"@tanstack/query/exhaustive-deps": {
"components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants.ts": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx": 3,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx": 2,
"data/branches/branch-diff-query.ts": 1,
"data/database/foreign-key-constraints-query.ts": 1,
"data/database/schemas-query.ts": 1,
@@ -258,6 +255,574 @@
"hooks/analytics/useProjectUsageStats.tsx": 1,
"hooks/analytics/useSingleLog.tsx": 1
},
"@tanstack/query/no-deprecated-options": {}
"@tanstack/query/no-deprecated-options": {},
"@typescript-eslint/no-explicit-any": {
"components/grid/SupabaseGrid.tsx": 1,
"components/grid/SupabaseGrid.utils.ts": 4,
"components/grid/components/common/Hooks.tsx": 2,
"components/grid/components/common/MonacoEditor.tsx": 3,
"components/grid/components/editor/BooleanEditor.tsx": 1,
"components/grid/components/editor/JsonEditor.tsx": 2,
"components/grid/components/editor/SelectEditor.tsx": 1,
"components/grid/components/editor/TextEditor.tsx": 2,
"components/grid/components/formatter/ReferenceRecordPeek.tsx": 4,
"components/grid/components/grid/AddColumn.tsx": 2,
"components/grid/components/grid/Grid.tsx": 10,
"components/grid/components/grid/Grid.utils.tsx": 2,
"components/grid/components/grid/GridError.tsx": 4,
"components/grid/components/grid/SelectColumn.tsx": 3,
"components/grid/components/header/Header.utils.ts": 1,
"components/grid/components/header/sort/SortRow.tsx": 1,
"components/grid/components/menu/ColumnMenu.tsx": 1,
"components/grid/types/base.ts": 2,
"components/grid/types/table.ts": 1,
"components/grid/utils/column.ts": 2,
"components/grid/utils/common.ts": 1,
"components/grid/utils/gridColumns.tsx": 10,
"components/interfaces/Account/AccessTokens/NewAccessTokenButton.tsx": 1,
"components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx": 1,
"components/interfaces/Auth/AdvancedAuthSettingsForm.tsx": 2,
"components/interfaces/Auth/AuditLogsForm.tsx": 1,
"components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts": 1,
"components/interfaces/Auth/AuthProvidersForm/FormField.tsx": 4,
"components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx": 6,
"components/interfaces/Auth/AuthProvidersForm/index.tsx": 5,
"components/interfaces/Auth/AuthProvidersFormValidation.tsx": 2,
"components/interfaces/Auth/BasicAuthSettingsForm.tsx": 1,
"components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx": 1,
"components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx": 1,
"components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx": 3,
"components/interfaces/Auth/Overview/OverviewTable.tsx": 1,
"components/interfaces/Auth/Overview/OverviewUsage.constants.ts": 1,
"components/interfaces/Auth/Policies/Policies.tsx": 1,
"components/interfaces/Auth/Policies/Policies.utils.ts": 1,
"components/interfaces/Auth/Policies/PolicyEditor/PolicyRoles.tsx": 1,
"components/interfaces/Auth/Policies/PolicyEditor/index.tsx": 2,
"components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx": 2,
"components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx": 1,
"components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx": 2,
"components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx": 2,
"components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx": 1,
"components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx": 1,
"components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx": 2,
"components/interfaces/Auth/SiteUrl/SiteUrl.tsx": 1,
"components/interfaces/Auth/SmtpForm/SmtpForm.tsx": 4,
"components/interfaces/Auth/Users/CreateUserModal.tsx": 1,
"components/interfaces/Auth/Users/InviteUserModal.tsx": 3,
"components/interfaces/Auth/Users/UserLogs.tsx": 1,
"components/interfaces/Auth/Users/UserPanel.tsx": 2,
"components/interfaces/Auth/Users/Users.utils.tsx": 5,
"components/interfaces/Auth/Users/UsersGridComponents.tsx": 2,
"components/interfaces/Auth/Users/UsersV2.tsx": 6,
"components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx": 3,
"components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx": 3,
"components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx": 3,
"components/interfaces/BranchManagement/Branch.Commands.tsx": 1,
"components/interfaces/BranchManagement/DatabaseDiffPanel.tsx": 1,
"components/interfaces/BranchManagement/EmptyStates.tsx": 1,
"components/interfaces/BranchManagement/ReviewWithAI.tsx": 1,
"components/interfaces/Connect/ConnectTabs.tsx": 1,
"components/interfaces/Database/Backups/PITR/PITR.utils.ts": 1,
"components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx": 1,
"components/interfaces/Database/EnumeratedTypes/CreateEnumeratedTypeSidePanel.tsx": 2,
"components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx": 1,
"components/interfaces/Database/EnumeratedTypes/EditEnumeratedTypeSidePanel.tsx": 2,
"components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx": 1,
"components/interfaces/Database/Extensions/EnableExtensionModal.tsx": 4,
"components/interfaces/Database/Functions/CreateFunction/FunctionEditor.tsx": 1,
"components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx": 3,
"components/interfaces/Database/Hooks/EditHookPanel.tsx": 9,
"components/interfaces/Database/Hooks/FormContents.tsx": 5,
"components/interfaces/Database/Hooks/HTTPRequestFields.tsx": 6,
"components/interfaces/Database/Hooks/HooksList/HooksList.tsx": 2,
"components/interfaces/Database/Indexes/Indexes.tsx": 1,
"components/interfaces/Database/Migrations/Migrations.tsx": 2,
"components/interfaces/Database/Privileges/PrivilegesTable.tsx": 1,
"components/interfaces/Database/Publications/PublicationsList.tsx": 5,
"components/interfaces/Database/Publications/PublicationsTableItem.tsx": 5,
"components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx": 8,
"components/interfaces/Database/Replication/PublicationsComboBox.tsx": 1,
"components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx": 1,
"components/interfaces/Database/Replication/RetryOptionsDropdown.tsx": 1,
"components/interfaces/Database/Replication/RowMenu.tsx": 2,
"components/interfaces/Database/Replication/UpdateVersionModal.tsx": 2,
"components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx": 1,
"components/interfaces/Database/Roles/RoleRow.tsx": 4,
"components/interfaces/Database/Roles/RolesList.tsx": 1,
"components/interfaces/Database/Schemas/SchemaGraph.tsx": 1,
"components/interfaces/Database/Tables/ColumnList.tsx": 2,
"components/interfaces/DiskManagement/DiskManagement.utils.ts": 3,
"components/interfaces/Docs/Description.tsx": 2,
"components/interfaces/Docs/GeneratingTypes.tsx": 1,
"components/interfaces/Docs/Param.tsx": 1,
"components/interfaces/Docs/ResourceContent.tsx": 1,
"components/interfaces/Docs/RpcContent.tsx": 4,
"components/interfaces/Docs/Snippets.ts": 1,
"components/interfaces/Functions/CommandRender.tsx": 4,
"components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx": 1,
"components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx": 3,
"components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx": 1,
"components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx": 1,
"components/interfaces/Functions/EdgeFunctionsListItem.tsx": 2,
"components/interfaces/Functions/FunctionsNav.tsx": 1,
"components/interfaces/Home/Home.tsx": 6,
"components/interfaces/Home/NewProjectPanel/APIKeys.tsx": 1,
"components/interfaces/Home/ProjectList/ProjectList.tsx": 1,
"components/interfaces/Home/ProjectUsage.tsx": 1,
"components/interfaces/Home/ServiceStatus.tsx": 1,
"components/interfaces/HomeNew/ProjectUsageSection.tsx": 1,
"components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx": 1,
"components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx": 2,
"components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx": 1,
"components/interfaces/Integrations/CronJobs/CronJobsTab.tsx": 1,
"components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx": 2,
"components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx": 1,
"components/interfaces/Integrations/Landing/Integrations.constants.tsx": 1,
"components/interfaces/Integrations/Queues/QueuesSettings.tsx": 1,
"components/interfaces/Integrations/Queues/SingleQueue/MessageDetailsPanel.tsx": 1,
"components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx": 1,
"components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx": 2,
"components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx": 6,
"components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx": 1,
"components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx": 3,
"components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx": 6,
"components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx": 7,
"components/interfaces/Integrations/Wrappers/InputField.tsx": 1,
"components/interfaces/Integrations/Wrappers/WrapperDynamicColumns.tsx": 1,
"components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx": 7,
"components/interfaces/Integrations/Wrappers/Wrappers.utils.ts": 2,
"components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx": 2,
"components/interfaces/JwtSecrets/jwt-settings.tsx": 5,
"components/interfaces/Linter/LinterDataGrid.tsx": 4,
"components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx": 2,
"components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx": 2,
"components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils.ts": 2,
"components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx": 2,
"components/interfaces/Organization/BillingSettings/CreditTopUp.tsx": 2,
"components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx": 1,
"components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx": 2,
"components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx": 1,
"components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx": 6,
"components/interfaces/Organization/BillingSettings/Subscription/UpgradeModal.tsx": 3,
"components/interfaces/Organization/Documents/SOC2.tsx": 1,
"components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx": 1,
"components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx": 3,
"components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx": 1,
"components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx": 1,
"components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx": 1,
"components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx": 9,
"components/interfaces/Organization/ProjectClaim/confirm.tsx": 1,
"components/interfaces/Organization/TeamSettings/MemberRow.tsx": 2,
"components/interfaces/Organization/TeamSettings/TeamSettings.tsx": 1,
"components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx": 2,
"components/interfaces/Organization/Usage/Usage.tsx": 1,
"components/interfaces/Organization/Usage/Usage.utils.ts": 1,
"components/interfaces/Organization/Usage/UsageChartTooltips.tsx": 7,
"components/interfaces/ProjectAPIDocs/Content/Entities.tsx": 1,
"components/interfaces/ProjectAPIDocs/Content/Entity.tsx": 1,
"components/interfaces/ProjectAPIDocs/Content/RPC.tsx": 1,
"components/interfaces/ProjectAPIDocs/ProjectAPIDocs.constants.ts": 2,
"components/interfaces/ProjectAPIDocs/ResourceContent.tsx": 1,
"components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx": 1,
"components/interfaces/ProjectCreation/DatabasePasswordInput.tsx": 1,
"components/interfaces/ProjectCreation/PostgresVersionSelector.tsx": 2,
"components/interfaces/ProjectCreation/SchemaGenerator.tsx": 1,
"components/interfaces/QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton.tsx": 1,
"components/interfaces/QueryPerformance/IndexAdvisor/IndexAdvisorDisabledState.tsx": 1,
"components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils.ts": 2,
"components/interfaces/QueryPerformance/QueryDetail.tsx": 1,
"components/interfaces/QueryPerformance/QueryIndexes.tsx": 1,
"components/interfaces/QueryPerformance/QueryPerformance.tsx": 1,
"components/interfaces/QueryPerformance/QueryPerformanceChart.tsx": 3,
"components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx": 3,
"components/interfaces/QueryPerformance/WithMonitor/WithMonitor.utils.ts": 2,
"components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx": 2,
"components/interfaces/QueryPerformance/WithStatements/WithStatements.utils.ts": 1,
"components/interfaces/Realtime/Inspector/Messages.types.ts": 1,
"components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx": 1,
"components/interfaces/Realtime/Inspector/useRealtimeMessages.ts": 10,
"components/interfaces/Realtime/RealtimeSettings.tsx": 2,
"components/interfaces/Reports/GridResize.tsx": 2,
"components/interfaces/Reports/MetricOptions.tsx": 1,
"components/interfaces/Reports/ReportBlock/ChartBlock.tsx": 1,
"components/interfaces/Reports/ReportWidget.tsx": 2,
"components/interfaces/Reports/Reports.tsx": 3,
"components/interfaces/Reports/Reports.types.ts": 1,
"components/interfaces/Reports/Reports.utils.tsx": 10,
"components/interfaces/Reports/SharedAPIReport/SharedAPIReport.tsx": 3,
"components/interfaces/Reports/v2/ReportChartUpsell.tsx": 1,
"components/interfaces/Reports/v2/ReportChartV2.test.tsx": 4,
"components/interfaces/Reports/v2/ReportChartV2.tsx": 8,
"components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx": 1,
"components/interfaces/SQLEditor/MonacoEditor.tsx": 2,
"components/interfaces/SQLEditor/MoveQueryModal.tsx": 2,
"components/interfaces/SQLEditor/RenameQueryModal.tsx": 5,
"components/interfaces/SQLEditor/SQLEditor.tsx": 4,
"components/interfaces/SQLEditor/SQLEditor.utils.ts": 1,
"components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts.tsx": 1,
"components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx": 1,
"components/interfaces/SQLEditor/UtilityPanel/CellDetailPanel.tsx": 1,
"components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx": 2,
"components/interfaces/SQLEditor/UtilityPanel/Results.tsx": 7,
"components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx": 1,
"components/interfaces/SQLEditor/hooks.ts": 1,
"components/interfaces/SQLEditor/useAddDefinitions.ts": 1,
"components/interfaces/SchemaVisualizer/index.tsx": 1,
"components/interfaces/Settings/Addons/Addons.tsx": 1,
"components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx": 1,
"components/interfaces/Settings/Addons/IPv4SidePanel.tsx": 1,
"components/interfaces/Settings/Addons/PITRSidePanel.tsx": 1,
"components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx": 2,
"components/interfaces/Settings/Database/DiskSizeConfigurationModal.tsx": 1,
"components/interfaces/Settings/Database/NetworkRestrictions/AddRestrictionModal.tsx": 5,
"components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.utils.ts": 2,
"components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx": 1,
"components/interfaces/Settings/General/General.tsx": 3,
"components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx": 1,
"components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx": 1,
"components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx": 1,
"components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx": 1,
"components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/RestartReplicaConfirmationModal.tsx": 1,
"components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx": 1,
"components/interfaces/Settings/Logs/LogTable.tsx": 3,
"components/interfaces/Settings/Logs/Logs.constants.ts": 1,
"components/interfaces/Settings/Logs/Logs.types.ts": 1,
"components/interfaces/Settings/Logs/Logs.utils.ts": 11,
"components/interfaces/Settings/Logs/LogsQueryPanel.tsx": 2,
"components/interfaces/Settings/Logs/PreviewFilterPanel.tsx": 1,
"components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx": 3,
"components/interfaces/Settings/Logs/SidebarV2/SidebarItem.tsx": 1,
"components/interfaces/Sidebar.tsx": 3,
"components/interfaces/SignIn/SignInForm.tsx": 1,
"components/interfaces/SignIn/SignInWithCustom.tsx": 1,
"components/interfaces/SignIn/SignInWithGitHub.tsx": 1,
"components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx": 2,
"components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/InitializeForeignSchemaDialog.tsx": 1,
"components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx": 4,
"components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx": 1,
"components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateForeignSchemaDialog.tsx": 1,
"components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx": 1,
"components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx": 4,
"components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx": 1,
"components/interfaces/Storage/CreateBucketModal.tsx": 1,
"components/interfaces/Storage/DeleteBucketModal.tsx": 1,
"components/interfaces/Storage/ImportForeignSchemaDialog.tsx": 1,
"components/interfaces/Storage/Storage.utils.ts": 7,
"components/interfaces/Storage/StorageBucketsError.tsx": 1,
"components/interfaces/Storage/StorageExplorer/CustomExpiryModal.tsx": 5,
"components/interfaces/Storage/StorageExplorer/FileExplorer.tsx": 2,
"components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx": 6,
"components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx": 6,
"components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx": 1,
"components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx": 2,
"components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx": 1,
"components/interfaces/Storage/StorageExplorer/MoveItemsModal.tsx": 1,
"components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx": 1,
"components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx": 2,
"components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx": 8,
"components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx": 11,
"components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx": 2,
"components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx": 3,
"components/interfaces/Storage/StorageSettings/StorageSettings.tsx": 2,
"components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx": 2,
"components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx": 3,
"components/interfaces/Support/AttachmentUpload.tsx": 3,
"components/interfaces/Support/LinkSupportTicketForm.tsx": 6,
"components/interfaces/Support/SupportFormV2.tsx": 1,
"components/interfaces/Support/__tests__/SupportFormPage.test.tsx": 19,
"components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx": 5,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx": 9,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx": 9,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownViewer.tsx": 3,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx": 3,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx": 9,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.test.ts": 8,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts": 9,
"components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx": 14,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.types.ts": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.test.ts": 6,
"components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx": 14,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadSheetFileUpload.tsx": 3,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadSheetTextInput.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx": 4,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.types.ts": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.utils.tsx": 18,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetPreviewGrid.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx": 3,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx": 3,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeyRow.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx": 2,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types.ts": 1,
"components/interfaces/TableGridEditor/TableDefinition.tsx": 3,
"components/interfaces/TableGridEditor/TableEntity.utils.ts": 1,
"components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceFlowHeader.tsx": 1,
"components/interfaces/UnifiedLogs/ServiceFlow/components/blocks/ResponseCompletedBlock.tsx": 2,
"components/interfaces/UnifiedLogs/ServiceFlow/components/shared/Block.tsx": 1,
"components/interfaces/UnifiedLogs/ServiceFlow/components/shared/CollapsibleSection.tsx": 4,
"components/interfaces/UnifiedLogs/ServiceFlow/components/shared/FieldWithSeeMore.tsx": 4,
"components/interfaces/UnifiedLogs/ServiceFlow/components/shared/TimelineStep.tsx": 1,
"components/interfaces/UnifiedLogs/ServiceFlow/config/fieldHelpers.ts": 3,
"components/interfaces/UnifiedLogs/ServiceFlow/types.ts": 10,
"components/interfaces/UnifiedLogs/ServiceFlow/utils/storageUtils.ts": 3,
"components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts": 5,
"components/interfaces/UnifiedLogs/UnifiedLogs.tsx": 8,
"components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts": 1,
"components/interfaces/UnifiedLogs/components/DownloadLogsButton.tsx": 1,
"components/interfaces/UnifiedLogs/components/LogsListPanel.tsx": 1,
"components/layouts/AccountLayout/WithSidebar.tsx": 3,
"components/layouts/DocsLayout/DocsLayout.tsx": 2,
"components/layouts/IntegrationsLayout/Integrations.utils.ts": 2,
"components/layouts/LogsLayout/LogsSidebarMenuV2.tsx": 1,
"components/layouts/ObservabilityLayout/ObservabilityMenu.tsx": 1,
"components/layouts/ProjectLayout/BuildingState.tsx": 1,
"components/layouts/ProjectLayout/LayoutHeader/BreadcrumbsView.tsx": 2,
"components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx": 3,
"components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx": 1,
"components/layouts/ProjectLayout/NavigationBar/NavigationIconLink.tsx": 1,
"components/layouts/ReportsLayout/ReportsMenu.tsx": 1,
"components/layouts/SQLEditorLayout/SQLEditorMenu.tsx": 2,
"components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx": 3,
"components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts": 2,
"components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx": 2,
"components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx": 1,
"components/layouts/TableEditorLayout/EntityListItem.tsx": 3,
"components/layouts/TableEditorLayout/TableEditorMenu.tsx": 2,
"components/layouts/Tabs/NewTab.tsx": 1,
"components/to-be-cleaned/KeyMap.tsx": 3,
"components/ui/AIAssistantPanel/AIAssistant.utils.ts": 1,
"components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx": 1,
"components/ui/AIAssistantPanel/Message.Parts.tsx": 1,
"components/ui/AIAssistantPanel/MessageMarkdown.tsx": 1,
"components/ui/AIEditor/index.tsx": 2,
"components/ui/CardButton.tsx": 2,
"components/ui/Charts/AreaChart.tsx": 1,
"components/ui/Charts/BarChart.tsx": 2,
"components/ui/Charts/ChartHandler.tsx": 4,
"components/ui/Charts/ChartHeader.tsx": 5,
"components/ui/Charts/Charts.constants.ts": 1,
"components/ui/Charts/Charts.utils.tsx": 3,
"components/ui/Charts/ComposedChart.tsx": 10,
"components/ui/Charts/ComposedChart.utils.tsx": 13,
"components/ui/Charts/ComposedChartHandler.tsx": 9,
"components/ui/Charts/StackedBarChart.tsx": 3,
"components/ui/Charts/useChartHighlight.tsx": 9,
"components/ui/Charts/useChartHoverState.test.tsx": 1,
"components/ui/CodeEditor/CodeEditor.tsx": 8,
"components/ui/CodeEditor/CodeEditor.utils.ts": 2,
"components/ui/CodeEditor/Providers/BackwardIterator.ts": 1,
"components/ui/CodeEditor/Providers/PgSQLCompletionProvider.ts": 21,
"components/ui/CodeEditor/Providers/PgSQLSignatureHelpProvider.ts": 4,
"components/ui/DataTable/DataTable.utils.ts": 8,
"components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx": 1,
"components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts": 1,
"components/ui/DataTable/DataTableInfinite.tsx": 6,
"components/ui/DataTable/DataTableViewOptions.tsx": 1,
"components/ui/DataTable/LiveButton.tsx": 1,
"components/ui/DataTable/providers/DataTableProvider.tsx": 3,
"components/ui/DatePicker/DatePicker.types.ts": 2,
"components/ui/DatePicker/index.tsx": 2,
"components/ui/DateRangePicker.tsx": 1,
"components/ui/DebouncedComponent.tsx": 1,
"components/ui/DownloadResultsButton.tsx": 2,
"components/ui/Error.tsx": 1,
"components/ui/ErrorBoundary/ErrorBoundary.tsx": 2,
"components/ui/ErrorBoundary/GlobalErrorBoundaryState.tsx": 1,
"components/ui/FilterPopover.tsx": 1,
"components/ui/Forms/Form.constants.ts": 1,
"components/ui/InfiniteList.tsx": 1,
"components/ui/Logs/LogsExplorerHeader.tsx": 2,
"components/ui/PasswordStrengthBar.tsx": 4,
"components/ui/ProductMenu/ProductMenu.types.ts": 1,
"components/ui/ProjectSettings/DisplayApiSettings.tsx": 1,
"components/ui/QueryBlock/QueryBlock.tsx": 4,
"components/ui/QueryBlock/QueryBlock.utils.ts": 1,
"components/ui/SchemaComboBox.tsx": 1,
"components/ui/SchemaSelector.tsx": 1,
"components/ui/SqlEditor.tsx": 6,
"components/ui/TwoOptionToggle.tsx": 4,
"components/ui/ui.types.ts": 1,
"data/ai/check-api-key-query.ts": 1,
"data/ai/rate-message-mutation.ts": 1,
"data/ai/sql-cron-mutation.ts": 1,
"data/ai/sql-title-mutation.ts": 1,
"data/analytics/functions-combined-stats-query.ts": 1,
"data/analytics/functions-req-stats-query.ts": 1,
"data/analytics/functions-resource-usage-query.ts": 1,
"data/analytics/infra-monitoring-queries.ts": 1,
"data/analytics/infra-monitoring-query.ts": 1,
"data/analytics/project-daily-stats-queries.ts": 1,
"data/analytics/project-metrics-query.ts": 3,
"data/api-settings/create-and-expose-api-schema-mutation.ts": 1,
"data/auth/auth-overview-query.ts": 1,
"data/auth/session-access-token-query.ts": 1,
"data/config/project-disk-resize-mutation.ts": 1,
"data/config/project-storage-config-query.ts": 2,
"data/content/content-insert-mutation.ts": 1,
"data/custom-domains/custom-domains-query.ts": 1,
"data/database-columns/database-column-create-mutation.ts": 1,
"data/database-queues/database-queues-metrics-query.ts": 1,
"data/database-triggers/database-triggers-query.ts": 2,
"data/database/table-columns-query.ts": 1,
"data/docs/project-json-schema-query.ts": 1,
"data/documents/dpa-request-mutation.ts": 1,
"data/edge-functions/edge-function-test-mutation.ts": 1,
"data/edge-functions/edge-functions-deploy-mutation.ts": 2,
"data/fdw/fdw-create-mutation.ts": 1,
"data/fdw/fdw-update-mutation.ts": 1,
"data/fetchers.ts": 12,
"data/foreign-tables/foreign-tables-query.ts": 1,
"data/graphql/fragment-masking.ts": 29,
"data/graphql/gql.ts": 1,
"data/graphql/graphql.ts": 2,
"data/integrations/integrations.types.ts": 1,
"data/invoices/invoices-count-query.ts": 1,
"data/jwt-signing-keys/jwt-signing-key-create-mutation.ts": 1,
"data/log-drains/create-log-drain-mutation.ts": 1,
"data/log-drains/update-log-drain-mutation.ts": 1,
"data/logs/get-unified-logs.ts": 1,
"data/logs/unified-log-inspection-query.ts": 2,
"data/logs/unified-logs-chart-query.ts": 1,
"data/logs/unified-logs-count-query.ts": 1,
"data/logs/unified-logs-infinite-query.ts": 2,
"data/materialized-views/materialized-views-query.ts": 1,
"data/network-restrictions/network-restrictions-query.ts": 3,
"data/notifications/keys.ts": 1,
"data/notifications/notifications-v2-query.ts": 2,
"data/oauth-secrets/client-secret-create-mutation.ts": 2,
"data/open-api/api-spec-query.ts": 8,
"data/organization-members/organization-roles-query.ts": 1,
"data/organizations/organization-create-mutation.ts": 2,
"data/organizations/organization-customer-profile-update-mutation.ts": 2,
"data/organizations/organization-payment-method-default-mutation.ts": 2,
"data/organizations/organization-update-mutation.ts": 1,
"data/platform/platform-status-query.ts": 1,
"data/profile/profile-identities-query.ts": 1,
"data/profile/profile-unlink-identity-mutation.ts": 2,
"data/projects/project-create-mutation.ts": 1,
"data/projects/project-detail-query.ts": 2,
"data/replication/restart-pipeline-helper.ts": 2,
"data/replication/rollback-table-mutation.ts": 1,
"data/replication/use-table-reset.ts": 1,
"data/reports/database-charts.ts": 5,
"data/reports/v2/auth.config.ts": 16,
"data/reports/v2/edge-functions.config.ts": 9,
"data/reports/v2/edge-functions.test.tsx": 2,
"data/reports/v2/reports.types.ts": 2,
"data/sql/execute-sql-query.ts": 5,
"data/ssl-enforcement/ssl-enforcement-query.ts": 2,
"data/ssl-enforcement/ssl-enforcement-update-mutation.ts": 1,
"data/sso/sso-config-query.ts": 2,
"data/storage/analytics-bucket-delete-mutation.ts": 1,
"data/storage/bucket-delete-mutation.ts": 1,
"data/storage/bucket-update-mutation.ts": 3,
"data/subscriptions/org-subscription-confirm-pending-create.ts": 1,
"data/subscriptions/org-subscription-update-mutation.ts": 2,
"data/support/generate-attachment-urls-mutation.ts": 1,
"data/table-rows/get-cell-value-mutation.ts": 1,
"data/table-rows/keys.ts": 2,
"data/table-rows/table-row-create-mutation.ts": 1,
"data/table-rows/table-row-update-mutation.ts": 2,
"data/table-rows/table-rows-query.ts": 4,
"data/tables/tables-query.ts": 1,
"data/vault/vault-secret-decrypted-value-query.ts": 2,
"data/views/views-query.ts": 1,
"hooks/analytics/useDbQuery.tsx": 1,
"hooks/analytics/useFillTimeseriesSorted.ts": 3,
"hooks/analytics/useLogsPreview.tsx": 1,
"hooks/analytics/useLogsQuery.tsx": 1,
"hooks/analytics/useLogsUrlState.ts": 1,
"hooks/analytics/useSingleLog.tsx": 1,
"hooks/analytics/useTimeseriesUnixToIso.ts": 1,
"hooks/branches/useBranchMergeDiff.ts": 3,
"hooks/misc/withAuth.tsx": 1,
"hooks/storage/useStoragePolicyCounts.ts": 2,
"hooks/ui/useClickedOutside.ts": 2,
"hooks/ui/useFlag.ts": 1,
"hooks/ui/useHotKey.ts": 1,
"instrumentation-client.ts": 5,
"lib/ai/model.ts": 2,
"lib/ai/model.utils.ts": 4,
"lib/ai/test-fixtures.ts": 1,
"lib/ai/tool-filter.test.ts": 5,
"lib/ai/tool-filter.ts": 4,
"lib/api/apiAuthenticate.test.ts": 2,
"lib/api/apiHelpers.test.ts": 1,
"lib/api/apiHelpers.ts": 2,
"lib/api/apiWrappers.test.ts": 2,
"lib/api/generate-v4.test.ts": 2,
"lib/api/rate.test.ts": 2,
"lib/api/self-hosted/logs.ts": 2,
"lib/gotrue.ts": 1,
"lib/helpers.test.ts": 10,
"lib/helpers.ts": 12,
"lib/integration-utils.test.ts": 1,
"lib/integration-utils.ts": 2,
"lib/password-strength.ts": 1,
"lib/pg-format.ts": 6,
"lib/pingPostgrest.test.ts": 1,
"lib/posthog.test.ts": 4,
"lib/profile.tsx": 1,
"lib/role-impersonation.test.ts": 5,
"lib/role-impersonation.ts": 1,
"lib/sanitize.test.ts": 10,
"lib/semver.test.ts": 3,
"lib/telemetry/track.ts": 1,
"lib/upload.test.ts": 1,
"pages/_app.tsx": 1,
"pages/account/tokens.tsx": 1,
"pages/api/ai/feedback/rate.ts": 2,
"pages/api/ai/sql/generate-v4.ts": 3,
"pages/api/check-cname.ts": 1,
"pages/api/edge-functions/test.ts": 1,
"pages/api/platform/auth/[ref]/users/[id]/factors.ts": 1,
"pages/api/platform/database/[ref]/pooling.ts": 1,
"pages/api/platform/integrations/github/authorization.ts": 1,
"pages/api/platform/projects/[ref]/analytics/log-drains.ts": 1,
"pages/api/platform/projects/[ref]/content/index.ts": 1,
"pages/api/platform/projects/[ref]/content/item/[id].ts": 1,
"pages/api/platform/projects/[ref]/databases.ts": 1,
"pages/cli/login.tsx": 1,
"pages/integrations/vercel/[slug]/deploy-button/new-project.tsx": 1,
"pages/integrations/vercel/[slug]/marketplace/choose-project.tsx": 1,
"pages/new/index.tsx": 1,
"pages/project/[ref]/api/index.tsx": 3,
"pages/project/[ref]/auth/templates/[templateId].tsx": 1,
"pages/project/[ref]/functions/[functionSlug]/index.tsx": 3,
"pages/project/[ref]/logs/explorer/index.tsx": 3,
"pages/project/[ref]/merge.tsx": 1,
"pages/project/[ref]/observability/database.tsx": 2,
"pages/project/[ref]/settings/log-drains.tsx": 1,
"pages/project/[ref]/storage/files/buckets/[bucketId].tsx": 1,
"scripts/__tests__/ratchet-eslint-rules.test.ts": 1,
"state/ai-assistant-state.tsx": 6,
"state/editor-panel-state.tsx": 4,
"state/role-impersonation-state.tsx": 1,
"state/sql-editor-v2.ts": 5,
"state/storage-explorer.tsx": 16,
"state/table-editor-table.tsx": 9,
"state/table-editor.tsx": 5,
"state/tabs.tsx": 1,
"tests/components/Editor/SpreadsheetImport.utils.test.ts": 1,
"tests/components/ui/Charts/Charts.utils.test.ts": 1,
"tests/features/logs/LogsPreviewer.test.tsx": 2,
"tests/features/logs/logs-query.test.tsx": 1,
"tests/lib/custom-render.tsx": 1,
"tests/setup/polyfills.ts": 3,
"tests/vitestSetup.ts": 1,
"types/form.ts": 1,
"types/next.ts": 2,
"types/ui.ts": 2
}
}
}
+3 -3
View File
@@ -34,7 +34,7 @@ Project settings are managed outside of the Dashboard. If you use docker compose
### Developer Quickstart
> [!NOTE]
> **Supabase internal use:** To develop on Studio locally with the backend services, see the instructions in the [internal `infrastructure` repo](https://github.com/supabase/infrastructure/blob/develop/docs/contributing.md).
> **Supabase internal use:** To develop on Studio locally with the backend services, see the instructions in the [internal `infrastructure` repo](https://github.com/supabase/platform/blob/develop/docs/contributing.md).
```bash
# You'll need to be on Node v20
@@ -45,8 +45,8 @@ pnpm install # install dependencies
pnpm run dev # start dev server
## For internal contributors
## First clone the private supabase/infrastructure repo and follow instructions for setting up mise
mise studio # Run from supabase/infrastructure alongside `mise infra`
## First clone the private supabase/platform repo and follow instructions for setting up mise
mise studio # Run from supabase/platform alongside `mise infra`
## For all
pnpm run test # run tests
+1 -3
View File
@@ -44,7 +44,7 @@ export const SupabaseGrid = ({
const gridRef = useRef<DataGridHandle>(null)
const [mounted, setMounted] = useState(false)
const { filters, onApplyFilters } = useTableFilter()
const { filters } = useTableFilter()
const { sorts, onApplySorts } = useTableSort()
const roleImpersonationState = useRoleImpersonationStateSnapshot()
@@ -104,8 +104,6 @@ export const SupabaseGrid = ({
isLoading={isLoading}
isSuccess={isSuccess}
isError={isError}
filters={filters}
onApplyFilters={onApplyFilters}
/>
<Footer enableForeignRowsQuery={tableQueriesEnabled} />
<Shortcuts gridRef={gridRef} rows={rows} />
@@ -1,10 +1,11 @@
import { parseAsString, useQueryState } from 'nuqs'
import { useParams } from 'common'
import { GridFooter } from 'components/ui/GridFooter'
import TwoOptionToggle from 'components/ui/TwoOptionToggle'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { isTableLike, isViewLike } from 'data/table-editor/table-editor-types'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useUrlState } from 'hooks/ui/useUrlState'
import { Pagination } from './pagination/Pagination'
type FooterProps = {
@@ -15,23 +16,13 @@ export const Footer: React.FC<FooterProps> = ({ enableForeignRowsQuery = true }:
const { id: _id } = useParams()
const id = _id ? Number(_id) : undefined
const { data: project } = useSelectedProjectQuery()
const [selectedView, setSelectedView] = useQueryState('view', parseAsString.withDefault('data'))
const { data: entity } = useTableEditorQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
id,
})
const [{ view: selectedView = 'data' }, setUrlState] = useUrlState()
const setSelectedView = (view: string) => {
if (view === 'data') {
setUrlState({ view: undefined })
} else {
setUrlState({ view })
}
}
const isViewSelected = isViewLike(entity)
const isTableSelected = isTableLike(entity)
@@ -2,6 +2,7 @@ import { forwardRef, memo, Ref, useRef } from 'react'
import DataGrid, { CalculatedColumn, DataGridHandle } from 'react-data-grid'
import { ref as valtioRef } from 'valtio'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import { handleCopyCell } from 'components/grid/SupabaseGrid.utils'
import { formatForeignKeys } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils'
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
@@ -14,7 +15,7 @@ import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import { Button, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import type { Filter, GridProps, SupaRow } from '../../types'
import type { GridProps, SupaRow } from '../../types'
import { useOnRowsChange } from './Grid.utils'
import { GridError } from './GridError'
import RowRenderer from './RowRenderer'
@@ -30,8 +31,6 @@ interface IGrid extends GridProps {
isLoading: boolean
isSuccess: boolean
isError: boolean
filters: Filter[]
onApplyFilters: (appliedFilters: Filter[]) => void
}
// [Joshen] Just for visibility this is causing some hook errors in the browser
@@ -50,13 +49,12 @@ export const Grid = memo(
isLoading,
isSuccess,
isError,
filters,
onApplyFilters,
},
ref: Ref<DataGridHandle> | undefined
) => {
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const { filters, onApplyFilters } = useTableFilter()
const { data: org } = useSelectedOrganizationQuery()
const { data: project } = useSelectedProjectQuery()
@@ -73,7 +73,7 @@ export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true
) : snap.selectedRows.size > 0 ? (
<RowHeader tableQueriesEnabled={tableQueriesEnabled} />
) : (
<DefaultHeader />
<DefaultHeader tableQueriesEnabled={tableQueriesEnabled} />
)}
<GridHeaderActions table={snap.originalTable} isRefetching={isRefetching} />
</div>
@@ -81,7 +81,9 @@ export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true
)
}
const DefaultHeader = () => {
const DefaultHeader = ({
tableQueriesEnabled = true,
}: Pick<HeaderProps, 'tableQueriesEnabled'>) => {
const { ref: projectRef } = useParams()
const { data: org } = useSelectedOrganizationQuery()
@@ -104,7 +106,7 @@ const DefaultHeader = () => {
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<FilterPopover />
<SortPopover />
<SortPopover tableQueriesEnabled={tableQueriesEnabled} />
</div>
{canAddNew && (
<>
@@ -231,7 +233,6 @@ type RowHeaderProps = {
}
const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
debugger
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
@@ -7,17 +7,26 @@ import { SortPopoverPrimitive } from './SortPopoverPrimitive'
export interface SortPopoverProps {
portal?: boolean
tableQueriesEnabled?: boolean
}
export const SortPopover = ({ portal = true }: SortPopoverProps) => {
export const SortPopover = ({ portal = true, tableQueriesEnabled }: SortPopoverProps) => {
const { urlSorts, onApplySorts } = useTableSort()
const tableState = useTableEditorTableStateSnapshot()
const tableName = tableState?.table?.name || ''
const snap = useTableEditorTableStateSnapshot()
const tableName = snap.table?.name || ''
// Convert string[] to Sort[]
const sorts = useMemo(() => {
return tableName && urlSorts ? formatSortURLParams(tableName, urlSorts) : []
}, [tableName, urlSorts])
return <SortPopoverPrimitive portal={portal} sorts={sorts} onApplySorts={onApplySorts} />
return (
<SortPopoverPrimitive
portal={portal}
sorts={sorts}
onApplySorts={onApplySorts}
tableQueriesEnabled={tableQueriesEnabled}
/>
)
}
@@ -1,8 +1,18 @@
import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/query/table-row-query'
import { isEqual } from 'lodash'
import { ChevronDown, List } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'common'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import type { Sort } from 'components/grid/types'
import { InlineLink } from 'components/ui/InlineLink'
import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import {
type RoleImpersonationState,
useRoleImpersonationStateSnapshot,
} from 'state/role-impersonation-state'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
@@ -11,6 +21,7 @@ import {
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { DropdownControl } from '../../common/DropdownControl'
import SortRow from './SortRow'
@@ -19,6 +30,8 @@ export interface SortPopoverPrimitiveProps {
sorts: Sort[]
onApplySorts: (sorts: Sort[]) => void
portal?: boolean
defaultOpen?: boolean
tableQueriesEnabled?: boolean
}
/**
@@ -35,10 +48,20 @@ export const SortPopoverPrimitive = ({
sorts,
onApplySorts,
portal = true,
defaultOpen = false,
tableQueriesEnabled = true,
}: SortPopoverPrimitiveProps) => {
const [open, setOpen] = useState(false)
const snap = useTableEditorTableStateSnapshot()
const { ref } = useParams()
const { filters } = useTableFilter()
const { data: project } = useSelectedProjectQuery()
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const tableName = snap.table?.name || ''
const tableSchema = snap.table.schema || ''
const [open, setOpen] = useState(defaultOpen)
const [showWarning, setShowWarning] = useState(false)
// Local state for draft sorts
const [localSorts, setLocalSorts] = useState<Sort[]>(sorts)
@@ -47,21 +70,18 @@ export const SortPopoverPrimitive = ({
// Track if we're in the middle of applying our own changes
const isApplyingRef = useRef(false)
// Sync with props when they change, but in a smarter way
useEffect(() => {
// If we're in the middle of applying changes, don't sync from props
if (isApplyingRef.current) {
isApplyingRef.current = false
return
}
// If the props changed unexpectedly (not due to our own actions)
// then we should update our local state
if (!isEqual(sorts, lastSortsRef.current)) {
setLocalSorts(sorts)
lastSortsRef.current = sorts
}
}, [sorts])
const { data: countData } = useTableRowsCountQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
tableId: snap.table.id,
filters,
enforceExactCount: snap.enforceExactCount,
roleImpersonationState: roleImpersonationState as RoleImpersonationState,
},
{ keepPreviousData: true, enabled: tableQueriesEnabled }
)
const isLargeTable = (countData?.count ?? 0) > THRESHOLD_COUNT
// Fix: Use localSorts for button text, not sorts
const displayButtonText =
@@ -180,67 +200,128 @@ export const SortPopoverPrimitive = ({
return `sort-${sort.table}-${sort.column}-${index}`
}
const content = (
<div className="space-y-2 py-2">
{localSorts.map((sort, index) => (
<SortRow
key={getSortRowKey(sort, index)}
index={index}
columnName={sort.column}
sort={sort}
onDelete={onDeleteSort}
onToggle={onToggleSort}
onDrag={onDragSort}
/>
))}
{localSorts.length === 0 && (
<div className="space-y-1 px-3">
<h5 className="text-xs text-foreground-light">No sorts applied to this view</h5>
<p className="text-xs text-foreground-lighter">Add a column below to sort the view</p>
</div>
)}
// Sync with props when they change, but in a smarter way
useEffect(() => {
// If we're in the middle of applying changes, don't sync from props
if (isApplyingRef.current) {
isApplyingRef.current = false
return
}
<PopoverSeparator_Shadcn_ />
<div className="px-3 flex flex-row justify-between">
{dropdownOptions && dropdownOptions.length > 0 ? (
<DropdownControl
options={dropdownOptions}
onSelect={onAddSort}
side="bottom"
align="start"
>
<Button
asChild
type="dashed"
iconRight={<ChevronDown size="14" className="text-foreground-light" />}
className="sb-grid-dropdown__item-trigger"
data-testid="table-editor-pick-column-to-sort-button"
>
<span>Pick {localSorts.length > 1 ? 'another' : 'a'} column to sort by</span>
</Button>
</DropdownControl>
) : (
<p className="text-sm text-foreground-light">All columns have been added</p>
)}
<div className="flex items-center">
<Button disabled={!hasChanges} type="default" onClick={onSelectApplySorts}>
Apply sorting
</Button>
</div>
</div>
</div>
)
// If the props changed unexpectedly (not due to our own actions)
// then we should update our local state
if (!isEqual(sorts, lastSortsRef.current)) {
setLocalSorts(sorts)
lastSortsRef.current = sorts
}
}, [sorts])
return (
<Popover_Shadcn_ modal={false} open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button type={localSorts.length > 0 ? 'link' : 'text'} icon={<List />}>
{displayButtonText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
{content}
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
<>
<Popover_Shadcn_ modal={false} open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button type={localSorts.length > 0 ? 'link' : 'text'} icon={<List />}>
{displayButtonText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
<div className="space-y-2 py-2">
{localSorts.map((sort, index) => (
<SortRow
key={getSortRowKey(sort, index)}
index={index}
columnName={sort.column}
sort={sort}
onDelete={onDeleteSort}
onToggle={onToggleSort}
onDrag={onDragSort}
/>
))}
{localSorts.length === 0 && (
<div className="space-y-1 px-3">
<h5 className="text-xs text-foreground-light">No sorts applied to this view</h5>
<p className="text-xs text-foreground-lighter">
Add a column below to sort the view
</p>
</div>
)}
<PopoverSeparator_Shadcn_ />
<div className="px-3 flex flex-row justify-between">
{dropdownOptions && dropdownOptions.length > 0 ? (
<DropdownControl
options={dropdownOptions}
onSelect={onAddSort}
side="bottom"
align="start"
>
<Button
asChild
type="dashed"
iconRight={<ChevronDown size="14" className="text-foreground-light" />}
className="sb-grid-dropdown__item-trigger"
data-testid="table-editor-pick-column-to-sort-button"
>
<span>Pick {localSorts.length > 1 ? 'another' : 'a'} column to sort by</span>
</Button>
</DropdownControl>
) : (
<p className="text-sm text-foreground-light">All columns have been added</p>
)}
<div className="flex items-center">
<Button
disabled={!hasChanges}
type="default"
onClick={() => {
if (isLargeTable && localSorts.length > 0) {
// [Joshen] Note we're only checking PKs - unable to check indexes properly
// as we are not able to deterministically figure out which columns the indexes are applied to
const hasSortNotPK = localSorts.some(
(x) => !snap.table.columns.find((y) => x.column === y.name)?.isPrimaryKey
)
if (hasSortNotPK) setShowWarning(true)
} else onSelectApplySorts()
}}
>
Apply sorting
</Button>
</div>
</div>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
<ConfirmationModal
size="medium"
variant="warning"
visible={showWarning}
confirmLabel="Confirm"
title="Sorting on a large table"
onConfirm={() => {
onSelectApplySorts()
setShowWarning(false)
}}
onCancel={() => {
setLocalSorts(sorts)
setShowWarning(false)
}}
alert={{
base: { variant: 'warning' },
title: 'Be careful with sorting on unindexed columns',
description:
'This may adversely impact your database, in particular if your table has a large number of rows - use with caution.',
}}
>
<p className="text-foreground-light text-sm">
We highly recommend only sorting on columns which are{' '}
<InlineLink
href={`/project/${ref}/database/indexes?search=${tableName}&schema=${tableSchema}`}
>
indexed
</InlineLink>
, such as your primary key columns.
</p>
</ConfirmationModal>
</>
)
}
@@ -69,7 +69,9 @@ export const AuditLogs = () => {
)
?.filter((log) => {
if (filters.projects.length > 0) {
return filters.projects.includes(log.target.metadata.project_ref || '')
return filters.projects.includes(
log.target.metadata.project_ref || log.target.metadata.ref || ''
)
} else {
return log
}
@@ -220,12 +222,12 @@ export const AuditLogs = () => {
]}
body={
sortedLogs?.map((log) => {
const project = projects?.find(
(project) => project.ref === log.target.metadata.project_ref
)
const organization = organizations?.find(
(org) => org.slug === log.target.metadata.org_slug
)
const logProjectRef =
log.target.metadata.project_ref || log.target.metadata.ref
const logOrgSlug = log.target.metadata.org_slug || log.target.metadata.slug
const project = projects?.find((project) => project.ref === logProjectRef)
const organization = organizations?.find((org) => org.slug === logOrgSlug)
const hasStatusCode = log.action.metadata[0]?.status !== undefined
@@ -261,16 +263,10 @@ export const AuditLogs = () => {
</p>
<p
className="text-foreground-light text-xs mt-0.5 truncate"
title={
log.target.metadata.project_ref ?? log.target.metadata.org_slug
}
title={logProjectRef ?? logOrgSlug ?? ''}
>
{log.target.metadata.project_ref
? 'Ref: '
: log.target.metadata.org_slug
? 'Slug: '
: null}
{log.target.metadata.project_ref ?? log.target.metadata.org_slug}
{logProjectRef ? 'Ref: ' : logOrgSlug ? 'Slug: ' : null}
{logProjectRef ?? logOrgSlug}
</p>
</Table.td>
<Table.td>
@@ -47,7 +47,7 @@ export function useApiKeysCommands() {
badge: () => (
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge className="capitalize">{publishableKey.type}</Badge>
<Badge>{publishableKey.type}</Badge>
</span>
),
icon: () => <Key />,
@@ -63,7 +63,7 @@ export function useApiKeysCommands() {
badge: () => (
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge className="capitalize">{key.type}</Badge>
<Badge>{key.type}</Badge>
</span>
),
icon: () => <Key />,
@@ -81,7 +81,7 @@ export function useApiKeysCommands() {
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge>Public</Badge>
<Badge className="capitalize">{anonKey.type}</Badge>
<Badge>{anonKey.type}</Badge>
</span>
),
icon: () => <Key />,
@@ -98,7 +98,7 @@ export function useApiKeysCommands() {
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge variant="destructive">Secret</Badge>
<Badge className="capitalize">{serviceKey.type}</Badge>
<Badge>{serviceKey.type}</Badge>
</span>
),
icon: () => <Key />,
@@ -52,10 +52,8 @@ export const AddHookDropdown = ({
const nonEnterpriseHookOptions = hooks.filter((h) => !isValidHook(h) && !h.enterprise)
const enterpriseHookOptions = hooks.filter((h) => !isValidHook(h) && h.enterprise)
const hasAccessToAdvancedHooks =
organization?.plan.id === 'team' ||
organization?.plan.id === 'enterprise' ||
organization?.plan.id === 'platform'
const isTeamsOrEnterprisePlan =
organization?.plan.id === 'team' || organization?.plan.id === 'enterprise'
if (!canUpdateAuthHook) {
return (
@@ -89,7 +87,7 @@ export const AddHookDropdown = ({
<DropdownMenuSeparator />
{!hasAccessToAdvancedHooks && (
{!isTeamsOrEnterprisePlan && (
<DropdownMenuLabel className="grid gap-1 bg-surface-200">
<p className="text-foreground-light">Team or Enterprise Plan required</p>
<p className="text-foreground-lighter text-xs">
@@ -99,10 +97,10 @@ export const AddHookDropdown = ({
</DropdownMenuLabel>
)}
{enterpriseHookOptions.map((h) =>
hasAccessToAdvancedHooks ? (
isTeamsOrEnterprisePlan ? (
<DropdownMenuItem
key={h.title}
disabled={!hasAccessToAdvancedHooks}
disabled={!isTeamsOrEnterprisePlan}
className=""
onClick={() => onSelectHook(h.title)}
>
@@ -111,7 +109,7 @@ export const AddHookDropdown = ({
) : (
<DropdownMenuItem
key={h.title}
disabled={!hasAccessToAdvancedHooks}
disabled={!isTeamsOrEnterprisePlan}
onClick={() => onSelectHook(h.title)}
>
{h.title}
@@ -1,8 +1,8 @@
import type { OAuthClient } from '@supabase/supabase-js'
import { Edit, MoreVertical, Plus, RotateCw, Search, Trash, X } from 'lucide-react'
import Link from 'next/link'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useRef, useState } from 'react'
import { parseAsBoolean, parseAsStringLiteral, useQueryState } from 'nuqs'
import { useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
@@ -30,6 +30,7 @@ import {
TableCell,
TableHead,
TableHeader,
TableHeadSort,
TableRow,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
@@ -44,6 +45,21 @@ import {
OAUTH_APP_REGISTRATION_TYPE_OPTIONS,
} from './oauthApps.utils'
const OAUTH_APPS_SORT_VALUES = [
'name:asc',
'name:desc',
'client_type:asc',
'client_type:desc',
'registration_type:asc',
'registration_type:desc',
'created_at:asc',
'created_at:desc',
] as const
type OAuthAppsSort = (typeof OAUTH_APPS_SORT_VALUES)[number]
type OAuthAppsSortColumn = OAuthAppsSort extends `${infer Column}:${string}` ? Column : unknown
type OAuthAppsSortOrder = OAuthAppsSort extends `${string}:${infer Order}` ? Order : unknown
export const OAuthAppsList = () => {
const { ref: projectRef } = useParams()
const { data: authConfig, isLoading: isAuthConfigLoading } = useAuthConfigQuery({ projectRef })
@@ -108,12 +124,40 @@ export const OAuthAppsList = () => {
const [filterString, setFilterString] = useState<string>('')
const filteredOAuthApps = filterOAuthApps({
apps: oAuthApps,
searchString: filterString,
registrationTypes: filteredRegistrationTypes,
clientTypes: filteredClientTypes,
})
const [sort, setSort] = useQueryState(
'sort',
parseAsStringLiteral<OAuthAppsSort>(OAUTH_APPS_SORT_VALUES).withDefault('name:asc')
)
const filteredAndSortedOAuthApps = useMemo(() => {
const filtered = filterOAuthApps({
apps: oAuthApps,
searchString: filterString,
registrationTypes: filteredRegistrationTypes,
clientTypes: filteredClientTypes,
})
const [sortCol, sortOrder] = sort.split(':') as [OAuthAppsSortColumn, OAuthAppsSortOrder]
const orderMultiplier = sortOrder === 'asc' ? 1 : -1
return filtered.sort((a, b) => {
if (sortCol === 'name') {
return a.client_name.localeCompare(b.client_name) * orderMultiplier
}
if (sortCol === 'client_type') {
return a.client_type.localeCompare(b.client_type) * orderMultiplier
}
if (sortCol === 'registration_type') {
return a.registration_type.localeCompare(b.registration_type) * orderMultiplier
}
if (sortCol === 'created_at') {
return (
(new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier
)
}
return 0
})
}, [oAuthApps, filterString, filteredRegistrationTypes, filteredClientTypes, sort])
const hasActiveFilters =
filterString.length > 0 ||
@@ -126,6 +170,22 @@ export const OAuthAppsList = () => {
setFilteredClientTypes([])
}
const handleSortChange = (column: OAuthAppsSortColumn) => {
const [currentCol, currentOrder] = sort.split(':') as [OAuthAppsSortColumn, OAuthAppsSortOrder]
if (currentCol === column) {
// Cycle through: asc -> desc -> no sort (default)
if (currentOrder === 'asc') {
setSort(`${column}:desc` as OAuthAppsSort)
} else {
// Reset to default sort (name:asc)
setSort('name:asc')
}
} else {
// New column, start with asc
setSort(`${column}:asc` as OAuthAppsSort)
}
}
if (isAuthConfigLoading || (isOAuthServerEnabled && isLoading)) {
return <GenericSkeletonLoader />
}
@@ -223,26 +283,54 @@ export const OAuthAppsList = () => {
<Table containerProps={{ stickyLastColumn: true }}>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>
<TableHeadSort column="name" currentSort={sort} onSortChange={handleSortChange}>
Name
</TableHeadSort>
</TableHead>
<TableHead>Client ID</TableHead>
<TableHead>Client Type</TableHead>
<TableHead>Registration Type</TableHead>
<TableHead>Created</TableHead>
<TableHead>
<TableHeadSort
column="client_type"
currentSort={sort}
onSortChange={handleSortChange}
>
Client Type
</TableHeadSort>
</TableHead>
<TableHead>
<TableHeadSort
column="registration_type"
currentSort={sort}
onSortChange={handleSortChange}
>
Registration Type
</TableHeadSort>
</TableHead>
<TableHead>
<TableHeadSort
column="created_at"
currentSort={sort}
onSortChange={handleSortChange}
>
Created
</TableHeadSort>
</TableHead>
<TableHead className="w-8 px-0">
<div className="!bg-200 px-4 w-full h-full flex items-center border-l @[944px]:border-l-0" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOAuthApps.length === 0 && (
{filteredAndSortedOAuthApps.length === 0 && (
<TableRow>
<TableCell colSpan={6}>
<p className="text-foreground-lighter">No OAuth apps found</p>
</TableCell>
</TableRow>
)}
{filteredOAuthApps.length > 0 &&
filteredOAuthApps.map((app) => (
{filteredAndSortedOAuthApps.length > 0 &&
filteredAndSortedOAuthApps.map((app) => (
<TableRow key={app.client_id} className="w-full">
<TableCell className="w-48 max-w-48 flex" title={app.client_name}>
<Button
@@ -15,8 +15,7 @@ export const NoProjectsOnPaidOrgInfo = ({ organization }: NoProjectsOnPaidOrgInf
projectCount > 0 ||
organization?.plan === undefined ||
organization.plan.id === 'free' ||
organization.plan.id === 'enterprise' ||
organization.plan.id === 'platform'
organization.plan.id === 'enterprise'
)
return null
@@ -45,35 +45,24 @@ export const getPlanChangeType = (
pro: 'upgrade',
team: 'upgrade',
enterprise: 'upgrade',
platform: 'upgrade',
},
pro: {
free: 'downgrade',
pro: 'none',
team: 'upgrade',
enterprise: 'upgrade',
platform: 'upgrade',
},
team: {
free: 'downgrade',
pro: 'downgrade',
team: 'none',
enterprise: 'upgrade',
platform: 'upgrade',
},
enterprise: {
free: 'downgrade',
pro: 'downgrade',
team: 'downgrade',
enterprise: 'none',
platform: 'upgrade',
},
platform: {
free: 'downgrade',
pro: 'downgrade',
team: 'downgrade',
enterprise: 'downgrade',
platform: 'none',
},
}
@@ -26,6 +26,11 @@ import { Admonition } from 'ui-patterns'
const orioleExtCallOuts = ['vector', 'postgis']
// Extensions that have recommended schemas (rather than required schemas)
const extensionsWithRecommendedSchemas: Record<string, string> = {
wrappers: 'extensions',
}
interface EnableExtensionModalProps {
visible: boolean
extension: PostgresExtension
@@ -89,6 +94,20 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM
}
}, [visible, extension.name])
const getSchemaDescriptionText = (extensionName: string, schema: string | null | undefined) => {
// Prioritize defaultSchema (required/forced) over recommended schema
if (schema) {
return `Extension must be installed in the “${schema}” schema.`
}
const recommendedSchema = extensionsWithRecommendedSchemas[extensionName]
if (recommendedSchema) {
return `Use the “${recommendedSchema}” schema for full compatibility with related features.`
}
return undefined
}
const validate = (values: any) => {
const errors: any = {}
if (values.schema === 'custom' && !values.name) errors.name = 'Required field'
@@ -165,13 +184,14 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM
name="schema"
value={defaultSchema}
label="Select a schema to enable the extension for"
descriptionText={`Extension must be installed in ${defaultSchema}.`}
descriptionText={getSchemaDescriptionText(extension.name, defaultSchema)}
/>
) : (
<Listbox
size="small"
name="schema"
label="Select a schema to enable the extension for"
descriptionText={getSchemaDescriptionText(extension.name, null)}
>
<Listbox.Option
key="custom"
@@ -1,7 +1,7 @@
import { sortBy } from 'lodash'
import { AlertCircle, Search, Trash } from 'lucide-react'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useEffect, useRef, useState } from 'react'
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
import { useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
@@ -12,14 +12,13 @@ import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/Shimmerin
import { useDatabaseIndexDeleteMutation } from 'data/database-indexes/index-delete-mutation'
import { useIndexesQuery, type DatabaseIndex } from 'data/database-indexes/indexes-query'
import { useSchemasQuery } from 'data/database/schemas-query'
import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect'
import { useIsProtectedSchema } from 'hooks/useProtectedSchemas'
import {
Button,
Card,
Input,
SidePanel,
Table,
TableBody,
@@ -28,7 +27,8 @@ import {
TableHeader,
TableRow,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { Input } from 'ui-patterns/DataInputs/Input'
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
import CreateIndexSidePanel from './CreateIndexSidePanel'
@@ -36,7 +36,7 @@ const Indexes = () => {
const { data: project } = useSelectedProjectQuery()
const { schema: urlSchema, table } = useParams()
const [search, setSearch] = useState('')
const [search, setSearch] = useQueryState('search', parseAsString.withDefault(''))
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const deletingIndexNameRef = useRef<string | null>(null)
@@ -184,7 +184,7 @@ const Indexes = () => {
</TableRow>
</TableHeader>
<TableBody>
{sortedIndexes.length === 0 && search.length === 0 && (
{indexes.length === 0 && search.length === 0 && (
<TableRow>
<TableCell colSpan={4}>
<p className="text-sm text-foreground">No indexes created yet</p>
@@ -194,7 +194,7 @@ const Indexes = () => {
</TableCell>
</TableRow>
)}
{sortedIndexes.length === 0 && search.length > 0 && (
{indexes.length === 0 && search.length > 0 && (
<TableRow>
<TableCell colSpan={4}>
<p className="text-sm text-foreground">No results found</p>
@@ -7,7 +7,6 @@ import * as z from 'zod'
import { useFlag, useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCheckPrimaryKeysExists } from 'data/database/primary-keys-exists-query'
@@ -221,19 +220,10 @@ export const DestinationPanel = ({
// Helper function to handle namespace creation if needed
const resolveNamespace = async (data: z.infer<typeof FormSchema>) => {
if (data.namespace === CREATE_NEW_NAMESPACE) {
if (!data.newNamespaceName) {
throw new Error('New namespace name is required')
}
// Construct catalog URI for namespace creation
const protocol = projectSettings?.app_config?.protocol ?? 'https'
const endpoint =
projectSettings?.app_config?.storage_endpoint || projectSettings?.app_config?.endpoint
const catalogUri = getCatalogURI(project?.ref ?? '', protocol, endpoint)
if (!data.newNamespaceName) throw new Error('New namespace name is required')
await createNamespace({
projectRef,
catalogUri,
warehouse: data.warehouseName!,
namespace: data.newNamespaceName,
})
@@ -1,10 +1,9 @@
import { Eye, EyeOff, Loader2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useState } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { InlineLink } from 'components/ui/InlineLink'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
@@ -154,30 +153,19 @@ export const AnalyticsBucketFields = ({
isError: isErrorBuckets,
} = useAnalyticsBucketsQuery({ projectRef })
// Construct catalog URI for iceberg namespaces query
const catalogUri = useMemo(() => {
if (!project?.ref || !projectSettings) return ''
const protocol = projectSettings.app_config?.protocol ?? 'https'
const endpoint =
projectSettings.app_config?.storage_endpoint || projectSettings.app_config?.endpoint
return getCatalogURI(project.ref, protocol, endpoint)
}, [project?.ref, projectSettings])
const canSelectNamespace = !!warehouseName && !!serviceApiKey
const {
data: namespaces = [],
isLoading: isLoadingNamespaces,
isError: isErrorNamespaces,
refetch: refetchNamespaces,
} = useIcebergNamespacesQuery(
{
projectRef,
catalogUri,
warehouse: warehouseName || '',
warehouse: warehouseName,
},
{
enabled: type === 'Analytics Bucket' && !!catalogUri && !!warehouseName && !!serviceApiKey,
enabled: type === 'Analytics Bucket' && !!serviceApiKey,
}
)
@@ -1,5 +1,5 @@
// [Joshen] These should eventually be in shared-types so that it can be used between infra and dashboard
// https://github.com/supabase/infrastructure/blob/a466d85ece3179cf7b6da1b15c11f2ed2be49bd6/shared/src/volumes.ts#L5-L24
// https://github.com/supabase/platform/blob/a466d85ece3179cf7b6da1b15c11f2ed2be49bd6/shared/src/volumes.ts#L5-L24
// https://docs.aws.amazon.com/ebs/latest/userguide/provisioned-iops.html#io2-bx-considerations
export const IO2_AVAILABLE_REGIONS = [
'ap-east-1',
@@ -50,8 +50,9 @@ export const CreateDiskStorageSchema = ({
}) => {
const isFlyProject = cloudProvider === 'FLY'
const isAwsNimbusProject = cloudProvider === 'AWS_NIMBUS'
const isAwsK8sProject = cloudProvider === 'AWS_K8S'
const validateDiskConfiguration = !isFlyProject && !isAwsNimbusProject
const validateDiskConfiguration = !isFlyProject && !isAwsNimbusProject && !isAwsK8sProject
const schema = baseSchema.superRefine((data, ctx) => {
const { storageType, totalSize, provisionedIOPS, throughput, maxSizeGb } = data
@@ -72,6 +72,9 @@ export function DiskManagementForm() {
const { data: org } = useSelectedOrganizationQuery()
const { setProjectStatus } = useSetProjectStatus()
const isSpendCapEnabled =
org?.plan.id !== 'free' && !org?.usage_billing_enabled && project?.cloud_provider !== 'FLY'
const { data: resourceWarnings } = useResourceWarningsQuery({ ref: projectRef })
// [Joshen Cleanup] JFYI this client side filtering can be cleaned up once BE changes are live which will only return the warnings based on the provided ref
const projectResourceWarnings = (resourceWarnings ?? [])?.find(
@@ -208,7 +211,7 @@ export function DiskManagementForm() {
const usedPercentage = (usedSize / totalSize) * 100
const disableIopsThroughputConfig =
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(form.watch('computeSize')) && !hasAccess
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize)
const isBranch = project?.parent_project_ref !== undefined
@@ -216,6 +219,7 @@ export function DiskManagementForm() {
isRequestingChanges ||
isPlanUpgradeRequired ||
isWithinCooldownWindow ||
isSpendCapEnabled ||
!canUpdateDiskConfiguration ||
!isAws
@@ -256,6 +260,7 @@ export function DiskManagementForm() {
// [Joshen] Skip disk configuration related stuff for AWS Nimbus
try {
if (
!isAwsK8s &&
!isAwsNimbus &&
(payload.storageType !== form.formState.defaultValues?.storageType ||
payload.provisionedIOPS !== form.formState.defaultValues?.provisionedIOPS ||
@@ -274,6 +279,7 @@ export function DiskManagementForm() {
}
if (
!isAwsK8s &&
!isAwsNimbus &&
(payload.growthPercent !== form.formState.defaultValues?.growthPercent ||
payload.minIncrementGb !== form.formState.defaultValues?.minIncrementGb ||
@@ -323,154 +329,160 @@ export function DiskManagementForm() {
<ScaffoldContainer className="relative flex flex-col gap-10" bottomPadding>
<NoticeBar
type="default"
visible={isEntitlementsLoaded && !hasAccess}
visible={isEntitlementsLoaded && isPlanUpgradeRequired}
title="Compute and Disk configuration is not available on the Free Plan"
actions={<UpgradePlanButton source="diskManagementConfigure" plan="Pro" />}
description="You will need to upgrade to at least the Pro Plan to configure compute and disk"
/>
{isProjectResizing || isProjectRequestingDiskChanges || noPermissions ? (
<div className="relative flex flex-col gap-10">
<DiskMangementRestartRequiredSection
visible={isProjectResizing}
title="Your project will now automatically restart."
description="Your project will be unavailable for up to 2 mins."
/>
<NoticeBar
type="default"
visible={isProjectRequestingDiskChanges}
title="Disk configuration changes have been requested"
description="The requested changes will be applied to your disk shortly"
/>
<NoticeBar
type="default"
visible={noPermissions}
title="You do not have permission to update disk configuration"
description="Please contact your organization administrator to update your disk configuration"
/>
</div>
) : null}
{isProjectResizing ||
isProjectRequestingDiskChanges ||
(noPermissions && (
<div className="relative flex flex-col gap-10">
<DiskMangementRestartRequiredSection
visible={isProjectResizing}
title="Your project will now automatically restart."
description="Your project will be unavailable for up to 2 mins."
/>
<NoticeBar
type="default"
visible={isProjectRequestingDiskChanges}
title="Disk configuration changes have been requested"
description="The requested changes will be applied to your disk shortly"
/>
<NoticeBar
type="default"
visible={noPermissions}
title="You do not have permission to update disk configuration"
description="Please contact your organization administrator to update your disk configuration"
/>
</div>
))}
<Separator />
<ComputeSizeField form={form} disabled={disableComputeInputs} />
{!(isAws || isAwsNimbus) && <Separator />}
<SpendCapDisabledSection />
<div className="flex flex-col gap-y-2">
<SpendCapDisabledSection />
<NoticeBar
type="default"
visible={!(isAws || isAwsNimbus)}
title="Disk configuration is only available for projects in the AWS cloud provider"
description={
isAwsK8s
? 'Configuring your disk for AWS (Revamped) projects is unavailable for now.'
: isBranch
? 'Delete and recreate your Preview Branch to configure disk size. It was deployed on an older branching infrastructure.'
: 'The Fly Postgres offering is deprecated - please migrate your instance to the AWS cloud prov to configure your disk.'
}
/>
{isAws && (
<>
<div className="flex flex-col gap-y-3">
<DiskCountdownRadial />
{!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && (
<Admonition
type="destructive"
title="Database size is currently over 90% of disk size"
description="Your project will enter read-only mode once you reach 95% of the disk space to prevent your database from exceeding the disk limitations"
>
<DocsButton
abbrev={false}
className="mt-2"
href={`${DOCS_URL}/guides/platform/database-size#read-only-mode`}
/>
</Admonition>
)}
{isReadOnlyMode && (
<Admonition
type="destructive"
title="Project is currently in read-only mode"
description="You will need to manually override read-only mode and reduce the database size to below 95% of the disk size"
>
<DocsButton
abbrev={false}
className="mt-2"
href={`${DOCS_URL}/guides/platform/database-size#disabling-read-only-mode`}
/>
</Admonition>
)}
</div>
<DiskSizeField
form={form}
disableInput={disableDiskInputs}
setAdvancedSettingsOpenState={setAdvancedSettingsOpenState}
/>
<Separator />
<Collapsible_Shadcn_
// TO DO: wrap component into pattern
className="-space-y-px"
open={advancedSettingsOpen}
onOpenChange={() => setAdvancedSettingsOpenState((prev) => !prev)}
>
<CollapsibleTrigger_Shadcn_ className="px-8 py-3 w-full border flex items-center gap-6 rounded-t data-[state=closed]:rounded-b group justify-between">
<div className="flex flex-col items-start">
<span className="text-sm text-foreground">Advanced disk settings</span>
<span className="text-sm text-foreground-light text-left">
Specify additional settings for your disk, including autoscaling
configuration, IOPS, throughput, and disk type.
</span>
</div>
<ChevronRight
size={16}
className="text-foreground-light transition-all group-data-[state=open]:rotate-90"
strokeWidth={1}
/>
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_
className={cn(
'flex flex-col gap-8 py-8 transition-all',
'data-[state=open]:border data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down'
<NoticeBar
type="default"
visible={!(isAws || isAwsNimbus)}
title="Disk configuration is only available for projects in the AWS cloud provider"
description={
isAwsK8s
? 'Configuring your disk for AWS (Revamped) projects is unavailable for now.'
: isBranch
? 'Delete and recreate your Preview Branch to configure disk size. It was deployed on an older branching infrastructure.'
: 'The Fly Postgres offering is deprecated - please migrate your instance to the AWS cloud prov to configure your disk.'
}
/>
{isAws && (
<>
<div className="flex flex-col gap-y-3">
<DiskCountdownRadial />
{!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && (
<Admonition
type="destructive"
title="Database size is currently over 90% of disk size"
description="Your project will enter read-only mode once you reach 95% of the disk space to prevent your database from exceeding the disk limitations"
>
<DocsButton
abbrev={false}
className="mt-2"
href={`${DOCS_URL}/guides/platform/database-size#read-only-mode`}
/>
</Admonition>
)}
{isReadOnlyMode && (
<Admonition
type="destructive"
title="Project is currently in read-only mode"
description="You will need to manually override read-only mode and reduce the database size to below 95% of the disk size"
>
<DocsButton
abbrev={false}
className="mt-2"
href={`${DOCS_URL}/guides/platform/database-size#disabling-read-only-mode`}
/>
</Admonition>
)}
</div>
<DiskSizeField
form={form}
disableInput={disableDiskInputs}
setAdvancedSettingsOpenState={setAdvancedSettingsOpenState}
/>
<Separator />
<Collapsible_Shadcn_
// TO DO: wrap component into pattern
className="-space-y-px"
open={advancedSettingsOpen}
onOpenChange={() => setAdvancedSettingsOpenState((prev) => !prev)}
>
<div className="px-8 flex flex-col gap-y-8">
<AutoScaleFields form={form} />
</div>
<Separator />
<div className="px-8 flex flex-col gap-y-8">
<NoticeBar
type="default"
visible={disableIopsThroughputConfig}
title="Adjusting disk configuration requires LARGE Compute size or above"
description={`Increase your compute size to adjust your disk's storage type, ${form.getValues('storageType') === 'gp3' ? 'IOPS, ' : ''} and throughput`}
actions={
<Button
type="default"
onClick={() => {
form.setValue('computeSize', 'ci_large')
}}
>
Change to LARGE Compute
</Button>
}
<CollapsibleTrigger_Shadcn_ className="px-8 py-3 w-full border flex items-center gap-6 rounded-t data-[state=closed]:rounded-b group justify-between">
<div className="flex flex-col items-start">
<span className="text-sm text-foreground">Advanced disk settings</span>
<span className="text-sm text-foreground-light text-left">
Specify additional settings for your disk, including autoscaling
configuration, IOPS, throughput, and disk type.
</span>
</div>
<ChevronRight
size={16}
className="text-foreground-light transition-all group-data-[state=open]:rotate-90"
strokeWidth={1}
/>
<StorageTypeField
form={form}
disableInput={disableIopsThroughputConfig || disableDiskInputs}
/>
<IOPSField
form={form}
disableInput={disableIopsThroughputConfig || disableDiskInputs}
/>
<ThroughputField
form={form}
disableInput={disableIopsThroughputConfig || disableDiskInputs}
/>
</div>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</>
)}
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_
className={cn(
'flex flex-col gap-8 py-8 transition-all',
'data-[state=open]:border data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down'
)}
>
<div className="px-8 flex flex-col gap-y-8">
<AutoScaleFields form={form} />
</div>
<Separator />
<div className="px-8 flex flex-col gap-y-8">
<NoticeBar
type="default"
visible={disableIopsThroughputConfig}
title="Adjusting disk configuration requires LARGE Compute size or above"
description={`Increase your compute size to adjust your disk's storage type, ${form.getValues('storageType') === 'gp3' ? 'IOPS, ' : ''} and throughput`}
actions={
<Button
type="default"
onClick={() => {
form.setValue('computeSize', 'ci_large')
}}
>
Change to LARGE Compute
</Button>
}
/>
<StorageTypeField
form={form}
disableInput={disableIopsThroughputConfig || disableDiskInputs}
/>
<IOPSField
form={form}
disableInput={disableIopsThroughputConfig || disableDiskInputs}
/>
<ThroughputField
form={form}
disableInput={disableIopsThroughputConfig || disableDiskInputs}
/>
</div>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</>
)}
</div>
</ScaffoldContainer>
<AnimatePresence>
{isDirty ? (
@@ -152,6 +152,8 @@ export const DiskManagementReviewAndSubmitDialog = ({
const { data: org } = useSelectedOrganizationQuery()
const isAwsNimbus = useIsAwsNimbusCloudProvider()
const isAwsK8sProject = project?.cloud_provider === 'AWS_K8S'
const { formState, getValues } = form
const { can: canUpdateDiskConfiguration } = useAsyncCheckPermissions(
@@ -214,22 +216,34 @@ export const DiskManagementReviewAndSubmitDialog = ({
const hasComputeChanges =
form.formState.defaultValues?.computeSize !== form.getValues('computeSize')
const hasTotalSizeChanges =
!isAwsNimbus && form.formState.defaultValues?.totalSize !== form.getValues('totalSize')
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.totalSize !== form.getValues('totalSize')
const hasStorageTypeChanges =
!isAwsNimbus && form.formState.defaultValues?.storageType !== form.getValues('storageType')
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.storageType !== form.getValues('storageType')
const hasThroughputChanges =
!isAwsNimbus && form.formState.defaultValues?.throughput !== form.getValues('throughput')
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.throughput !== form.getValues('throughput')
const hasIOPSChanges =
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.provisionedIOPS !== form.getValues('provisionedIOPS')
const hasGrowthPercentChanges =
!isAwsNimbus && form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent')
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent')
const hasMinIncrementChanges =
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.minIncrementGb !== form.getValues('minIncrementGb')
const hasMaxSizeChanges =
!isAwsNimbus && form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb')
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb')
const hasDiskConfigChanges =
hasIOPSChanges ||
@@ -8,7 +8,7 @@ export enum DiskType {
IO2 = 'io2',
}
// [Joshen] As per https://github.com/supabase/infrastructure/pull/20478
// [Joshen] As per https://github.com/supabase/platform/pull/20478
export const DISK_AUTOSCALE_CONFIG_DEFAULTS = {
growthPercent: 50,
minIncrementSize: 4,
@@ -57,7 +57,6 @@ export const PLAN_DETAILS: Record<PlanId, PlanDetails> = {
pro: { includedDiskGB: { gp3: 8, io2: 0 } },
team: { includedDiskGB: { gp3: 8, io2: 0 } },
enterprise: { includedDiskGB: { gp3: 8, io2: 0 } },
platform: { includedDiskGB: { gp3: 8, io2: 0 } },
}
export const COMPUTE_BASELINE_IOPS = {
@@ -25,7 +25,7 @@ interface EdgeFunctionSecretProps {
const EdgeFunctionSecret = ({ secret, onSelectEdit, onSelectDelete }: EdgeFunctionSecretProps) => {
const { can: canUpdateSecrets } = useAsyncCheckPermissions(PermissionAction.SECRETS_WRITE, '*')
// [Joshen] Following API's validation:
// https://github.com/supabase/infrastructure/blob/develop/api/src/routes/v1/projects/ref/secrets/secrets.controller.ts#L106
// https://github.com/supabase/platform/blob/develop/api/src/routes/v1/projects/ref/secrets/secrets.controller.ts#L106
const isReservedSecret = !!secret.name.match(/^(SUPABASE_).*/)
return (
@@ -35,7 +35,7 @@ import {
type ChartIntervalKey = ProjectLogStatsVariables['interval']
const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90, platform: 1 }
const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90 }
const CHART_INTERVALS: ChartIntervals[] = [
{
@@ -44,7 +44,7 @@ const CHART_INTERVALS: ChartIntervals[] = [
startValue: 1,
startUnit: 'hour',
format: 'MMM D, h:mma',
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
availableIn: ['free', 'pro', 'team', 'enterprise'],
},
{
key: '1day',
@@ -52,7 +52,7 @@ const CHART_INTERVALS: ChartIntervals[] = [
startValue: 24,
startUnit: 'hour',
format: 'MMM D, ha',
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
availableIn: ['free', 'pro', 'team', 'enterprise'],
},
{
key: '7day',
@@ -1,10 +1,10 @@
import { BASE_PATH } from 'lib/constants'
import { Check, ChevronLeft, ChevronRight } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { cn, Button, Card, CardContent, Badge } from 'ui'
import { GettingStartedStep, GettingStartedAction } from './GettingStarted.types'
import { BASE_PATH } from 'lib/constants'
import { Badge, Button, Card, CardContent, cn } from 'ui'
import { GettingStartedAction, GettingStartedStep } from './GettingStarted.types'
// Determine action type for tracking
const getActionType = (action: GettingStartedAction): 'primary' | 'ai_assist' | 'external_link' => {
@@ -186,7 +186,7 @@ export function GettingStarted({ steps, onStepClick }: GettingStartedProps) {
<h3>{activeStep.title}</h3>
<Badge
variant={activeStep.status === 'complete' ? 'success' : 'default'}
className="capitalize hidden @2xl:inline-flex"
className="hidden @2xl:inline-flex"
>
{activeStep.status}
</Badge>
@@ -43,7 +43,7 @@ import {
formatDelta,
} from './ProjectUsage.metrics'
const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90, platform: 1 }
const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90 }
const CHART_INTERVALS: ChartIntervals[] = [
{
@@ -52,7 +52,7 @@ const CHART_INTERVALS: ChartIntervals[] = [
startValue: 1,
startUnit: 'hour',
format: 'MMM D, h:mma',
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
availableIn: ['free', 'pro', 'team', 'enterprise'],
},
{
key: '1day',
@@ -60,7 +60,7 @@ const CHART_INTERVALS: ChartIntervals[] = [
startValue: 24,
startUnit: 'hour',
format: 'MMM D, ha',
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
availableIn: ['free', 'pro', 'team', 'enterprise'],
},
{
key: '7day',
@@ -105,7 +105,7 @@ export const IntegrationInstallation = forwardRef<HTMLLIElement, IntegrationInst
integration.metadata?.gitHubConnectionOwner)}
</span>
<Badge className="capitalize">{integration.metadata?.account.type}</Badge>
<Badge>{integration.metadata?.account.type}</Badge>
</div>
<div className="flex flex-col gap-0">
<span className="text-foreground-lighter text-xs">
@@ -1,6 +1,6 @@
import { LockKeyholeOpen, RectangleEllipsis } from 'lucide-react'
import { ExternalLink, HelpCircle, LockKeyholeOpen, RectangleEllipsis } from 'lucide-react'
import { InfoPill } from 'components/ui/InfoPill'
import { HoverCard, HoverCardContent, HoverCardTrigger } from 'ui'
import { AlgorithmDetail, algorithmDetails } from './algorithm-details'
interface AlgorithmHoverCardProps {
@@ -12,39 +12,57 @@ export const AlgorithmHoverCard = ({ algorithm, legacy }: AlgorithmHoverCardProp
const details: AlgorithmDetail = algorithmDetails[algorithm]
return (
<InfoPill
label={<span className="pt-1 h-6">{legacy ? `Legacy ${details.label}` : details.label}</span>}
icon={
algorithm === 'HS256' ? (
<RectangleEllipsis className="size-4 flex-shrink-0" />
) : (
<LockKeyholeOpen className="size-4 flex-shrink-0" />
)
}
title={details.name}
description={
<div className="flex flex-col gap-2">
<p>{details.description}</p>
<p>
Pros:
<ul className="list-disc">
{details.pros.map((pro, i) => (
<li key={i}>{pro}</li>
<div className="flex items-center gap-x-2">
{algorithm === 'HS256' ? (
<RectangleEllipsis className="flex-shrink-0" size={14} />
) : (
<LockKeyholeOpen className="flex-shrink-0" size={14} />
)}
<p>{legacy ? `Legacy ${details.label}` : details.label}</p>
<HoverCard closeDelay={50} openDelay={300}>
<HoverCardTrigger className="min-w-0">
<HelpCircle size={14} className="text-foreground-lighter" />
</HoverCardTrigger>
<HoverCardContent className="w-[22rem] flex items-start gap-3" side="right" align="center">
<div className="flex-1">
<h4 className="font-semibold truncate">{details.name}</h4>
<div className="flex flex-col gap-2 text-sm text-foreground-light">
<p>{details.description}</p>
<p>
Pros:
<ul className="list-disc pl-6">
{details.pros.map((pro, i) => (
<li key={i}>{pro}</li>
))}
</ul>
</p>
<p>
Cons:{' '}
<ul className="list-disc pl-6">
{details.cons.map((con, i) => (
<li key={i}>{con}</li>
))}
</ul>
<br />
</p>
</div>
<div className="space-y-1 mt-2">
{details.links.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-brand hover:text-brand-600 flex items-center"
>
<ExternalLink className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{link.label}</span>
</a>
))}
</ul>
</p>
<p>
Cons:{' '}
<ul className="list-disc">
{details.cons.map((con, i) => (
<li key={i}>{con}</li>
))}
</ul>
<br />
</p>
</div>
}
links={details.links}
/>
</div>
</div>
</HoverCardContent>
</HoverCard>
</div>
)
}
@@ -236,6 +236,7 @@ export const JWTSecretKeysTable = () => {
<TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2">
Type
</TableHead>
<TableHead />
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
Actions
</TableHead>
@@ -1,19 +1,18 @@
import { useMemo } from 'react'
import { FileKey } from 'lucide-react'
import { useMemo } from 'react'
import { JWTSigningKey } from 'data/jwt-signing-keys/jwt-signing-keys-query'
import {
DialogContent,
DialogDescription,
Button,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
Input,
Textarea,
Label_Shadcn_,
Button,
Textarea,
} from 'ui'
import { JWTSigningKey } from 'data/jwt-signing-keys/jwt-signing-keys-query'
export function KeyDetailsDialog({
selectedKey,
@@ -1,10 +1,11 @@
import { ArrowRight, ExternalLink, Info, Key, Timer } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useJWTSigningKeyUpdateMutation } from 'data/jwt-signing-keys/jwt-signing-key-update-mutation'
import { JWTSigningKey } from 'data/jwt-signing-keys/jwt-signing-keys-query'
import { ArrowRight, ExternalLink, Info, Key, Timer } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import {
Badge,
Button,
@@ -71,7 +72,7 @@ export function RotateKeyDialog({
'px-4 py-1 gap-2 flex flex-row items-center uppercase'
)}
>
<Timer className="size-4" />
<Timer size={14} />
Standby key
</Badge>
<div>
@@ -84,7 +85,7 @@ export function RotateKeyDialog({
'px-4 py-1 gap-2 flex flex-row items-center uppercase'
)}
>
<Key className="size-4" />
<Key size={14} />
Current key
</Badge>
</div>
@@ -102,7 +103,7 @@ export function RotateKeyDialog({
'px-4 py-1 gap-2 flex flex-row items-center uppercase'
)}
>
<Key className="size-4" />
<Key size={14} />
Current key
</Badge>
<div>
@@ -114,7 +115,7 @@ export function RotateKeyDialog({
'px-4 py-1 gap-2 flex flex-row items-center uppercase'
)}
>
<Timer className="size-4" />
<Timer size={14} />
Previous key
</Badge>
<div />
@@ -1,4 +1,3 @@
import dayjs from 'dayjs'
import { motion } from 'framer-motion'
import {
CircleArrowDown,
@@ -14,6 +13,7 @@ import {
import { components } from 'api-types'
import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
import { JWTSigningKey } from 'data/jwt-signing-keys/jwt-signing-keys-query'
import dayjs from 'dayjs'
import {
Badge,
Button,
@@ -25,6 +25,7 @@ import {
TableCell,
TableRow,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { AlgorithmHoverCard } from '../algorithm-hover-card'
import { statusColors, statusLabels } from '../jwt.constants'
@@ -77,15 +78,15 @@ export const SigningKeyRow = ({
)}
>
{signingKey.status === 'standby' ? (
<Timer className="size-4 flex-shrink-0" />
<Timer className="flex-shrink-0" size={14} />
) : (
<Key className="size-4 flex-shrink-0" />
<Key className="flex-shrink-0" size={14} />
)}
<span className="truncate">{statusLabels[signingKey.status]}</span>
<span className="truncate text-xs">{statusLabels[signingKey.status]}</span>
</Badge>
</div>
</TableCell>
<TableCell className="font-mono truncate max-w-[100px] pl-0 py-2">
<TableCell className="font-mono truncate w-[315px] pl-0 py-2">
<div className="min-w-0 flex">
<Badge
className={cn(
@@ -94,22 +95,28 @@ export const SigningKeyRow = ({
'gap-2 py-2 h-6 min-w-0 overflow-hidden flex items-center flex-1'
)}
>
<span className="truncate flex-1" title={signingKey.id}>
<span className="truncate flex-1 text-xs" title={signingKey.id}>
{signingKey.id}
</span>
</Badge>
</div>
</TableCell>
<TableCell className="truncate max-w-[100px] py-2">
<TableCell className="truncate py-2">
<AlgorithmHoverCard
algorithm={signingKey.algorithm}
legacy={signingKey.id === legacyKey?.id}
/>
</TableCell>
{(signingKey.status === 'previously_used' || signingKey.status === 'revoked') && (
<TableCell className="text-right py-2 text-sm text-foreground-light whitespace-nowrap hidden lg:table-cell">
{dayjs(signingKey.updated_at).fromNow()}
{signingKey.status === 'previously_used' || signingKey.status === 'revoked' ? (
<TableCell className="max-w-[100px] text-right py-2 text-sm text-foreground-light whitespace-nowrap hidden lg:table-cell">
<TimestampInfo
className="text-sm"
utcTimestamp={signingKey.updated_at}
label={dayjs(signingKey.updated_at).fromNow()}
/>
</TableCell>
) : (
<TableCell />
)}
<TableCell className="text-right py-2">
{(signingKey.status !== 'in_use' || signingKey.algorithm !== 'HS256') && (
@@ -24,6 +24,7 @@ import {
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
AlertDescription_Shadcn_,
@@ -32,7 +33,6 @@ import {
Button,
WarningIcon,
} from 'ui'
import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements'
const logsUpgradeError = 'upgrade to Team or Enterprise Plan to access audit logs.'
@@ -348,12 +348,12 @@ export const AuditLogs = () => {
(member) => member.gotrue_id === log.actor.id
)
const role = roles.find((role) => user?.role_ids?.[0] === role.id)
const project = projects?.find(
(project) => project.ref === log.target.metadata.project_ref
)
const organization = organizations?.find(
(org) => org.slug === log.target.metadata.org_slug
)
const logProjectRef =
log.target.metadata.project_ref ?? log.target.metadata.ref
const logOrgSlug = log.target.metadata.org_slug ?? log.target.metadata.slug
const project = projects?.find((project) => project.ref === logProjectRef)
const organization = organizations?.find((org) => logOrgSlug)
const hasStatusCode = log.action.metadata[0]?.status !== undefined
const userIcon =
@@ -420,16 +420,10 @@ export const AuditLogs = () => {
</p>
<p
className="text-foreground-light text-xs mt-0.5 truncate"
title={
log.target.metadata.project_ref ?? log.target.metadata.org_slug
}
title={logProjectRef ?? logOrgSlug ?? ''}
>
{log.target.metadata.project_ref
? 'Ref: '
: log.target.metadata.org_slug
? 'Slug: '
: null}
{log.target.metadata.project_ref ?? log.target.metadata.org_slug}
{logProjectRef ? 'Ref: ' : logOrgSlug ? 'Slug: ' : null}
{logProjectRef ?? logOrgSlug}
</p>
</Table.td>
<Table.td>
@@ -57,7 +57,6 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
isSuccess,
} = useOrgUpcomingInvoiceQuery({ orgSlug: slug })
// For non-platform customers, compute is broken down per project and contains a breakdown array
const computeItems =
upcomingInvoice?.lines?.filter(
(item) =>
@@ -82,8 +81,7 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
const otherItems =
upcomingInvoice?.lines?.filter(
(item) =>
// In case we have no per-project breakdown for compute, we treat it as a regular item for display purposes
(!item.usage_metric?.toString()?.startsWith('COMPUTE_HOURS') || !item.breakdown?.length) &&
!item.description?.toLowerCase().includes('compute') &&
!item.description?.toLowerCase().includes('plan')
) || []
@@ -51,7 +51,7 @@ const CostControl = ({}: CostControlProps) => {
const isUsageBillingEnabled = subscription?.usage_billing_enabled ?? false
const canChangeTier =
!projectUpdateDisabled && !['team', 'enterprise', 'platform'].includes(currentPlan?.id || '')
!projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '')
const costControlDisabled = selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE
@@ -122,7 +122,7 @@ const CostControl = ({}: CostControlProps) => {
{isSuccess && !costControlDisabled && (
<div className="space-y-6">
{['team', 'enterprise', 'platform'].includes(currentPlan?.id || '') ? (
{['team', 'enterprise'].includes(currentPlan?.id || '') ? (
<Alert
withIcon
variant="info"
@@ -238,7 +238,6 @@ export const PlanUpdateSidePanel = () => {
type={isDowngradeOption ? 'default' : 'primary'}
disabled={
subscription?.plan?.id === 'enterprise' ||
subscription?.plan?.id === 'platform' ||
// Downgrades to free are still allowed through the dashboard given we have much better control about showing customers the impact + any possible issues with downgrading to free
(selectedOrganization?.managed_by !== MANAGED_BY.SUPABASE &&
plan.id !== 'tier_free') ||
@@ -263,9 +262,8 @@ export const PlanUpdateSidePanel = () => {
side: 'bottom',
className: hasOrioleProjects ? 'w-96 text-center' : '',
text:
subscription?.plan?.id === 'enterprise' ||
subscription?.plan?.id === 'platform'
? 'Reach out to us via support to update your plan'
subscription?.plan?.id === 'enterprise'
? 'Reach out to us via support to update your plan from Enterprise'
: hasOrioleProjects
? 'Your organization has projects that are using the OrioleDB extension which is only available on the Free plan. Remove all OrioleDB projects before changing your plan.'
: !canUpdateSubscription
@@ -41,8 +41,7 @@ const Subscription = () => {
const currentPlan = subscription?.plan
const planName = currentPlan?.name ?? 'Unknown'
const canChangeTier =
!projectUpdateDisabled && !['enterprise', 'platform'].includes(currentPlan?.id ?? '')
const canChangeTier = !projectUpdateDisabled && !['enterprise'].includes(currentPlan?.id ?? '')
return (
<>
@@ -69,7 +69,7 @@ export const SSOConfig = () => {
const { data: organization } = useSelectedOrganizationQuery()
const plan = organization?.plan.id
const canSetupSSOConfig = ['team', 'enterprise', 'platform'].includes(plan ?? '')
const canSetupSSOConfig = ['team', 'enterprise'].includes(plan ?? '')
const {
data: ssoConfig,
@@ -19,8 +19,6 @@ const Activity = ({
orgSlug,
projectRef,
subscription,
startDate,
endDate,
currentBillingCycleSelected,
orgDailyStats,
isLoadingOrgDailyStats,
@@ -11,6 +11,8 @@ export interface EgressProps {
currentBillingCycleSelected: boolean
orgDailyStats: OrgDailyUsageResponse | undefined
isLoadingOrgDailyStats: boolean
startDate: string | undefined
endDate: string | undefined
}
const Egress = ({
@@ -20,6 +22,8 @@ const Egress = ({
currentBillingCycleSelected,
orgDailyStats,
isLoadingOrgDailyStats,
startDate,
endDate,
}: EgressProps) => {
const chartMeta: {
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean }
@@ -47,6 +51,8 @@ const Egress = ({
chartMeta={chartMeta}
subscription={subscription}
currentBillingCycleSelected={currentBillingCycleSelected}
startDate={startDate}
endDate={endDate}
/>
)
}
@@ -15,6 +15,8 @@ export interface SizeAndCountsProps {
currentBillingCycleSelected: boolean
orgDailyStats: OrgDailyUsageResponse | undefined
isLoadingOrgDailyStats: boolean
startDate: string | undefined
endDate: string | undefined
}
const SizeAndCounts = ({
@@ -24,6 +26,8 @@ const SizeAndCounts = ({
currentBillingCycleSelected,
orgDailyStats,
isLoadingOrgDailyStats,
startDate,
endDate,
}: SizeAndCountsProps) => {
const chartMeta: {
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean }
@@ -46,6 +50,8 @@ const SizeAndCounts = ({
chartMeta={chartMeta}
subscription={subscription}
currentBillingCycleSelected={currentBillingCycleSelected}
startDate={startDate}
endDate={endDate}
/>
)
}
@@ -63,8 +63,6 @@ export const TotalUsage = ({
// When the user filters by project ref or selects a custom timeframe, we only display usage+project breakdown, but no costs/limits
const showRelationToSubscription = currentBillingCycleSelected && !projectRef
const isOnHigherPlan = ['team', 'enterprise', 'platform'].includes(subscription?.plan.id ?? '')
const hasExceededAnyLimits =
showRelationToSubscription &&
Boolean(
@@ -150,7 +148,7 @@ export const TotalUsage = ({
{isSuccessUsage && subscription && (
<div>
{showRelationToSubscription && !isOnHigherPlan && (
{showRelationToSubscription && (
<p className="text-sm">
{!hasExceededAnyLimits ? (
<span>
@@ -32,7 +32,7 @@ import SizeAndCounts from './SizeAndCounts'
import { TotalUsage } from './TotalUsage'
export const Usage = () => {
const { slug, projectRef } = useParams()
const { slug } = useParams()
const [dateRange, setDateRange] = useState<any>()
@@ -106,7 +106,7 @@ export const Usage = () => {
isError: isErrorOrgDailyStats,
} = useOrgDailyStatsQuery({
orgSlug: slug,
projectRef,
projectRef: selectedProjectRef ?? undefined,
startDate,
endDate,
})
@@ -299,6 +299,8 @@ export const Usage = () => {
currentBillingCycleSelected={currentBillingCycleSelected}
orgDailyStats={orgDailyStats}
isLoadingOrgDailyStats={isLoadingOrgDailyStats}
startDate={startDate}
endDate={endDate}
/>
<SizeAndCounts
@@ -308,6 +310,8 @@ export const Usage = () => {
currentBillingCycleSelected={currentBillingCycleSelected}
orgDailyStats={orgDailyStats}
isLoadingOrgDailyStats={isLoadingOrgDailyStats}
startDate={startDate}
endDate={endDate}
/>
<Activity
@@ -23,7 +23,7 @@ export function useGetUpgradeUrl(slug: string, subscription?: OrgSubscription, s
if (!billingAll) {
const subject = `Enquiry to upgrade plan for organization`
const message = `Organization Slug: ${slug}\nRequested plan: <Specify which plan to upgrade to: Pro | Team | Enterprise | Platform>`
const message = `Organization Slug: ${slug}\nRequested plan: <Specify which plan to upgrade to: Pro | Team | Enterprise>`
return `/support/new?orgSlug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}`
}
@@ -20,6 +20,8 @@ export interface UsageSectionProps {
subscription?: OrgSubscription
chartMeta: ChartMeta
currentBillingCycleSelected: boolean
startDate?: string
endDate?: string
}
const UsageSection = ({
@@ -29,6 +31,8 @@ const UsageSection = ({
chartMeta,
subscription,
currentBillingCycleSelected,
startDate,
endDate,
}: UsageSectionProps) => {
const {
data: usage,
@@ -36,7 +40,12 @@ const UsageSection = ({
isLoading: isLoadingUsage,
isError: isErrorUsage,
isSuccess: isSuccessUsage,
} = useOrgUsageQuery({ orgSlug })
} = useOrgUsageQuery({
orgSlug,
projectRef,
start: !currentBillingCycleSelected && startDate ? new Date(startDate) : undefined,
end: !currentBillingCycleSelected && endDate ? new Date(endDate) : undefined,
})
const categoryMeta = USAGE_CATEGORIES(subscription).find(
(category) => category.key === categoryKey
@@ -26,32 +26,32 @@ export const REPORTS_DATEPICKER_HELPERS: ReportsDatetimeHelper[] = [
text: REPORT_DATERANGE_HELPER_LABELS.LAST_10_MINUTES,
calcFrom: () => dayjs().subtract(10, 'minute').toISOString(),
calcTo: () => dayjs().toISOString(),
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
availableIn: ['free', 'pro', 'team', 'enterprise'],
},
{
text: REPORT_DATERANGE_HELPER_LABELS.LAST_30_MINUTES,
calcFrom: () => dayjs().subtract(30, 'minute').toISOString(),
calcTo: () => dayjs().toISOString(),
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
availableIn: ['free', 'pro', 'team', 'enterprise'],
},
{
text: REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES,
calcFrom: () => dayjs().subtract(1, 'hour').toISOString(),
calcTo: () => dayjs().toISOString(),
default: true,
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
availableIn: ['free', 'pro', 'team', 'enterprise'],
},
{
text: REPORT_DATERANGE_HELPER_LABELS.LAST_3_HOURS,
calcFrom: () => dayjs().subtract(3, 'hour').toISOString(),
calcTo: () => dayjs().toISOString(),
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
availableIn: ['free', 'pro', 'team', 'enterprise'],
},
{
text: REPORT_DATERANGE_HELPER_LABELS.LAST_24_HOURS,
calcFrom: () => dayjs().subtract(1, 'day').toISOString(),
calcTo: () => dayjs().toISOString(),
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
availableIn: ['free', 'pro', 'team', 'enterprise'],
},
{
text: REPORT_DATERANGE_HELPER_LABELS.LAST_7_DAYS,
@@ -1,5 +1,5 @@
// https://supabase.com/docs/guides/platform/performance#optimizing-the-number-of-connections
// https://github.com/supabase/infrastructure/blob/develop/worker/src/lib/constants.ts#L544-L596
// https://github.com/supabase/platform/blob/develop/worker/src/lib/constants.ts#L544-L596
// https://github.com/supabase/supabase-admin-api/blob/master/optimizations/pgbouncer.go
// [Joshen] This matches for both Supavisor and PgBouncer
@@ -57,7 +57,7 @@ export const getAddressEndRange = (type: 'IPv4' | 'IPv6', address: string) => {
}
// [Joshen] Using same logic as worker
// https://github.com/supabase/infrastructure/blob/840c9596e8bf9e9090ec94de1756bd511e67393a/worker/src/tasks/db/add_as_pooler_tenant.ts#L176C15-L176C15
// https://github.com/supabase/platform/blob/840c9596e8bf9e9090ec94de1756bd511e67393a/worker/src/tasks/db/add_as_pooler_tenant.ts#L176C15-L176C15
export const normalize = (address: string) => {
const [isIpV4] = Validator.isValidIPv4String(address.split('/')[0])
if (isIpV4) {
@@ -76,17 +76,17 @@ const DeployNewReplicaPanel = ({
const { data: addons, isSuccess } = useProjectAddonsQuery({ projectRef })
const { data: diskConfiguration } = useDiskAttributesQuery({ projectRef })
const isNotOnHigherPlan = useMemo(
() => !['team', 'enterprise', 'platform'].includes(org?.plan.id ?? ''),
const isNotOnTeamOrEnterprisePlan = useMemo(
() => !['team', 'enterprise'].includes(org?.plan.id ?? ''),
[org]
)
const { data: allOverdueInvoices } = useOverdueInvoicesQuery({
enabled: isNotOnHigherPlan,
enabled: isNotOnTeamOrEnterprisePlan,
})
const overdueInvoices = (allOverdueInvoices ?? []).filter(
(x) => x.organization_id === project?.organization_id
)
const hasOverdueInvoices = overdueInvoices.length > 0 && isNotOnHigherPlan
const hasOverdueInvoices = overdueInvoices.length > 0 && isNotOnTeamOrEnterprisePlan
const isAwsK8s = useIsAwsK8sCloudProvider()
// Opting for useState temporarily as Listbox doesn't seem to work with react-hook-form yet
@@ -777,7 +777,6 @@ export const TIER_QUERY_LIMITS: {
PAYG: { text: '7 days', value: 7, unit: 'day', promptUpgrade: true },
TEAM: { text: '28 days', value: 28, unit: 'day', promptUpgrade: true },
ENTERPRISE: { text: '90 days', value: 90, unit: 'day', promptUpgrade: false },
PLATFORM: { text: '1 day', value: 1, unit: 'day', promptUpgrade: false },
}
export const LOG_ROUTES_WITH_REPLICA_SUPPORT = [
@@ -8,7 +8,7 @@ import {
} from 'components/layouts/Scaffold'
import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants'
import { ConnectTablesDialog } from './ConnectTablesDialog'
import { CreateTableInstructionsDialog } from './CreateTableInstructions/CreateTableInstructionsDialog'
import { CreateTableInstructionsDialog } from './CreateTable/CreateTableInstructionsDialog'
interface BucketHeaderProps {
showActions?: boolean
@@ -35,14 +35,12 @@ export const BucketHeader = ({
</div>
{showActions && (
<div className="flex items-center gap-x-2">
{namespaces.length > 0 && (
<>
{HIDE_REPLICATION_USER_FLOW ? (
<CreateTableInstructionsDialog />
) : (
<ConnectTablesDialog onSuccessConnectTables={onSuccessConnectTables} />
)}
</>
{HIDE_REPLICATION_USER_FLOW ? (
<CreateTableInstructionsDialog />
) : (
namespaces.length > 0 && (
<ConnectTablesDialog onSuccessConnectTables={onSuccessConnectTables} />
)
)}
</div>
)}
@@ -1,3 +1,4 @@
import { Eye, EyeOff } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
@@ -13,7 +14,6 @@ import {
} from 'data/vault/vault-secret-decrypted-value-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import { Eye, EyeOff } from 'lucide-react'
import {
Accordion_Shadcn_,
AccordionContent_Shadcn_,
@@ -0,0 +1,71 @@
import { useFlag } from 'common'
import { ChevronDown, Plus } from 'lucide-react'
import { useState } from 'react'
import {
Button,
cn,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import { CreateTableInstructions } from './CreateTableInstructions'
import { CreateTableSheet } from './CreateTableSheet'
export const CreateTableInstructionsDialog = () => {
const enableCreationOfTablesFromDashboard = useFlag('analyticsBucketsTableCreation')
const [showModal, setShowModal] = useState(false)
const [showSheet, setShowSheet] = useState(false)
return (
<>
<div className="flex items-center">
<Button
type="primary"
icon={<Plus />}
className={cn(enableCreationOfTablesFromDashboard && 'rounded-r-none hover:z-10')}
onClick={() => {
if (enableCreationOfTablesFromDashboard) setShowSheet(true)
else setShowModal(true)
}}
>
Create table
</Button>
{enableCreationOfTablesFromDashboard && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="primary"
className="w-7 rounded-l-none -ml-[1px]"
icon={<ChevronDown />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" align="end">
<DropdownMenuItem onClick={() => setShowModal(true)}>Via Pyiceberg</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent size="xlarge">
<DialogHeader>
<DialogTitle>Adding tables to your Analytics Bucket</DialogTitle>
<DialogDescription>
Tables can be created or added to your bucket via Pyiceberg
</DialogDescription>
</DialogHeader>
<CreateTableInstructions hideHeader className="rounded-t-none border-x-0 border-b-0" />
</DialogContent>
</Dialog>
<CreateTableSheet open={showSheet} onOpenChange={setShowSheet} />
</>
)
}
@@ -0,0 +1,26 @@
export const NEW_NAMESPACE_MARKER = 'new-namespace'
export const COLUMN_TYPES = [
'boolean',
'int',
'long',
'float',
'double',
'string',
'timestamp',
'date',
'time',
'timestamptz',
'uuid',
'binary',
'decimal',
'fixed',
]
export const COLUMN_TYPE_FIELDS = {
decimal: [
{ name: 'precision', type: 'number' },
{ name: 'scale', type: 'number' },
],
fixed: [{ name: 'length', type: 'number' }],
}
@@ -0,0 +1,82 @@
import { z } from 'zod'
import { NEW_NAMESPACE_MARKER } from './CreateTableSheet.constants'
const getValidRegex = (type: 'namespace' | 'table') =>
type === 'namespace'
? /^(?!aws)[a-z0-9](?:[a-z0-9_]*[a-z0-9])?$/
: /^[a-z0-9](?:[a-z0-9_]*[a-z0-9])?$/
const getErrorRegex = (type: 'namespace' | 'table') =>
type === 'namespace'
? /^(?:(?<starts_with_reserved>aws.*)|(?<invalid_start>[^a-z0-9].*)|(?<invalid_char>.*[^a-z0-9_].*)|(?<invalid_end>.*[^a-z0-9]))$/
: /^(?:(?<invalid_start>[^a-z0-9].*)|(?<invalid_char>.*[^a-z0-9_].*)|(?<invalid_end>.*[^a-z0-9]))$/
const validateName = ({ name, type }: { name: string; type: 'namespace' | 'table' }) => {
const validRe = getValidRegex(type)
if (validRe.test(name)) return undefined
const errorRe = getErrorRegex(type)
const match = name.match(errorRe)?.groups || {}
if (match.starts_with_reserved) return "Namespace must not start with 'aws'"
if (match.invalid_start) return 'Name must begin with a lowercase letter or number'
if (match.invalid_end) return 'Name must end with a lowercase letter or number'
if (match.invalid_char) return 'Name may only contain lowercase letters, numbers, and underscores'
return 'Invalid name'
}
export const createFormSchema = () =>
z
.object({
namespace: z.string().min(1, 'Please select a namespace'),
newNamespace: z.string().max(255, 'Name must be within 255 characters').optional(),
name: z
.string()
.min(1, 'Provide a name for your table')
.max(255, 'Name must be within 255 characters'),
columns: z
.object({
name: z.string().min(1, 'Provide a name for your column'),
type: z.string().min(1, 'Select a type for your column'),
// For decimal type
precision: z.number().optional(),
scale: z.number().int().optional(),
// For fixed type
length: z.number().int().optional(),
})
.array()
.default([]),
})
.superRefine((data, ctx) => {
if (data.namespace === NEW_NAMESPACE_MARKER) {
if (data.newNamespace) {
const newNamespaceError = validateName({
name: data.newNamespace,
type: 'namespace',
})
if (newNamespaceError) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: newNamespaceError,
path: ['newNamespace'],
})
}
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide a name for your new namespace',
path: ['newNamespace'],
})
}
}
if (data.name) {
const newTableError = validateName({ name: data.name, type: 'table' })
if (newTableError) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: newTableError,
path: ['name'],
})
}
}
})
@@ -0,0 +1,372 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Plus, X } from 'lucide-react'
import { Fragment, useState } from 'react'
import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { useParams } from 'common'
import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation'
import {
NamespaceTableFields,
useIcebergNamespaceTableCreateMutation,
} from 'data/storage/iceberg-namespace-table-create-mutation'
import { useIcebergNamespaceTablesQuery } from 'data/storage/iceberg-namespace-tables-query'
import { useIcebergNamespacesQuery } from 'data/storage/iceberg-namespaces-query'
import {
Button,
DialogSectionSeparator,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectSeparator_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { COLUMN_TYPE_FIELDS, COLUMN_TYPES } from './CreateTableSheet.constants'
import { createFormSchema } from './CreateTableSheet.schema'
const formId = 'create-namespace-table'
const NEW_NAMESPACE_MARKER = 'new-namespace'
interface CreateTableSheetProps {
open: boolean
onOpenChange: (value: boolean) => void
}
export const CreateTableSheet = ({ open, onOpenChange }: CreateTableSheetProps) => {
const { ref: projectRef, bucketId } = useParams()
const [isCreating, setIsCreating] = useState(false)
const FormSchema = createFormSchema()
const defaultValues = {
namespace: '',
newNamespace: undefined,
name: '',
columns: [{ name: '', type: 'string' as any }],
}
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues,
mode: 'onChange',
})
const { namespace } = form.watch()
const {
fields: columns,
append: appendColumn,
remove: removeColumn,
} = useFieldArray({ control: form.control, name: 'columns' })
const { data: namespaces = [] } = useIcebergNamespacesQuery({ projectRef, warehouse: bucketId })
const { data: tables = [] } = useIcebergNamespaceTablesQuery(
{
projectRef,
warehouse: bucketId,
namespace,
},
{ enabled: namespace !== NEW_NAMESPACE_MARKER }
)
const { mutateAsync: createNamespace } = useIcebergNamespaceCreateMutation()
const { mutateAsync: createTable } = useIcebergNamespaceTableCreateMutation()
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
if (!bucketId) return console.error('Bucket ID is missing')
if (namespaces.includes(values.newNamespace ?? '')) {
return form.setError('newNamespace', { message: 'Namespace name already exists' })
}
if (tables.includes(values.name ?? '')) {
return form.setError('name', { message: 'Table name already exists' })
}
const isCreatingNewNamespace =
values.namespace === NEW_NAMESPACE_MARKER && !!values.newNamespace
try {
setIsCreating(true)
if (isCreatingNewNamespace) {
await createNamespace({
projectRef,
warehouse: bucketId,
namespace: values.newNamespace as string,
})
}
const fields = values.columns.map((column, idx) => {
return {
id: idx + 1,
name: column.name,
type:
column.type === 'decimal'
? `decimal(${column.precision}, ${column.scale})`
: column.type === 'fixed'
? `fixed[${column.length}]`
: column.type,
required: false,
}
}) as NamespaceTableFields
await createTable({
projectRef,
warehouse: bucketId,
namespace: isCreatingNewNamespace ? (values.newNamespace as string) : values.namespace,
name: values.name,
fields,
})
toast.success(`Successfully created table in ${values.newNamespace ?? values.namespace}!`)
onOpenChange(false)
form.reset(defaultValues)
} catch (error) {
} finally {
setIsCreating(false)
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<Form_Shadcn_ {...form}>
<form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<SheetContent aria-describedby={undefined} className="flex flex-col gap-0">
<SheetHeader className="shrink-0 flex items-center gap-4">
<SheetTitle>Create a new table</SheetTitle>
</SheetHeader>
<SheetSection className="overflow-auto flex-grow p-0">
<div className="flex flex-col gap-y-4 py-4 px-5">
<FormField_Shadcn_
name="namespace"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="namespace"
label="Select a namespace to create your table in"
>
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
onValueChange={(value) => {
field.onChange(value)
form.resetField('newNamespace')
}}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select a namespace" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{namespaces.map((x) => (
<SelectItem_Shadcn_ key={x} value={x}>
{x}
</SelectItem_Shadcn_>
))}
{namespaces.length > 0 && <SelectSeparator_Shadcn_ />}
<SelectItem_Shadcn_ value={NEW_NAMESPACE_MARKER}>
<div className="flex items-center gap-x-2">
<Plus size={14} />
<p>Create a new namespace</p>
</div>
</SelectItem_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{namespace === NEW_NAMESPACE_MARKER && (
<FormField_Shadcn_
name="newNamespace"
control={form.control}
render={({ field }) => (
<FormItemLayout name="newNamespace" label="Name of new namespace">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="Provide a name for your new namespace"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
</div>
<DialogSectionSeparator />
{!!namespace && (
<div className="px-5 py-4 flex flex-col gap-y-4">
<FormField_Shadcn_
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout name="name" label="Name of table">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="Provide a name for your new table"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<div className="flex flex-col gap-y-2">
<div className="flex items-center justify-between">
<p className="text-sm">Columns</p>
<Button
type="default"
icon={<Plus />}
onClick={() => appendColumn({ name: '', type: 'string' })}
>
Add column
</Button>
</div>
{columns.length === 0 ? (
<div className="flex items-center justify-center rounded border border-strong border-dashed py-4 text-foreground-lighter text-sm">
Add a column to your table
</div>
) : (
<>
<div className="grid grid-cols-[1fr,1fr,32px]">
<p className="text-xs text-foreground-lighter">Name</p>
<p className="text-xs text-foreground-lighter">Type</p>
</div>
{columns.map((_, idx) => {
const columnType = form.watch(`columns.${idx}.type`)
const additionalFields =
COLUMN_TYPE_FIELDS[columnType as keyof typeof COLUMN_TYPE_FIELDS] ?? []
return (
<Fragment key={`column-${idx}`}>
<div className="grid grid-cols-[1fr,1fr,32px] gap-x-1">
<FormField_Shadcn_
control={form.control}
name={`columns.${idx}.name`}
render={({ field }) => (
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="Provide a column name"
disabled={isCreating}
className="h-auto"
/>
</FormControl_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name={`columns.${idx}.type`}
render={({ field }) => (
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger_Shadcn_ className="h-auto">
<SelectValue_Shadcn_ placeholder="Select a type" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{COLUMN_TYPES.map((x) => (
<SelectItem_Shadcn_ key={x} value={x}>
{x}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
)}
/>
<div className="flex items-center justify-center">
<Button
type="text"
size="tiny"
icon={<X strokeWidth={1.5} size={14} />}
className="w-6 h-6"
onClick={() => removeColumn(idx)}
/>
</div>
{additionalFields.length > 0 && (
<div className="col-span-full flex items-center mt-2">
<div className="grid grid-cols-2 gap-x-1 w-[85%]">
{additionalFields.map((x, index) => (
<div
className="flex items-center"
style={
additionalFields.length % 2 === 1 && index % 2 === 0
? { gridColumnStart: 2 }
: undefined
}
>
<div className="font-mono text-xs px-2 border border-r-0 rounded-l h-full flex items-center justify-center">
{x.name}
</div>
<FormField_Shadcn_
control={form.control}
key={`columns.${idx}.${x.name}`}
name={`columns.${idx}.${x.name}` as any}
render={({ field }) => (
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type={x.type === 'number' ? 'number' : 'text'}
disabled={isCreating}
className="h-[34px] rounded-l-none"
{...form.register(
`columns.${idx}.${x.name}` as any,
{
valueAsNumber: true, // Ensure the value is handled as a number
}
)}
/>
</FormControl_Shadcn_>
)}
/>
</div>
))}
</div>
<div className="w-4 h-[1.6rem] border-r border-b rounded-br mr-3 border-control -translate-y-3" />
</div>
)}
</div>
</Fragment>
)
})}
</>
)}
</div>
</div>
)}
</SheetSection>
<SheetFooter>
<Button
disabled={isCreating}
type="default"
onClick={() => {
onOpenChange(false)
form.reset(defaultValues)
}}
>
Cancel
</Button>
<Button form={formId} htmlType="submit" loading={isCreating}>
Create table
</Button>
</SheetFooter>
</SheetContent>
</form>
</Form_Shadcn_>
</Sheet>
)
}
@@ -1,32 +0,0 @@
import { Plus } from 'lucide-react'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from 'ui'
import { CreateTableInstructions } from '.'
export const CreateTableInstructionsDialog = () => {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="primary" icon={<Plus />}>
Create table
</Button>
</DialogTrigger>
<DialogContent size="xlarge">
<DialogHeader>
<DialogTitle>Adding tables to your Analytics Bucket</DialogTitle>
<DialogDescription>
Tables can be created or added to your bucket via Pyiceberg
</DialogDescription>
</DialogHeader>
<CreateTableInstructions hideHeader className="rounded-t-none border-x-0 border-b-0" />
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,87 @@
import { useParams } from 'common'
import { DocsButton } from 'components/ui/DocsButton'
import { FDWTable } from 'data/fdw/fdws-query'
import { SqlEditor } from 'icons'
import { DOCS_URL } from 'lib/constants'
import Link from 'next/link'
import {
Button,
cn,
CodeBlock,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
} from 'ui'
interface InsertDataDialogProps {
table: string
fdwTable: FDWTable
}
export const InsertDataDialog = ({ table, fdwTable }: InsertDataDialogProps) => {
const { ref } = useParams()
const sql = /* SQL */ `
insert into ${fdwTable.schema}.${fdwTable.name} (
-- specify columns
)
values (
-- specify values for each column
);
`.trim()
return (
<Dialog>
<DialogTrigger asChild>
<Button type="default">Insert data</Button>
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>
Insert data into <code className="text-code-inline">{table}</code>
</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-y-2">
<p className="text-sm">
The Iceberg Foreign Data Wrapper (FDW) supports inserting data into Iceberg tables using
standard SQL <code className="text-code-inline">INSERT</code> statements.
</p>
<p className="text-sm">
Use the following SQL snippet to insert data into your iceberg table:
</p>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="!p-0">
<CodeBlock
hideLineNumbers
wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-3 [&>pre]:rounded-none [&>pre]:border-0')}
className="[&_code]:text-foreground"
language="sql"
value={sql}
/>
</DialogSection>
<DialogFooter>
<DocsButton
href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg#data-insertion`}
/>
<Button asChild type="default" icon={<SqlEditor />}>
<Link href={`/project/${ref}/sql/new?content=${encodeURIComponent(sql)}`}>
Open in SQL Editor
</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -12,12 +12,12 @@ import {
import { getDecryptedParameters } from 'components/interfaces/Storage/ImportForeignSchemaDialog.utils'
import { DotPing } from 'components/ui/DotPing'
import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation'
import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
import { useUpdatePublicationMutation } from 'data/replication/publication-update-mutation'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
import { useReplicationTablesQuery } from 'data/replication/tables-query'
import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation'
import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
import { useIcebergNamespaceTableDeleteMutation } from 'data/storage/iceberg-namespace-table-delete-mutation'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { SqlEditor, TableEditor } from 'icons'
@@ -43,6 +43,7 @@ import {
} from '../AnalyticsBucketDetails.utils'
import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities'
import { useAnalyticsBucketWrapperInstance } from '../useAnalyticsBucketWrapperInstance'
import { InsertDataDialog } from './InsertDataDialog'
import { inferPostgresTableFromNamespaceTable } from './NamespaceWithTables.utils'
interface TableRowComponentProps {
@@ -78,7 +79,7 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen
const { mutateAsync: updateFDW } = useFDWUpdateMutation()
const { mutateAsync: dropForeignTable } = useFDWDropForeignTableMutation()
const { mutateAsync: deleteNamespaceTable, isLoading: isDeletingNamespaceTable } =
const { mutateAsync: deleteNamespaceTable, isPending: isDeletingNamespaceTable } =
useIcebergNamespaceTableDeleteMutation({ onError: () => {} })
const { mutateAsync: updatePublication } = useUpdatePublicationMutation()
const { mutateAsync: startPipeline } = useStartPipelineMutation()
@@ -227,7 +228,6 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
await deleteNamespaceTable({
projectRef,
catalogUri: wrapperValues.catalog_uri,
warehouse: wrapperValues.warehouse,
namespace: namespace,
table: table.name,
@@ -257,7 +257,6 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
await deleteNamespaceTable({
projectRef,
catalogUri: wrapperValues.catalog_uri,
warehouse: wrapperValues.warehouse,
namespace: namespace,
table: table.name,
@@ -419,7 +418,7 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen
<TableRow key={x.id}>
<TableCell className="pl-6">
<div className="flex items-center gap-x-2 rounded">
<div className="w-4 h-4 rounded-bl-lg border-l-2 border-b-2 border-control -translate-y-1.5" />
<div className="w-4 h-5 rounded-bl-lg border-l-2 border-b-2 border-control -translate-y-2" />
<div
className={cn(
'flex items-center justify-center text-xs h-4 w-4 rounded-[2px] font-bold',
@@ -433,7 +432,8 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen
</p>
</div>
</TableCell>
<TableCell className="flex flex-row justify-end">
<TableCell className="flex flex-row justify-end gap-x-2">
<InsertDataDialog table={table.name} fdwTable={x} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="w-7" icon={<MoreVertical />} />
@@ -5,7 +5,6 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog'
import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation'
import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation'
@@ -79,7 +78,6 @@ export const NamespaceWithTables = ({
isSuccess: isSuccessNamespaceTables,
} = useIcebergNamespaceTablesQuery(
{
catalogUri: wrapperValues.catalog_uri,
warehouse: wrapperValues.warehouse,
namespace: namespace,
projectRef,
@@ -179,11 +177,6 @@ export const NamespaceWithTables = ({
const onConfirmDeleteNamespace = async () => {
if (!bucketId) return console.error('Bucket ID is required')
// Construct catalog URI for namespace creation
const protocol = projectSettings?.app_config?.protocol ?? 'https'
const endpoint =
projectSettings?.app_config?.storage_endpoint || projectSettings?.app_config?.endpoint
const catalogUri = getCatalogURI(project?.ref ?? '', protocol, endpoint)
try {
setIsDeletingNamespace(true)
@@ -193,7 +186,6 @@ export const NamespaceWithTables = ({
allTables.map((table) =>
deleteNamespaceTable({
projectRef,
catalogUri,
warehouse: bucketId,
namespace,
table: table.name,
@@ -213,7 +205,7 @@ export const NamespaceWithTables = ({
)
)
await deleteNamespace({ projectRef, catalogUri, warehouse: bucketId, namespace })
await deleteNamespace({ projectRef, warehouse: bucketId, namespace })
toast.success(`Successfully deleted namespace "${namespace}"`)
setShowConfirmDeleteNamespace(false)
@@ -38,7 +38,7 @@ import { useSelectedAnalyticsBucket } from '../useSelectedAnalyticsBucket'
import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants'
import { BucketHeader } from './BucketHeader'
import { ConnectTablesDialog } from './ConnectTablesDialog'
import { CreateTableInstructions } from './CreateTableInstructions'
import { CreateTableInstructions } from './CreateTable/CreateTableInstructions'
import { NamespaceWithTables } from './NamespaceWithTables'
import { SimpleConfigurationDetails } from './SimpleConfigurationDetails'
import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities'
@@ -120,7 +120,6 @@ export const AnalyticBucketDetails = () => {
} = useIcebergNamespacesQuery(
{
projectRef,
catalogUri: wrapperValues.catalog_uri,
warehouse: wrapperValues.warehouse,
},
{
@@ -1,28 +1,21 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Plus } from 'lucide-react'
import { useRouter } from 'next/router'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useIsAnalyticsBucketsEnabled } from 'data/config/project-storage-config-query'
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
import { useAnalyticsBucketCreateMutation } from 'data/storage/analytics-bucket-create-mutation'
import { useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query'
import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import {
Button,
cn,
Dialog,
DialogContent,
DialogFooter,
@@ -30,7 +23,6 @@ import {
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
@@ -122,40 +114,22 @@ const formId = 'create-analytics-storage-bucket-form'
export type CreateAnalyticsBucketForm = z.infer<typeof FormSchema>
interface CreateAnalyticsBucketModalProps {
buttonSize?: 'tiny' | 'small'
buttonType?: 'default' | 'primary'
buttonClassName?: string
disabled?: boolean
tooltip?: {
content: {
side?: 'top' | 'bottom' | 'left' | 'right'
text?: string
}
}
open: boolean
onOpenChange: (value: boolean) => void
}
export const CreateAnalyticsBucketModal = ({
buttonSize = 'tiny',
buttonType = 'default',
buttonClassName,
disabled = false,
tooltip,
open,
onOpenChange,
}: CreateAnalyticsBucketModalProps) => {
const router = useRouter()
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const { data: project } = useSelectedProjectQuery()
const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
const { extension: wrappersExtension, state: wrappersExtensionState } =
useIcebergWrapperExtension()
const [visible, setVisible] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const { data: buckets = [], isLoading } = useAnalyticsBucketsQuery({ projectRef: ref })
const icebergCatalogEnabled = useIsAnalyticsBucketsEnabled({ projectRef: ref })
const { data: buckets = [] } = useAnalyticsBucketsQuery({ projectRef: ref })
const wrappersExtenstionNeedsUpgrading = wrappersExtensionState === 'needs-upgrade'
const { mutate: sendEvent } = useSendEventMutation()
@@ -174,8 +148,6 @@ export const CreateAnalyticsBucketModal = ({
const config = BUCKET_TYPES['analytics']
const isCreating = isEnablingExtension || isCreatingIcebergWrapper || isCreatingAnalyticsBucket
const isDisabled =
!canCreateBuckets || !icebergCatalogEnabled || isLoading || buckets.length >= 2 || disabled
const form = useForm<CreateAnalyticsBucketForm>({
resolver: zodResolver(FormSchema),
@@ -217,8 +189,7 @@ export const CreateAnalyticsBucketModal = ({
form.reset()
toast.success(`Created bucket “${values.name}”`)
setVisible(false)
router.push(`/project/${ref}/storage/analytics/buckets/${values.name}`)
onOpenChange(false)
} catch (error: any) {
toast.error(`Failed to create bucket: ${error.message}`)
}
@@ -226,44 +197,16 @@ export const CreateAnalyticsBucketModal = ({
const handleClose = () => {
form.reset()
setVisible(false)
onOpenChange(false)
}
return (
<Dialog
open={visible}
open={open}
onOpenChange={(open) => {
if (!open) handleClose()
}}
>
<DialogTrigger asChild>
<ButtonTooltip
block
size={buttonSize}
type={buttonType}
className={buttonClassName}
icon={<Plus size={14} />}
disabled={isDisabled}
style={{ justifyContent: 'start' }}
onClick={() => setVisible(true)}
tooltip={{
content: {
side: tooltip?.content?.side || 'bottom',
className: cn(!icebergCatalogEnabled ? 'w-72 text-center' : ''),
text: !icebergCatalogEnabled
? 'Analytics buckets are not enabled for your project. Please contact support to enable it.'
: !canCreateBuckets
? 'You need additional permissions to create buckets'
: buckets.length >= 2
? 'Bucket limit reached'
: tooltip?.content?.text,
},
}}
>
New bucket
</ButtonTooltip>
</DialogTrigger>
<DialogContent size="large" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Create {config.singularName} bucket</DialogTitle>
@@ -336,7 +279,7 @@ export const CreateAnalyticsBucketModal = ({
</Form_Shadcn_>
<DialogFooter>
<Button type="default" disabled={isCreating} onClick={() => setVisible(false)}>
<Button type="default" disabled={isCreating} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
@@ -1,5 +1,6 @@
import { ChevronRight, Search } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useState } from 'react'
import { useParams } from 'common'
@@ -26,6 +27,7 @@ import { Input } from 'ui-patterns/DataInputs/Input'
import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent, PageSectionTitle } from 'ui-patterns/PageSection'
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateBucketButton } from '../NewBucketButton'
import { CreateAnalyticsBucketModal } from './CreateAnalyticsBucketModal'
export const AnalyticsBuckets = () => {
@@ -34,6 +36,11 @@ export const AnalyticsBuckets = () => {
const [filterString, setFilterString] = useState('')
const [visible, setVisible] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const {
data: buckets = [],
error: bucketsError,
@@ -64,133 +71,142 @@ export const AnalyticsBuckets = () => {
}
return (
<PageContainer>
<PageSection>
<PageSectionContent>
<AlphaNotice
entity="Analytics buckets"
feedbackUrl="https://github.com/orgs/supabase/discussions/40116"
/>
<>
<PageContainer>
<PageSection>
<PageSectionContent>
<AlphaNotice
entity="Analytics buckets"
feedbackUrl="https://github.com/orgs/supabase/discussions/40116"
/>
{isLoadingBuckets && <GenericSkeletonLoader />}
{isLoadingBuckets && <GenericSkeletonLoader />}
{isErrorBuckets && (
<AlertError error={bucketsError} subject="Failed to retrieve analytics buckets" />
)}
{isErrorBuckets && (
<AlertError error={bucketsError} subject="Failed to retrieve analytics buckets" />
)}
{isSuccessBuckets && (
<>
{hasNoBuckets ? (
<EmptyBucketState bucketType="analytics" />
) : (
<div className="flex flex-col gap-y-4">
<div className="flex flex-row items-center gap-x-2">
<PageSectionTitle>Buckets</PageSectionTitle>
{analyticsBuckets.length > 0 && (
<Tooltip>
<TooltipTrigger>
<span className="bg-surface-200 rounded-full px-2 py-1 leading-none text-xs text-foreground-lighter tracking-widest">
{analyticsBuckets.length}
/2
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-64 text-center">
Each project can only have up to 2 buckets while analytics buckets are in
alpha{' '}
</TooltipContent>
</Tooltip>
{isSuccessBuckets && (
<>
{hasNoBuckets ? (
<EmptyBucketState
bucketType="analytics"
onCreateBucket={() => setVisible(true)}
/>
) : (
<div className="flex flex-col gap-y-4">
<div className="flex flex-row items-center gap-x-2">
<PageSectionTitle>Buckets</PageSectionTitle>
{analyticsBuckets.length > 0 && (
<Tooltip>
<TooltipTrigger>
<span className="bg-surface-200 rounded-full px-2 py-1 leading-none text-xs text-foreground-lighter tracking-widest">
{analyticsBuckets.length}
/2
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-64 text-center">
Each project can only have up to 2 buckets while analytics buckets are
in alpha{' '}
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex flex-grow justify-between gap-x-2 items-center">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<CreateBucketButton onClick={() => setVisible(true)} />
</div>
{isLoadingBuckets ? (
<GenericSkeletonLoader />
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
{analyticsBuckets.length > 0 && (
<TableHead className="w-2 pr-1">
<span className="sr-only">Icon</span>
</TableHead>
)}
<TableHead>Name</TableHead>
<TableHead>Created at</TableHead>
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analyticsBuckets.length === 0 && filterString.length > 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</TableCell>
</TableRow>
)}
{analyticsBuckets.map((bucket) => (
<TableRow
key={bucket.name}
className="relative cursor-pointer h-16 inset-focus"
onClick={(event) => handleBucketNavigation(bucket.name, event)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleBucketNavigation(bucket.name, event)
}
}}
tabIndex={0}
>
<TableCell className="w-2 pr-1">
<AnalyticsBucketIcon
size={16}
className="text-foreground-muted"
/>
</TableCell>
<TableCell>
<p className="whitespace-nowrap max-w-[512px] truncate">
{bucket.name}
</p>
</TableCell>
<TableCell>
<p className="text-foreground-light">
<TimestampInfo
utcTimestamp={bucket.created_at}
className="text-sm text-foreground-light"
/>
</p>
</TableCell>
<TableCell>
<div className="flex justify-end items-center h-full">
<ChevronRight size={14} className="text-foreground-muted/60" />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
<div className="flex flex-grow justify-between gap-x-2 items-center">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<CreateAnalyticsBucketModal buttonType="primary" buttonClassName="w-fit" />
</div>
{isLoadingBuckets ? (
<GenericSkeletonLoader />
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
{analyticsBuckets.length > 0 && (
<TableHead className="w-2 pr-1">
<span className="sr-only">Icon</span>
</TableHead>
)}
<TableHead>Name</TableHead>
<TableHead>Created at</TableHead>
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analyticsBuckets.length === 0 && filterString.length > 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</TableCell>
</TableRow>
)}
{analyticsBuckets.map((bucket) => (
<TableRow
key={bucket.name}
className="relative cursor-pointer h-16 inset-focus"
onClick={(event) => handleBucketNavigation(bucket.name, event)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleBucketNavigation(bucket.name, event)
}
}}
tabIndex={0}
>
<TableCell className="w-2 pr-1">
<AnalyticsBucketIcon size={16} className="text-foreground-muted" />
</TableCell>
<TableCell>
<p className="whitespace-nowrap max-w-[512px] truncate">
{bucket.name}
</p>
</TableCell>
<TableCell>
<p className="text-foreground-light">
<TimestampInfo
utcTimestamp={bucket.created_at}
className="text-sm text-foreground-light"
/>
</p>
</TableCell>
<TableCell>
<div className="flex justify-end items-center h-full">
<ChevronRight size={14} className="text-foreground-muted/60" />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<CreateAnalyticsBucketModal open={visible} onOpenChange={setVisible} />
</>
)
}
@@ -1,20 +1,15 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useParams } from 'common'
import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import {
@@ -26,7 +21,6 @@ import {
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
@@ -85,30 +79,17 @@ const formId = 'create-storage-bucket-form'
export type CreateBucketForm = z.infer<typeof FormSchema>
interface CreateBucketModalProps {
buttonSize?: 'tiny' | 'small'
buttonType?: 'default' | 'primary'
buttonClassName?: string
label?: string
open: boolean
onOpenChange: (value: boolean) => void
}
export const CreateBucketModal = ({
buttonSize = 'tiny',
buttonType = 'default',
buttonClassName,
label = 'New bucket',
}: CreateBucketModalProps) => {
export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps) => {
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const [visible, setVisible] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.MB)
const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false)
const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
const formattedGlobalUploadLimit = `${value} ${unit}`
@@ -172,7 +153,7 @@ export const CreateBucketModal = ({
toast.success(`Successfully created bucket ${values.name}`)
form.reset()
setSelectedUnit(StorageSizeUnits.MB)
setVisible(false)
onOpenChange(false)
} catch (error: any) {
// Handle specific error cases for inline display
const errorMessage = error.message?.toLowerCase() || ''
@@ -196,42 +177,18 @@ export const CreateBucketModal = ({
const handleClose = () => {
form.reset()
setSelectedUnit(StorageSizeUnits.MB)
setVisible(false)
onOpenChange(false)
}
return (
<Dialog
open={visible}
open={open}
onOpenChange={(open) => {
if (!open) {
handleClose()
}
}}
>
<DialogTrigger asChild>
<ButtonTooltip
block
size={buttonSize}
type={buttonType}
className={buttonClassName}
icon={<Plus size={14} />}
disabled={!canCreateBuckets}
style={{ justifyContent: 'start' }}
onClick={() => setVisible(true)}
tabIndex={!canCreateBuckets ? -1 : 0}
tooltip={{
content: {
side: 'bottom',
text: !canCreateBuckets
? 'You need additional permissions to create buckets'
: undefined,
},
}}
>
{label}
</ButtonTooltip>
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Create file bucket</DialogTitle>
@@ -378,7 +335,7 @@ export const CreateBucketModal = ({
<InlineLink
className="text-destructive decoration-destructive-500 hover:decoration-destructive"
href={`/project/${ref}/storage/settings`}
onClick={() => setVisible(false)}
onClick={() => onOpenChange(false)}
>
Storage Settings
</InlineLink>{' '}
@@ -392,7 +349,7 @@ export const CreateBucketModal = ({
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/project/${ref}/storage/settings`}
onClick={() => setVisible(false)}
onClick={() => onOpenChange(false)}
>
global file size limit
</InlineLink>{' '}
@@ -449,7 +406,7 @@ export const CreateBucketModal = ({
</Form_Shadcn_>
<DialogFooter>
<Button type="default" disabled={isCreatingBucket} onClick={() => setVisible(false)}>
<Button type="default" disabled={isCreatingBucket} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
@@ -1,9 +1,7 @@
import { BucketPlus } from 'icons'
import { EmptyStatePresentational } from 'ui-patterns'
import { CreateAnalyticsBucketModal } from './AnalyticsBuckets/CreateAnalyticsBucketModal'
import { CreateBucketModal } from './CreateBucketModal'
import { CreateBucketButton } from './NewBucketButton'
import { BUCKET_TYPES } from './Storage.constants'
import { CreateVectorBucketButton } from './VectorBuckets/CreateVectorBucketDialog'
interface EmptyBucketStateProps {
bucketType: keyof typeof BUCKET_TYPES
@@ -25,17 +23,7 @@ export const EmptyBucketState = ({
description={config.valueProp}
className={className}
>
{bucketType === 'files' && (
<CreateBucketModal buttonSize="tiny" buttonType="primary" buttonClassName="w-fit" />
)}
{bucketType === 'analytics' && (
<CreateAnalyticsBucketModal
buttonSize="tiny"
buttonType="primary"
buttonClassName="w-fit"
/>
)}
{bucketType === 'vectors' && <CreateVectorBucketButton onClick={onCreateBucket} />}
<CreateBucketButton onClick={onCreateBucket} />
</EmptyStatePresentational>
)
}
@@ -9,6 +9,7 @@ import { useProjectStorageConfigQuery } from 'data/config/project-storage-config
import { useBucketsQuery } from 'data/storage/buckets-query'
import { IS_PLATFORM } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import {
Button,
@@ -25,6 +26,7 @@ import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { CreateBucketModal } from '../CreateBucketModal'
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateBucketButton } from '../NewBucketButton'
import { STORAGE_BUCKET_SORT } from '../Storage.constants'
import { BucketsTable } from './BucketsTable'
@@ -33,6 +35,11 @@ export const FilesBuckets = () => {
const snap = useStorageExplorerStateSnapshot()
const [filterString, setFilterString] = useState('')
const [visible, setVisible] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const {
data: buckets = [],
@@ -67,81 +74,84 @@ export const FilesBuckets = () => {
)
return (
<PageContainer>
<PageSection>
<PageSectionContent className="h-full gap-y-4">
{isLoadingBuckets && <GenericSkeletonLoader />}
{isErrorBuckets && (
<>
{hasNoApiKeys ? (
<Admonition type="warning" title="Project has no active API keys enabled">
<p className="!leading-normal text-sm">
The Dashboard relies on having active API keys on the project to function. If
you'd like to use Storage through the Dashboard, create a set of API keys{' '}
<InlineLink href={`/project/${ref}/settings/api-keys/new`}>here</InlineLink>.
</p>
</Admonition>
) : (
<AlertError error={bucketsError} subject="Failed to retrieve buckets" />
)}
</>
)}
{isSuccessBuckets && (
<>
{hasNoBuckets ? (
<EmptyBucketState bucketType="files" />
) : (
<>
<div className="flex flex-grow justify-between gap-x-2 items-center mb-4">
<div className="flex items-center gap-x-2">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<ArrowDownNarrowWide />}>
Sorted by {snap.sortBucket === 'alphabetical' ? 'name' : 'created at'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuRadioGroup
value={snap.sortBucket}
onValueChange={(value) =>
snap.setSortBucket(value as STORAGE_BUCKET_SORT)
}
>
<DropdownMenuRadioItem value="alphabetical">
Sort by name
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_at">
Sort by created at
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<>
<PageContainer>
<PageSection>
<PageSectionContent className="h-full gap-y-4">
{isLoadingBuckets && <GenericSkeletonLoader />}
{isErrorBuckets && (
<>
{hasNoApiKeys ? (
<Admonition type="warning" title="Project has no active API keys enabled">
<p className="!leading-normal text-sm">
The Dashboard relies on having active API keys on the project to function. If
you'd like to use Storage through the Dashboard, create a set of API keys{' '}
<InlineLink href={`/project/${ref}/settings/api-keys/new`}>here</InlineLink>.
</p>
</Admonition>
) : (
<AlertError error={bucketsError} subject="Failed to retrieve buckets" />
)}
</>
)}
{isSuccessBuckets && (
<>
{hasNoBuckets ? (
<EmptyBucketState bucketType="files" onCreateBucket={() => setVisible(true)} />
) : (
<>
<div className="flex flex-grow justify-between gap-x-2 items-center mb-4">
<div className="flex items-center gap-x-2">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<ArrowDownNarrowWide />}>
Sorted by {snap.sortBucket === 'alphabetical' ? 'name' : 'created at'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuRadioGroup
value={snap.sortBucket}
onValueChange={(value) =>
snap.setSortBucket(value as STORAGE_BUCKET_SORT)
}
>
<DropdownMenuRadioItem value="alphabetical">
Sort by name
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_at">
Sort by created at
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CreateBucketButton onClick={() => setVisible(true)} />
</div>
<CreateBucketModal buttonType="primary" buttonClassName="w-fit" />
</div>
<Card>
<BucketsTable
buckets={sortedFilesBuckets}
projectRef={ref ?? '_'}
filterString={filterString}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
/>
</Card>
</>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<Card>
<BucketsTable
buckets={sortedFilesBuckets}
projectRef={ref ?? '_'}
filterString={filterString}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
/>
</Card>
</>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<CreateBucketModal open={visible} onOpenChange={setVisible} />
</>
)
}
@@ -0,0 +1,34 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Plus } from 'lucide-react'
import { MouseEventHandler } from 'react'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
export const CreateBucketButton = ({
onClick,
}: {
onClick?: MouseEventHandler<HTMLButtonElement>
}) => {
const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
return (
<ButtonTooltip
block
size="tiny"
type="primary"
className="w-fit"
icon={<Plus size={14} />}
disabled={!canCreateBuckets}
onClick={onClick}
tooltip={{
content: {
side: 'bottom',
text: !canCreateBuckets ? 'You need additional permissions to create buckets' : undefined,
},
}}
>
New bucket
</ButtonTooltip>
)
}
@@ -102,9 +102,25 @@ const StoragePoliciesEditPolicyModal = ({
allowedOperations: [operation],
})
}
const updatedAllowedOperations = policyFormFields.allowedOperations.includes(operation)
? pull(policyFormFields.allowedOperations.slice(), operation)
: policyFormFields.allowedOperations.concat([operation])
const currentOps = policyFormFields.allowedOperations
const isRemoving = currentOps.includes(operation)
let updatedAllowedOperations = isRemoving
? pull(currentOps.slice(), operation)
: currentOps.concat([operation])
if (!isRemoving && (operation === 'UPDATE' || operation === 'DELETE')) {
if (!updatedAllowedOperations.includes('SELECT')) {
updatedAllowedOperations = updatedAllowedOperations.concat(['SELECT'])
}
}
if (isRemoving && operation === 'SELECT') {
updatedAllowedOperations = updatedAllowedOperations.filter(
(op: string) => op !== 'UPDATE' && op !== 'DELETE'
)
}
return setPolicyFormFields({
...policyFormFields,
allowedOperations: updatedAllowedOperations,
@@ -28,6 +28,9 @@ const PolicyDefinition = ({ definition = '', onUpdatePolicyDefinition = () => {}
const PolicyAllowedOperations = ({ allowedOperations = [], onToggleOperation = () => {} }: any) => {
const allowedClientLibraryMethods = deriveAllowedClientLibraryMethods(allowedOperations)
const hasUpdateOrDelete =
allowedOperations.includes('UPDATE') || allowedOperations.includes('DELETE')
return (
<div className="flex flex-col md:flex-row justify-between gap-4 md:gap-12">
<div className="flex md:w-1/3 flex-col space-y-2">
@@ -70,6 +73,12 @@ const PolicyAllowedOperations = ({ allowedOperations = [], onToggleOperation = (
checked={allowedOperations.includes('DELETE')}
/>
</div>
{hasUpdateOrDelete && (
<p className="text-sm text-foreground-light mt-3 prose [&>code]:text-xs">
<code>SELECT</code> has been auto selected as <code>UPDATE</code> and{' '}
<code>DELETE</code> require it
</p>
)}
<div className="flex w-5/6 flex-wrap">
{Object.keys(STORAGE_CLIENT_LIBRARY_MAPPINGS).map((method) => (
<ul key={method} className="mr-2 mt-2 list-none">
@@ -89,7 +89,9 @@ export const S3Connection = () => {
const { mutate: updateStorageConfig, isPending: isUpdating } =
useProjectStorageConfigUpdateUpdateMutation({
onSuccess: (_, vars) => {
if (vars.features) form.reset({ s3ConnectionEnabled: vars.features.s3Protocol.enabled })
if (vars.features?.s3Protocol) {
form.reset({ s3ConnectionEnabled: vars.features.s3Protocol.enabled })
}
toast.success('Successfully updated storage settings')
},
})
@@ -1,7 +1,7 @@
export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_FREE_PLAN = 50 * 1024 * 1024 // 50 MB
/** Max bytes for capped storage (i.e. Pro with spend cap) */
export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED = 50 * 1024 * 1024 * 1024 // 50 GB
/** Max bytes for uncapped storage (i.e. Pro without spend cap, Team, Enterprise, Platform) */
/** Max bytes for uncapped storage (i.e. Pro without spend cap, Team, Enterprise) */
export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED = 500 * 1024 * 1024 * 1024 // 500 GB
export enum StorageSizeUnits {
@@ -1,13 +1,10 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Plus } from 'lucide-react'
import { MouseEventHandler, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
import { useSchemaCreateMutation } from 'data/database/schema-create-mutation'
@@ -15,7 +12,6 @@ import { useS3VectorsWrapperCreateMutation } from 'data/storage/s3-vectors-wrapp
import { useVectorBucketCreateMutation } from 'data/storage/vector-bucket-create-mutation'
import { useVectorBucketsQuery } from 'data/storage/vector-buckets-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
@@ -87,34 +83,6 @@ const formId = 'create-storage-bucket-form'
export type CreateBucketForm = z.infer<typeof FormSchema>
export const CreateVectorBucketButton = ({
onClick,
}: {
onClick?: MouseEventHandler<HTMLButtonElement>
}) => {
const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
return (
<ButtonTooltip
block
size="tiny"
type="primary"
className="w-fit"
icon={<Plus size={14} />}
disabled={!canCreateBuckets}
onClick={onClick}
tooltip={{
content: {
side: 'bottom',
text: !canCreateBuckets ? 'You need additional permissions to create buckets' : undefined,
},
}}
>
New bucket
</ButtonTooltip>
)
}
export const CreateVectorBucketDialog = ({
visible,
setVisible,
@@ -327,7 +327,9 @@ export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetPro
<SheetSection className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground">Metadata keys</label>
<DocsButton href={`${DOCS_URL}/guides/storage/vector/introduction`} />
<DocsButton
href={`${DOCS_URL}/guides/storage/vector/storing-vectors#metadata-best-practices`}
/>
</div>
<div className="space-y-2">
{fields.map((field, index) => (
@@ -16,7 +16,8 @@ import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent, PageSectionTitle } from 'ui-patterns/PageSection'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateVectorBucketButton, CreateVectorBucketDialog } from './CreateVectorBucketDialog'
import { CreateBucketButton } from '../NewBucketButton'
import { CreateVectorBucketDialog } from './CreateVectorBucketDialog'
/**
* [Joshen] Low-priority refactor: We should use a virtualized table here as per how we do it
@@ -59,124 +60,131 @@ export const VectorsBuckets = () => {
}
return (
<PageContainer>
<PageSection>
<PageSectionContent>
<AlphaNotice
entity="Vector buckets"
feedbackUrl="https://github.com/orgs/supabase/discussions/40815"
/>
<>
<PageContainer>
<PageSection>
<PageSectionContent>
<AlphaNotice
entity="Vector buckets"
feedbackUrl="https://github.com/orgs/supabase/discussions/40815"
/>
{isLoadingBuckets && <GenericSkeletonLoader />}
{isLoadingBuckets && <GenericSkeletonLoader />}
{isErrorBuckets && (
<AlertError error={bucketsError} subject="Failed to retrieve vector buckets" />
)}
{isErrorBuckets && (
<AlertError error={bucketsError} subject="Failed to retrieve vector buckets" />
)}
{isSuccessBuckets && (
<>
{bucketsList.length === 0 ? (
<EmptyBucketState bucketType="vectors" onCreateBucket={() => setVisible(true)} />
) : (
<div className="flex flex-col gap-y-4">
<div className="py-0">
<PageSectionTitle>Buckets</PageSectionTitle>
</div>
<div className="flex flex-grow justify-between gap-x-2 items-center">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
{isSuccessBuckets && (
<>
{bucketsList.length === 0 ? (
<EmptyBucketState bucketType="vectors" onCreateBucket={() => setVisible(true)} />
) : (
<div className="flex flex-col gap-y-4">
<div className="py-0">
<PageSectionTitle>Buckets</PageSectionTitle>
</div>
<div className="flex flex-grow justify-between gap-x-2 items-center">
<Input
size="tiny"
className="flex-grow lg:flex-grow-0 w-52"
placeholder="Search for a bucket"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<CreateVectorBucketButton onClick={() => setVisible(true)} />
</div>
<CreateBucketButton onClick={() => setVisible(true)} />
</div>
{isLoadingBuckets ? (
<GenericSkeletonLoader />
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
{filteredBuckets.length > 0 && (
<TableHead className="w-2 pr-1">
<span className="sr-only">Icon</span>
{isLoadingBuckets ? (
<GenericSkeletonLoader />
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
{filteredBuckets.length > 0 && (
<TableHead className="w-2 pr-1">
<span className="sr-only">Icon</span>
</TableHead>
)}
<TableHead>Name</TableHead>
<TableHead>Created at</TableHead>
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
)}
<TableHead>Name</TableHead>
<TableHead>Created at</TableHead>
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredBuckets.length === 0 && filterString.length > 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-lighter">
Your search for "{filterString}" did not return any results
</p>
</TableCell>
</TableRow>
)}
{filteredBuckets.map((bucket, idx: number) => {
const id = `bucket-${idx}`
const name = bucket.vectorBucketName
// the creation time is in seconds, convert it to milliseconds
const created = +bucket.creationTime * 1000
return (
<TableRow
key={id}
className="relative cursor-pointer h-16 inset-focus"
onClick={(event) => handleBucketNavigation(name, event)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleBucketNavigation(name, event)
}
}}
tabIndex={0}
>
<TableCell className="w-2 pr-1">
<VectorBucketIcon size={16} className="text-foreground-muted" />
</TableCell>
<TableCell>
<p className="whitespace-nowrap max-w-[512px] truncate">{name}</p>
</TableCell>
<TableCell>
<p className="text-foreground-light">
<TimestampInfo
utcTimestamp={created}
className="text-sm text-foreground-light"
/>
</TableHeader>
<TableBody>
{filteredBuckets.length === 0 && filterString.length > 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-lighter">
Your search for "{filterString}" did not return any results
</p>
</TableCell>
<TableCell>
<div className="flex justify-end items-center h-full">
<ChevronRight size={14} className="text-foreground-muted/60" />
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
)}
</div>
)}
</>
)}
<CreateVectorBucketDialog visible={visible} setVisible={setVisible} />
</PageSectionContent>
</PageSection>
</PageContainer>
)}
{filteredBuckets.map((bucket, idx: number) => {
const id = `bucket-${idx}`
const name = bucket.vectorBucketName
// the creation time is in seconds, convert it to milliseconds
const created = +bucket.creationTime * 1000
return (
<TableRow
key={id}
className="relative cursor-pointer h-16 inset-focus"
onClick={(event) => handleBucketNavigation(name, event)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleBucketNavigation(name, event)
}
}}
tabIndex={0}
>
<TableCell className="w-2 pr-1">
<VectorBucketIcon size={16} className="text-foreground-muted" />
</TableCell>
<TableCell>
<p className="whitespace-nowrap max-w-[512px] truncate">
{name}
</p>
</TableCell>
<TableCell>
<p className="text-foreground-light">
<TimestampInfo
utcTimestamp={created}
className="text-sm text-foreground-light"
/>
</p>
</TableCell>
<TableCell>
<div className="flex justify-end items-center h-full">
<ChevronRight
size={14}
className="text-foreground-muted/60"
/>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
)}
</div>
)}
</>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<CreateVectorBucketDialog visible={visible} setVisible={setVisible} />
</>
)
}
@@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import { addAPIMock } from 'tests/lib/msw'
import { customRender } from 'tests/lib/custom-render'
import { addAPIMock } from 'tests/lib/msw'
import { routerMock } from 'tests/lib/route-mock'
import { CreateBucketModal } from '../CreateBucketModal'
@@ -42,7 +42,7 @@ describe(`CreateBucketModal`, () => {
it(`renders a dialog with a form`, async () => {
customRender(
<ProjectContextProvider projectRef="default">
<CreateBucketModal />
<CreateBucketModal open={true} onOpenChange={() => {}} />
</ProjectContextProvider>,
{
nuqs: {
@@ -62,7 +62,6 @@ export function ProjectAndPlanInfo({
{showPlanExpectationInfo &&
orgSlug &&
subscriptionPlanId !== 'enterprise' &&
subscriptionPlanId !== 'platform' &&
category !== 'Login_issues' && (
<PlanExpectationInfoBox orgSlug={orgSlug} planId={subscriptionPlanId} />
)}
@@ -1,13 +1,10 @@
import { Lightbulb, TriangleAlert } from 'lucide-react'
import { useState } from 'react'
import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_ } from 'ui'
import { FeedbackWidget } from './FeedbackWidget'
export const FeedbackDropdown = ({ className }: { className?: string }) => {
const [isOpen, setIsOpen] = useState(false)
const [stage, setStage] = useState<'select' | 'widget'>('select')
return (
<Popover_Shadcn_
@@ -15,7 +12,6 @@ export const FeedbackDropdown = ({ className }: { className?: string }) => {
open={isOpen}
onOpenChange={(e) => {
setIsOpen(e)
if (!e) setStage('select')
}}
>
<PopoverTrigger_Shadcn_ asChild>
@@ -23,7 +19,6 @@ export const FeedbackDropdown = ({ className }: { className?: string }) => {
asChild
onClick={() => {
setIsOpen((isOpen) => !isOpen)
setStage('select')
}}
type="text"
className="rounded-full h-[32px] text-foreground-light hover:text-foreground"
@@ -34,36 +29,11 @@ export const FeedbackDropdown = ({ className }: { className?: string }) => {
<PopoverContent_Shadcn_
side="bottom"
align="end"
className="px-0 flex flex-col pt-1 pb-3 w-96"
className="p-0 flex flex-col w-[22rem]"
id="feedback-widget"
>
{stage === 'select' && (
<div className="flex flex-col gap-4 p-4">
<div className="font-medium text-sm">What would you like to share?</div>
<div className="grid grid-cols-2 gap-3">
<Button type="default" className="h-32" onClick={() => setIsOpen(false)} asChild>
<SupportLink>
<span className="grid gap-1 text-center">
<TriangleAlert size="28" className="mx-auto text-destructive-600" />
<span className="text-base">Issue</span>
<span className="text-xm text-foreground-lighter">with my project</span>
</span>
</SupportLink>
</Button>
<Button type="default" className="h-32" onClick={() => setStage('widget')}>
<span className="grid gap-1 text-center">
<Lightbulb size="28" className="mx-auto text-warning" />
<span className="text-base">Idea</span>
<span className="text-xm text-foreground-lighter">to improve Supabase</span>
</span>
</Button>
</div>
</div>
)}
{stage === 'widget' && <FeedbackWidget onClose={() => setIsOpen(false)} />}
<FeedbackWidget onClose={() => setIsOpen(false)} />
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default FeedbackDropdown
@@ -57,3 +57,80 @@ export const uploadAttachment = async ({ image, userId }: UploadAttachmentArgs)
})
return signedUrls[0]
}
/**
* Client-side heuristic to quickly detect obvious support requests
* This provides immediate feedback before the AI classification completes
*/
export function isLikelySupportRequest(text: string): boolean {
if (!text || text.trim().length === 0) return false
const lowerText = text.toLowerCase()
// Common support request patterns
const supportPatterns = [
// Help requests
/need help/i,
/having trouble/i,
/having issues/i,
/having problems/i,
/can you help/i,
/could you help/i,
/please help/i,
/need help/i,
/how do/i,
/how can/i,
/how to/i,
/why isn't/i,
/why doesn't/i,
/why won't/i,
/why can't/i,
// Problem indicators
/it's not working/i,
/it doesn't work/i,
/it won't work/i,
/it can't work/i,
/isn't working/i,
/doesn't work/i,
/won't work/i,
/can't work/i,
/not work/i,
/i can't/i,
/i cannot/i,
/unable to/i,
/wrong/i,
/bad/i,
// Bug/error indicators
/\bbug\b/i,
/\bbroken\b/i,
/\berror\b/i,
/\berrors\b/i,
/\bfailed\b/i,
/\bfailure\b/i,
/\bfailing\b/i,
/\bcrash\b/i,
/\bcrashed\b/i,
/\bcrashing\b/i,
// Issue/problem keywords
/\bissue\b/i,
/\bissues\b/i,
/\bproblem\b/i,
/\bproblems\b/i,
/\btrouble\b/i,
/\bdifficulty\b/i,
/\bdifficulties\b/i,
// Support-specific phrases
/support ticket/i,
/contact support/i,
/get help/i,
/need assistance/i,
/require assistance/i,
/technical support/i,
]
return supportPatterns.some((pattern) => pattern.test(lowerText))
}
@@ -1,7 +1,7 @@
import { useDebounce } from '@uidotdev/usehooks'
import { AnimatePresence, motion } from 'framer-motion'
import { toPng } from 'html-to-image'
import { Camera, CircleCheck, Image as ImageIcon, Upload, X } from 'lucide-react'
import { Camera, CircleCheck, HelpCircle, Image as ImageIcon, Upload, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { ChangeEvent, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
@@ -14,7 +14,6 @@ import { useSendFeedbackMutation } from 'data/feedback/feedback-send'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { DOCS_URL } from 'lib/constants'
import { timeout } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import {
@@ -29,7 +28,11 @@ import {
TextArea_Shadcn_,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { convertB64toBlob, uploadAttachment } from './FeedbackDropdown.utils'
import {
convertB64toBlob,
isLikelySupportRequest,
uploadAttachment,
} from './FeedbackDropdown.utils'
interface FeedbackWidgetProps {
onClose: () => void
@@ -60,6 +63,10 @@ export const FeedbackWidget = ({ onClose }: FeedbackWidgetProps) => {
const { data: category } = useFeedbackCategoryQuery({ prompt: debouncedFeedback })
// Use client-side heuristic for immediate feedback, AI result takes precedence when available
const isLikelySupport = isLikelySupportRequest(feedback)
const effectiveCategory = category ?? (isLikelySupport ? 'support' : null)
const { mutate: sendEvent } = useSendEventMutation()
const { mutate: submitFeedback } = useSendFeedbackMutation({
onSuccess: () => {
@@ -158,72 +165,49 @@ export const FeedbackWidget = ({ onClose }: FeedbackWidgetProps) => {
<ThanksMessage onClose={onClose} />
) : (
<>
<div>
<div className="px-5 pb-4">
<TextArea_Shadcn_
placeholder="It would be great if..."
rows={5}
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
onPaste={handlePasteEvent}
className="text-sm mt-4 mb-1"
/>
</div>
<AnimatePresence>
{category === 'support' && (
<motion.div
key="support-alert"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{ duration: 0.25 }}
>
<Admonition
type="caution"
title="This looks like an issue that's better handled by support"
className="rounded-none border-x-0 border-b-0 mb-0 [&>h5]:text-xs [&>h5]:mb-0.5"
>
<p className="text-xs text-foreground-light !leading-tight">
Please{' '}
<SupportLink
className={cn(
InlineLinkClassName,
'text-foreground-light hover:text-foreground'
)}
queryParams={{ projectRef: slug, message: feedback }}
>
open a support ticket
</SupportLink>{' '}
to get help with this issue, as we do not reply to all product feedback.
</p>
</Admonition>
</motion.div>
)}
</AnimatePresence>
<div className="p-4">
<TextArea_Shadcn_
placeholder="My idea for improving Supabase is..."
rows={6}
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
onPaste={handlePasteEvent}
className="text-sm mb-1 resize-none"
/>
</div>
<AnimatePresence>
{effectiveCategory === 'support' && (
<motion.div
key="support-alert"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{ duration: 0.2 }}
>
<Admonition
type="caution"
title="This looks like an issue that’s better handled by support"
className="rounded-none border-x-0 border-b-0 mb-0"
>
<p className="text-xs text-foreground-light !leading-normal !mb-0">
Please{' '}
<SupportLink
className={cn(InlineLinkClassName)}
queryParams={{ projectRef: slug, message: feedback }}
>
open a support ticket
</SupportLink>{' '}
to get help with this issue, as we do not reply to all product feedback.
</p>
</Admonition>
</motion.div>
)}
</AnimatePresence>
<PopoverSeparator_Shadcn_ />
<div className="px-5 flex flex-row justify-between items-start mt-4">
<div>
<p className="text-xs text-foreground">Have a technical issue?</p>
<p className="text-xs text-foreground-light">
Contact{' '}
<SupportLink>
<span className="cursor-pointer text-brand-link transition-colors hover:text-brand-600">
support
</span>
</SupportLink>{' '}
or{' '}
<a href={`${DOCS_URL}`} target="_blank" rel="noreferrer">
<span className="cursor-pointer text-brand-link transition-colors hover:text-brand-600">
see docs
</span>
</a>
.
</p>
</div>
<div className="p-4 flex flex-row justify-end items-start">
<div className="flex items-center gap-2 flex-row">
{!!screenshot ? (
<div
@@ -312,24 +296,19 @@ const ThanksMessage = ({ onClose }: { onClose: () => void }) => {
return (
<div className="px-0 pt-3 pb-0">
<div className="grid gap-3">
<div className="px-6 grid gap-3 py-2 text-center text-foreground-light">
<div className="px-6 grid gap-4 py-3 text-center text-foreground-light">
<CircleCheck className="mx-auto text-brand-500" size={24} />
<p className="text-foreground text-base">Your feedback has been sent. Thanks!</p>
<p className="text-sm ">
We do not always respond to feedback. If you require assistance, please contact support
instead.
</p>
<div className="text-center flex flex-col">
<p className="text-foreground text-base">Your feedback has been sent. Thanks!</p>
<p className="text-sm text-balance">
We don’t always respond to feedback. If you require assistance, please contact support
via the <HelpCircle className="inline-block" size={12} aria-label="Help" /> menu
instead.
</p>
</div>
</div>
<PopoverSeparator_Shadcn_ />
<div className="flex items-center justify-between px-4">
<p className="text-xs text-foreground-light">
<SupportLink>
<span className="cursor-pointer text-brand transition-colors hover:text-brand-600">
Create a Support Ticket
</span>
</SupportLink>
</p>
<div className="flex items-center justify-end px-4">
<Button type="default" onClick={onClose}>
Close
</Button>
@@ -3,6 +3,7 @@ import { Edit, File, Plus, Trash } from 'lucide-react'
import { useEffect, useState } from 'react'
import AIEditor from 'components/ui/AIEditor'
import { toast } from 'sonner'
import {
Button,
ContextMenu_Shadcn_,
@@ -113,8 +114,52 @@ export const FileExplorerAndEditor = ({
}
}
const handleStartRename = (id: number) => {
// Force re-render of the TreeView with the updated metadata
setTreeData({
name: '',
children: files.map((file) => ({
id: file.id.toString(),
name: file.name,
metadata: {
isEditing: file.id === id,
originalId: file.id,
},
})),
})
}
const exitEditMode = () => {
// Force re-render of the TreeView with the updated metadata
setTreeData({
name: '',
children: files.map((file) => ({
id: file.id.toString(),
name: file.name,
metadata: {
isEditing: false,
originalId: file.id,
},
})),
})
}
const handleFileNameChange = (id: number, newName: string) => {
if (!newName.trim()) return // Don't allow empty names
// Don't allow empty names
if (!newName.trim()) {
toast.error('File name cannot be empty')
return exitEditMode()
}
// Check if the new name already exists in other files
const isDuplicate = files.some((file) => file.id !== id && file.name === newName)
if (isDuplicate) {
toast.error(
`The name ${newName} already exists in the current directory. Please use a different name.`
)
return exitEditMode()
}
const updatedFiles = files.map((file) =>
file.id === id
? {
@@ -157,23 +202,6 @@ export const FileExplorerAndEditor = ({
onFilesChange(updatedFiles)
}
const handleStartRename = (id: number) => {
const updatedTreeData = {
name: '',
children: files.map((file) => ({
id: file.id.toString(),
name: file.name,
metadata: {
isEditing: file.id === id,
originalId: file.id,
},
})),
}
// Force re-render of the TreeView with the updated metadata
setTreeData(updatedTreeData)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(true)
@@ -271,7 +299,9 @@ export const FileExplorerAndEditor = ({
}
}}
onClick={() => {
if (originalId !== null) handleFileSelect(originalId)
if (originalId !== null && !element.metadata?.isEditing) {
handleFileSelect(originalId)
}
}}
onDoubleClick={() => {
if (originalId !== null) {
-64
View File
@@ -1,64 +0,0 @@
import { ExternalLink } from 'lucide-react'
import React, { ReactNode } from 'react'
import { Badge } from 'ui/src/components/shadcn/ui/badge'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from 'ui/src/components/shadcn/ui/hover-card'
interface Link {
url: string
label: string
}
interface InfoPillProps {
label: string | ReactNode
icon: ReactNode
title: string
description: string | ReactNode
links?: Link[]
}
export const InfoPill = ({ label, icon, title, description, links }: InfoPillProps) => {
return (
<HoverCard closeDelay={50} openDelay={300}>
<HoverCardTrigger className="min-w-0">
<Badge className="gap-2 py-2 h-6 min-w-0 overflow-hidden">
<span className="flex-1 flex items-center gap-2">
{icon} {label}
</span>
</Badge>
</HoverCardTrigger>
<HoverCardContent className="w-80 flex items-start gap-3" side="right" align="center">
<div className="rounded-full bg-surface-300 p-2 flex-shrink-0">
{React.cloneElement(icon as React.ReactElement, {
className: 'w-5 h-5',
strokeWidth: 1.5,
})}
</div>
<div className="flex-1">
<h4 className="font-semibold truncate">{title}</h4>
<p className="text-xs text-foreground-light">{description}</p>
{links && links.length > 0 && (
<div className="space-y-1 mt-2">
{links.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-brand hover:text-brand-600 flex items-center"
>
<ExternalLink className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{link.label}</span>
</a>
))}
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
)
}
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOrgDailyStats } from './org-daily-stats-query'
vi.mock('data/fetchers', () => ({
get: vi.fn(),
handleError: vi.fn((error) => {
throw error
}),
}))
describe('org-daily-stats-query', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getOrgDailyStats', () => {
it('throws error when orgSlug is not provided', async () => {
await expect(
getOrgDailyStats({
orgSlug: undefined,
startDate: '2025-01-01',
endDate: '2025-01-31',
})
).rejects.toThrow('Org slug is required')
})
it('throws error when startDate is not provided', async () => {
await expect(
getOrgDailyStats({
orgSlug: 'test-org',
startDate: undefined,
endDate: '2025-01-31',
})
).rejects.toThrow('Start date is required')
})
it('throws error when endDate is not provided', async () => {
await expect(
getOrgDailyStats({
orgSlug: 'test-org',
startDate: '2025-01-01',
endDate: undefined,
})
).rejects.toThrow('Start date is required')
})
it('calls API with correct parameters including project_ref', async () => {
const { get } = await import('data/fetchers')
const mockGet = get as unknown as ReturnType<typeof vi.fn>
const mockResponse = { usages: [] }
mockGet.mockResolvedValueOnce({ data: mockResponse, error: null })
const result = await getOrgDailyStats({
orgSlug: 'test-org',
startDate: '2025-01-01',
endDate: '2025-01-31',
projectRef: 'test-project-ref',
})
expect(mockGet).toHaveBeenCalledWith(
'/platform/organizations/{slug}/usage/daily',
expect.objectContaining({
params: {
path: { slug: 'test-org' },
query: {
start: '2025-01-01',
end: '2025-01-31',
project_ref: 'test-project-ref',
},
},
})
)
expect(result).toEqual(mockResponse)
})
it('calls API without project_ref when not provided', async () => {
const { get } = await import('data/fetchers')
const mockGet = get as unknown as ReturnType<typeof vi.fn>
const mockResponse = { usages: [] }
mockGet.mockResolvedValueOnce({ data: mockResponse, error: null })
await getOrgDailyStats({
orgSlug: 'test-org',
startDate: '2025-01-01',
endDate: '2025-01-31',
})
expect(mockGet).toHaveBeenCalledWith(
'/platform/organizations/{slug}/usage/daily',
expect.objectContaining({
params: {
path: { slug: 'test-org' },
query: {
start: '2025-01-01',
end: '2025-01-31',
project_ref: undefined,
},
},
})
)
})
it('handles API errors correctly', async () => {
const { get, handleError } = await import('data/fetchers')
const mockGet = get as unknown as ReturnType<typeof vi.fn>
const mockHandleError = handleError as unknown as ReturnType<typeof vi.fn>
const mockError = { message: 'API Error' }
mockGet.mockResolvedValueOnce({ data: null, error: mockError })
mockHandleError.mockImplementation((error) => {
throw new Error(error.message)
})
await expect(
getOrgDailyStats({
orgSlug: 'test-org',
startDate: '2025-01-01',
endDate: '2025-01-31',
})
).rejects.toThrow('API Error')
})
})
})
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more