mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 04:45:08 +03:00
Merge branch 'master' into feat/mermaid-blog-v2
This commit is contained in:
150 files changed
+5294
-1706
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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'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'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'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'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'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."
|
||||
"After all," he said, "everyone enjoys a good joke, so it's only fair that
|
||||
they should pay for the privilege."
|
||||
</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'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."
|
||||
"After all," he said, "everyone enjoys a good joke, so it's only fair
|
||||
that they should pay for the privilege."
|
||||
</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'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'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'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's pillow, in his soup, even in the royal toilet. The
|
||||
king was furious, but he couldn'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't help but laugh. And once they started laughing, they
|
||||
couldn't stop.
|
||||
</p>
|
||||
<h3 className="mt-8 scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||
The People's Rebellion
|
||||
The People'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'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'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'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's happiness
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-11
@@ -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,
|
||||
})
|
||||
|
||||
+3
-15
@@ -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 ? (
|
||||
|
||||
+19
-5
@@ -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 = {
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+5
-6
@@ -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,
|
||||
|
||||
+8
-7
@@ -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 />
|
||||
|
||||
+17
-10
@@ -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>
|
||||
|
||||
+1
-3
@@ -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')
|
||||
) || []
|
||||
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+2
-4
@@ -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
|
||||
|
||||
+1
-2
@@ -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
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+4
-4
@@ -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 = [
|
||||
|
||||
+7
-9
@@ -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>
|
||||
)}
|
||||
|
||||
File renamed without changes.
+1
-1
@@ -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_,
|
||||
+71
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+26
@@ -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' }],
|
||||
}
|
||||
+82
@@ -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'],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
+372
@@ -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>
|
||||
)
|
||||
}
|
||||
-32
@@ -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>
|
||||
)
|
||||
}
|
||||
+87
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
-7
@@ -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 />} />
|
||||
|
||||
+1
-9
@@ -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)
|
||||
|
||||
+1
-2
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
+9
-66
@@ -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>
|
||||
)
|
||||
}
|
||||
+19
-3
@@ -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
-1
@@ -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
-33
@@ -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} />
|
||||
)}
|
||||
|
||||
+2
-32
@@ -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
|
||||
+77
@@ -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))
|
||||
}
|
||||
+59
-80
@@ -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) {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user