diff --git a/apps/design-system/content/docs/accessibility.mdx b/apps/design-system/content/docs/accessibility.mdx index 35bebb64804c1..a549d1eb7b67c 100644 --- a/apps/design-system/content/docs/accessibility.mdx +++ b/apps/design-system/content/docs/accessibility.mdx @@ -27,7 +27,7 @@ Chromium-based browsers and Firefox handle this automatically via the Tab key. S Interactive page elements should also provide visual feedback upon selection via a `focus-visible` state. We use consistent focus styles such as `inset-focus` so users recognize this state instantly. -[Button](components/button) has all of the above built-in. Bespoke interactive elements however, such as the below interactive [Table Row](components/table#examples), require these props to be added manually: +[Button](components/button) has all of the above built-in. The same explicit `tabIndex` default is also baked into Checkbox, Switch, Select Trigger, Toggle, Accordion Trigger, Collapsible Trigger, Dropdown Menu Trigger, Popover Trigger, Dialog Trigger, Sheet Trigger, Alert Dialog Trigger, and the Sidebar Menu and action buttons. Bespoke interactive elements however, such as the below interactive [Table Row](components/table#examples), require these props to be added manually: ```tsx showLineNumbers {4-14} ({ + default: ['auth'], +})) + // BASE_PATH defaults to '/docs' when NEXT_PUBLIC_BASE_PATH is unset, so test // paths include the /docs prefix to match the middleware's GUIDES_PATH check. function makeRequest( diff --git a/apps/docs/package.json b/apps/docs/package.json index 737d46427f83a..704b3a23cccff 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -42,10 +42,7 @@ "preinstall": "npx only-allow pnpm", "presync": "pnpm run codegen:graphql", "pretest": "pnpm run codegen:examples", - "pretest:local": "pnpm run build:guides-markdown", - "pretest:local:unwatch": "pnpm run build:guides-markdown", - "pretest:smoke": "pnpm run build:guides-markdown", - "pretypecheck": "pnpm run codegen:graphql && pnpm run build:markdown && next typegen", + "pretypecheck": "pnpm run codegen:graphql && next typegen", "start": "next start", "sync": "tsx --conditions=react-server ./resources/rootSync.ts", "test": "pnpm supabase start && pnpm run test:local && pnpm supabase stop", diff --git a/apps/docs/types/manifest.d.ts b/apps/docs/types/manifest.d.ts new file mode 100644 index 0000000000000..191ccfe543693 --- /dev/null +++ b/apps/docs/types/manifest.d.ts @@ -0,0 +1,6 @@ +// public/markdown/manifest.json is generated by internals/generate-guides-markdown.ts +// and gitignored, so it may not exist on disk when tsc runs. +declare module '~/public/markdown/manifest.json' { + const slugs: string[] + export default slugs +} diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index b75037cf88402..f958116f06175 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -19,7 +19,8 @@ "jsx-a11y/tabindex-no-positive": 0, "jsx-a11y/anchor-is-valid": 7, "jsx-a11y/heading-has-content": 2, - "jsx-a11y/no-distracting-elements": 0 + "jsx-a11y/no-distracting-elements": 0, + "supabase/require-explicit-tabindex": 82 }, "ruleFiles": { "react-hooks/exhaustive-deps": { @@ -1067,6 +1068,72 @@ "jsx-a11y/heading-has-content": { "components/layouts/Scaffold.tsx": 2 }, - "jsx-a11y/no-distracting-elements": {} + "jsx-a11y/no-distracting-elements": {}, + "supabase/require-explicit-tabindex": { + "components/grid/components/grid/ColumnHeader.tsx": 1, + "components/grid/components/header/sort/SortRow.tsx": 1, + "components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx": 1, + "components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx": 1, + "components/interfaces/BranchManagement/WorkflowLogs.tsx": 1, + "components/interfaces/Connect/OrganizationSelector.tsx": 1, + "components/interfaces/ConnectSheet/ConnectConfigSection.tsx": 1, + "components/interfaces/ConnectSheet/ConnectionParameters.tsx": 1, + "components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx": 1, + "components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx": 1, + "components/interfaces/Database/Privileges/PrivilegesTable.tsx": 1, + "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx": 1, + "components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaPricingDialog.tsx": 1, + "components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx": 1, + "components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx": 1, + "components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx": 1, + "components/interfaces/Database/Roles/RoleRow.tsx": 1, + "components/interfaces/Database/Roles/RolesList.tsx": 2, + "components/interfaces/Database/Triggers/TriggerSheet.tsx": 1, + "components/interfaces/ExplainVisualizer/ExplainVisualizer.NodeRow.tsx": 1, + "components/interfaces/Functions/CommandRender.tsx": 1, + "components/interfaces/Functions/EdgeFunctionsListItem.tsx": 1, + "components/interfaces/Integrations/Integration/MarkdownContent.tsx": 1, + "components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx": 3, + "components/interfaces/LogDrains/LogDrainsCard.tsx": 1, + "components/interfaces/Observability/ServiceHealthTable.tsx": 1, + "components/interfaces/Organization/OrganizationCard.tsx": 1, + "components/interfaces/Organization/PrivateApps/Apps/ViewAppSheet/ViewAppSheetInfo.tsx": 1, + "components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx": 6, + "components/interfaces/ProjectAPIDocs/SecondLevelNav.Layout.tsx": 1, + "components/interfaces/ProjectHome/ConnectSection.tsx": 1, + "components/interfaces/ProjectHome/SortableSection.tsx": 1, + "components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx": 2, + "components/interfaces/QueryPerformance/QueryPerformanceMetrics.tsx": 2, + "components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx": 3, + "components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerHeader.tsx": 1, + "components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx": 1, + "components/interfaces/Storage/StorageExplorer/MoveItemsModal.tsx": 1, + "components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx": 1, + "components/interfaces/Storage/StoragePolicies/StoragePolicyEditorModalTitle.tsx": 1, + "components/interfaces/Storage/StorageSettings/StorageSettings.ValidateSizeLimit.tsx": 1, + "components/interfaces/Support/AttachmentUpload.tsx": 3, + "components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx": 3, + "components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx": 1, + "components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx": 1, + "components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuItem.tsx": 1, + "components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/TopLevelRouteItem.tsx": 1, + "components/layouts/TableEditorLayout/EntityListItem.tsx": 1, + "components/layouts/Tabs/CollapseButton.tsx": 1, + "components/layouts/Tabs/SortableTab.tsx": 1, + "components/layouts/Tabs/Tabs.tsx": 1, + "components/ui/AIAssistantPanel/ModelSelector.tsx": 1, + "components/ui/Charts/ChartHighlightActions.tsx": 1, + "components/ui/Charts/ComposedChart.utils.tsx": 1, + "components/ui/ComputeBadgeWrapper.tsx": 1, + "components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx": 2, + "components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx": 1, + "components/ui/DevToolbar/ProjectStatusTab.tsx": 1, + "components/ui/DevToolbar/ResourceWarningsTab.tsx": 2, + "components/ui/FeaturePreviewBadge.tsx": 1, + "components/ui/FilterPopover.tsx": 1, + "components/ui/PasswordStrengthBar.tsx": 1, + "components/ui/SingleStat.tsx": 1 + } } } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/PermissionResourceSelector.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/PermissionResourceSelector.tsx index e0d823e77eea6..79955c9ebcedd 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/PermissionResourceSelector.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/PermissionResourceSelector.tsx @@ -1,5 +1,4 @@ import { Key, Plus } from 'lucide-react' -import { Path, PathValue } from 'react-hook-form' import { Button, Checkbox, @@ -14,30 +13,16 @@ import { PopoverTrigger, } from 'ui' -import { - PermissionResource, - PermissionResourceSelectorProps, - PermissionRow, - PermissionsFormValues, -} from './Permissions.types' -import { togglePermissionResource } from './Permissions.utils' +import { PermissionResourceSelectorProps, PermissionRow } from './Permissions.types' import { ACCESS_TOKEN_RESOURCES } from '@/components/interfaces/Account/AccessTokens/AccessToken.constants' -export const PermissionResourceSelector = ({ +export const PermissionResourceSelector = ({ open, onOpenChange, + onResourceToggled, permissionRows, - setValue, align = 'center', -}: PermissionResourceSelectorProps) => { - const handleToggleResource = (resource: PermissionResource) => { - const newRows = togglePermissionResource(permissionRows, resource) - setValue( - 'permissionRows' as Path, - newRows as PathValue> - ) - } - +}: PermissionResourceSelectorProps) => { return ( @@ -61,13 +46,13 @@ export const PermissionResourceSelector = handleToggleResource(resource)} + onSelect={() => onResourceToggled(resource)} className="text-foreground" >
handleToggleResource(resource)} + onCheckedChange={() => onResourceToggled(resource)} onClick={(e) => e.stopPropagation()} /> diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx index f22efce175dde..1860d289d0854 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx @@ -1,21 +1,39 @@ import { ChevronDown, RotateCcw, X } from 'lucide-react' -import { Path, PathValue } from 'react-hook-form' -import { Button, Checkbox, Popover, PopoverContent, PopoverTrigger, WarningIcon } from 'ui' +import { useFieldArray, useFormState } from 'react-hook-form' +import { + Button, + Checkbox, + FormControl, + FormField, + FormMessage, + Popover, + PopoverContent, + PopoverTrigger, + WarningIcon, +} from 'ui' +import { TokenFormValues } from '../../../AccessToken.schemas' import { PermissionResourceSelector } from './PermissionResourceSelector' -import { PermissionRow, PermissionsFormValues, PermissionsProps } from './Permissions.types' +import { PermissionsProps } from './Permissions.types' import { sortActions } from './Permissions.utils' import { ACCESS_TOKEN_RESOURCES } from '@/components/interfaces/Account/AccessTokens/AccessToken.constants' import { formatAccessText } from '@/components/interfaces/Account/AccessTokens/AccessToken.utils' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -export const Permissions = ({ - setValue, - watch, +export const Permissions = ({ + control, resourceSearchOpen, setResourceSearchOpen, -}: PermissionsProps) => { - const permissionRows = (watch('permissionRows' as Path) || []) as PermissionRow[] +}: PermissionsProps) => { + const { + fields: permissionRows, + append, + remove, + } = useFieldArray({ + name: 'permissionRows', + control, + }) + const { errors } = useFormState({ control, name: 'permissionRows' }) return (
@@ -28,12 +46,7 @@ export const Permissions = { - setValue( - 'permissionRows' as Path, - [] as PathValue> - ) - }} + onClick={() => remove()} icon={} tooltip={{ content: { @@ -49,7 +62,13 @@ export const Permissions = { + const index = permissionRows.findIndex((p) => p.resource === resource.resource) + if (index > -1) { + return remove(index) + } + append(resource) + }} align="end" />
@@ -66,88 +85,101 @@ export const Permissions = r.resource === row.resource ) return ( -
-
-
-
-
- - {selectedResource?.title} - -
-
-
-
- {selectedResource && ( - - + { + const fieldValue = field.value || [] + + return ( +
+
+
+
+
+ + {selectedResource?.title} + +
+
+
+
+ {selectedResource && ( + + + + + + + +
+ {sortActions(selectedResource.actions).map((action) => ( + + ))} +
+
+
+ )} - - -
- {sortActions(selectedResource.actions).map((action) => ( - - ))} -
-
- - )} -
-
- {index < permissionRows.length - 1 &&
} -
+ className="p-1" + onClick={() => { + remove(index) + }} + icon={} + aria-label="Remove" + /> +
+
+
+ +
+ {index < permissionRows.length - 1 && ( +
+ )} +
+ ) + }} + /> ) })}
@@ -160,6 +192,11 @@ export const Permissions =
+ {errors.permissionRows?.message || errors.permissionRows?.root?.message ? ( +

+ {errors.permissionRows?.message || errors.permissionRows?.root?.message} +

+ ) : null}
) } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts index ffb26fe487aee..8b3c4a5d43947 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts @@ -1,4 +1,6 @@ -import { FieldValues, UseFormSetValue, UseFormWatch } from 'react-hook-form' +import { Control, FieldValues } from 'react-hook-form' + +import { TokenFormValues } from '../../../AccessToken.schemas' export interface PermissionResource { resource: string @@ -15,19 +17,16 @@ export interface PermissionsFormValues extends FieldValues { permissionRows?: PermissionRow[] } -export interface PermissionsProps< - TFormValues extends PermissionsFormValues = PermissionsFormValues, -> { - setValue: UseFormSetValue - watch: UseFormWatch +export interface PermissionsProps { + control: Control resourceSearchOpen: boolean setResourceSearchOpen: (open: boolean) => void } -export interface PermissionResourceSelectorProps { +export interface PermissionResourceSelectorProps { open: boolean onOpenChange: (open: boolean) => void permissionRows: PermissionRow[] - setValue: UseFormSetValue + onResourceToggled: (resource: PermissionResource) => void align?: 'center' | 'end' | 'start' } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx index 21cd176dbcd27..31efd63cd9b55 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -66,6 +66,7 @@ export const NewScopedTokenSheet = ({ }, mode: 'onChange', }) + const track = useTrack() const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation() @@ -303,8 +304,7 @@ export const NewScopedTokenSheet = ({ /> diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 1c6ac29bba8ea..2dcbf39311bbd 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -47,8 +47,10 @@ import { SchemaGraphContextProvider, SchemaGraphContextType } from './SchemaGrap import { SchemaGraphLegend } from './SchemaGraphLegend' import { EdgeData, TableNodeData } from './Schemas.constants' import { + getEnumsAsMarkdown, getGraphDataFromTables, getLayoutedElementsViaDagre, + getPoliciesAsMarkdown, getSchemaAsMarkdown, } from './Schemas.utils' import { TableNode } from './SchemaTableNode' @@ -57,7 +59,9 @@ import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { SchemaSelector } from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' +import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query' import { useSchemasQuery } from '@/data/database/schemas-query' +import { useEnumeratedTypesQuery } from '@/data/enumerated-types/enumerated-types-query' import { useInfiniteTablesQuery } from '@/data/tables/tables-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useLocalStorage } from '@/hooks/misc/useLocalStorage' @@ -137,6 +141,20 @@ export const SchemaGraph = () => { const tables = useMemo(() => tablesData?.pages.flat() ?? [], [tablesData]) const hasNoTables = isSuccessTables && isSuccessSchemas && tables.length === 0 && !hasNextPage + const { data: enumeratedTypes = [], isPending: isLoadingEnumeratedTypes } = + useEnumeratedTypesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const { data: policies = [], isPending: isLoadingPolicies } = useDatabasePoliciesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schemas: [selectedSchema], + }) + + const isMarkdownDataLoading = isLoadingEnumeratedTypes || isLoadingPolicies + const schema = (schemas ?? []).find((s) => s.name === selectedSchema) const [, setStoredPositions] = useLocalStorage( LOCAL_STORAGE_KEYS.SCHEMA_VISUALIZER_POSITIONS(ref as string, schema?.id ?? 0), @@ -224,11 +242,37 @@ export const SchemaGraph = () => { } const copyAsMarkdown = () => { + if (isMarkdownDataLoading) return + const tableNodes = reactFlowInstance .getNodes() .filter((node) => node.type === 'table') .map((node) => node.data as TableNodeData) - copyToClipboard(getSchemaAsMarkdown(selectedSchema, tableNodes)) + + let markdown = getSchemaAsMarkdown(selectedSchema, tableNodes) + + const enumsMarkdown = getEnumsAsMarkdown( + selectedSchema, + enumeratedTypes.map((e) => ({ name: e.name, schema: e.schema, enums: e.enums })) + ) + if (enumsMarkdown) markdown += enumsMarkdown + + const policiesMarkdown = getPoliciesAsMarkdown( + selectedSchema, + policies.map((p) => ({ + name: p.name, + schema: p.schema, + table: p.table, + command: p.command, + roles: p.roles, + action: p.action, + definition: p.definition ? String(p.definition) : null, + check: p.check ? String(p.check) : null, + })) + ) + if (policiesMarkdown) markdown += policiesMarkdown + + copyToClipboard(markdown) setCopied(true) toast.success('Successfully copied as Markdown') } @@ -246,7 +290,7 @@ export const SchemaGraph = () => { useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_COPY_SQL, copyAsSQL, { enabled: shortcutsEnabled }) useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_COPY_MARKDOWN, copyAsMarkdown, { - enabled: shortcutsEnabled, + enabled: shortcutsEnabled && !isMarkdownDataLoading, }) useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_DOWNLOAD_PNG, () => downloadImage('png'), { enabled: shortcutsEnabled, @@ -423,12 +467,17 @@ export const SchemaGraph = () => { { e.stopPropagation() copyAsMarkdown() }} > - + {isMarkdownDataLoading ? ( + + ) : ( + + )} Copy as Markdown { test('getSchemaAsMarkdown returns properly formatted markdown', () => { @@ -173,4 +178,79 @@ An excellent description | \`user_id\` | \`uuid\` | Nullable | `) }) + + test('getEnumsAsMarkdown returns formatted enum section', () => { + const enums = [ + { name: 'order_status', schema: 'public', enums: ['pending', 'processing', 'shipped'] }, + { name: 'role_type', schema: 'public', enums: ['admin', 'user'] }, + { name: 'other_enum', schema: 'auth', enums: ['a', 'b'] }, + ] + const result = getEnumsAsMarkdown('public', enums) + expect(result).toBe(`## Custom Types / Enums + +### \`order_status\` + +\`pending\` | \`processing\` | \`shipped\` + +### \`role_type\` + +\`admin\` | \`user\` + +`) + }) + + test('getEnumsAsMarkdown returns empty string when no enums match schema', () => { + const enums = [{ name: 'other_enum', schema: 'auth', enums: ['a', 'b'] }] + const result = getEnumsAsMarkdown('public', enums) + expect(result).toBe('') + }) + + test('getPoliciesAsMarkdown returns formatted policy table grouped by table', () => { + const policies = [ + { + name: 'users_select', + schema: 'public', + table: 'users', + command: 'SELECT', + roles: ['authenticated'], + action: 'PERMISSIVE', + definition: 'auth.uid() = id', + check: null, + }, + { + name: 'users_insert', + schema: 'public', + table: 'users', + command: 'INSERT', + roles: ['authenticated'], + action: 'PERMISSIVE', + definition: null, + check: 'auth.uid() = id', + }, + ] + const result = getPoliciesAsMarkdown('public', policies) + expect(result).toContain('## RLS Policies') + expect(result).toContain('### `users`') + expect(result).toContain('`users_select`') + expect(result).toContain('`users_insert`') + expect(result).toContain('`auth.uid() = id`') + expect(result).toContain('—') + }) + + test('getPoliciesAsMarkdown returns empty string when no policies match schema', () => { + const policies = [ + { + name: 'test', + schema: 'auth', + table: 'users', + command: 'SELECT', + roles: ['anon'], + action: 'PERMISSIVE', + definition: 'true', + check: null, + }, + ] + const result = getPoliciesAsMarkdown('public', policies) + expect(result).toBe('') + }) }) diff --git a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts index 9b2d24d73a63d..c328f6c91fb8f 100644 --- a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts +++ b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts @@ -281,3 +281,64 @@ const escapeForMarkdown = (str: string) => { .replace(/\n/g, ' ') ) } + +// ── Enum / Custom Type markdown ──────────────────────────── + +export type EnumForMarkdown = { + name: string + schema: string + enums: string[] +} + +export const getEnumsAsMarkdown = (schema: string, enums: EnumForMarkdown[]): string => { + const filtered = enums.filter((e) => e.schema === schema && e.enums.length > 0) + if (filtered.length === 0) return '' + + let md = `## Custom Types / Enums\n\n` + for (const enumType of filtered) { + const values = enumType.enums.map((v) => `\`${escapeForMarkdown(v)}\``).join(' | ') + md += `### \`${escapeForMarkdown(enumType.name)}\`\n\n${values}\n\n` + } + return md +} + +// ── RLS Policy markdown ──────────────────────────────────── + +export type PolicyForMarkdown = { + name: string + schema: string + table: string + command: string + roles: string[] + action: string + definition: string | null + check: string | null +} + +export const getPoliciesAsMarkdown = (schema: string, policies: PolicyForMarkdown[]): string => { + const filtered = policies.filter((p) => p.schema === schema) + if (filtered.length === 0) return '' + + // Group by table + const byTable = new Map() + for (const policy of filtered) { + const existing = byTable.get(policy.table) ?? [] + existing.push(policy) + byTable.set(policy.table, existing) + } + + let md = `## RLS Policies\n\n` + for (const [table, tablePolicies] of byTable) { + md += `### \`${escapeForMarkdown(table)}\`\n\n` + md += `| Policy | Command | Roles | Action | USING | WITH CHECK |\n` + md += `|--------|---------|-------|--------|-------|------------|\n` + for (const p of tablePolicies) { + const roles = p.roles.map((r) => escapeForMarkdown(r)).join(', ') + const using = p.definition ? `\`${escapeForMarkdown(p.definition)}\`` : '—' + const check = p.check ? `\`${escapeForMarkdown(p.check)}\`` : '—' + md += `| \`${escapeForMarkdown(p.name)}\` | ${p.command} | ${roles} | ${p.action} | ${using} | ${check} |\n` + } + md += `\n` + } + return md +} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index 7da4fab326e00..1c1c6975f5289 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -403,7 +403,7 @@ export const LogsDatePicker = ({ -
+
-
+
{isLargeRange && !hideWarnings && ( -
- Large ranges may result in memory errors for
big projects. -
+

+ Large ranges may result in memory errors for big projects. +

)}
{startDate && endDate ? ( @@ -470,7 +470,7 @@ export const LogsDatePicker = ({ size="tiny" onClick={handleCopy} className={cn({ - 'text-brand-600': copied || pasted, + 'text-brand-link': copied || pasted, })} > {copied ? 'Copied!' : pasted ? 'Pasted!' : 'Copy range'} diff --git a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerState.tsx b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerState.tsx index 7ff3c850a1d37..c62f89ab2589f 100644 --- a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerState.tsx +++ b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerState.tsx @@ -52,13 +52,13 @@ function createBucketFilePickerState({ export type BucketFilePickerState = ReturnType -const DEFAULT_STATE_CONFIG = { +const createDefaultStateConfig = () => ({ bucket: {} as Bucket, maxFiles: 1 as const, -} +}) const BucketFilePickerStateContext = createContext( - createBucketFilePickerState(DEFAULT_STATE_CONFIG) + createBucketFilePickerState(createDefaultStateConfig()) ) export const BucketFilePickerStateContextProvider = ({ diff --git a/apps/studio/components/ui/DatePicker/TimeSplitInput.tsx b/apps/studio/components/ui/DatePicker/TimeSplitInput.tsx index 6851e41d356a5..e2337a14ddc6d 100644 --- a/apps/studio/components/ui/DatePicker/TimeSplitInput.tsx +++ b/apps/studio/components/ui/DatePicker/TimeSplitInput.tsx @@ -1,5 +1,4 @@ import { format } from 'date-fns' -import { Clock } from 'lucide-react' import { useEffect, useState } from 'react' import { cn } from 'ui' @@ -235,10 +234,6 @@ export const TimeSplitInput = ({ hover:border-stronger transition-colors `} > -
- -
- handleOnBlur()} diff --git a/apps/studio/scripts/ratchet-rules.json b/apps/studio/scripts/ratchet-rules.json index 4f0135b422ef0..c681e8b26c9fe 100644 --- a/apps/studio/scripts/ratchet-rules.json +++ b/apps/studio/scripts/ratchet-rules.json @@ -18,5 +18,6 @@ "jsx-a11y/tabindex-no-positive", "jsx-a11y/anchor-is-valid", "jsx-a11y/heading-has-content", - "jsx-a11y/no-distracting-elements" + "jsx-a11y/no-distracting-elements", + "supabase/require-explicit-tabindex" ] diff --git a/apps/studio/state/advisor-state.ts b/apps/studio/state/advisor-state.ts index 8bf49ba6a0b6c..adb66003ef78e 100644 --- a/apps/studio/state/advisor-state.ts +++ b/apps/studio/state/advisor-state.ts @@ -4,7 +4,7 @@ export type AdvisorTab = 'all' | 'security' | 'performance' | 'messages' export type AdvisorSeverity = 'critical' | 'warning' | 'info' export type AdvisorItemSource = 'lint' | 'notification' | 'signal' -const initialState = { +const createInitialState = () => ({ activeTab: 'all' as AdvisorTab, severityFilters: ['critical', 'warning'] as AdvisorSeverity[], selectedItemId: undefined as string | undefined, @@ -12,13 +12,10 @@ const initialState = { // Notification filters notificationFilterStatuses: [] as string[], notificationFilterPriorities: [] as string[], - get numNotificationFiltersApplied() { - return [...this.notificationFilterStatuses, ...this.notificationFilterPriorities].length - }, -} +}) export const advisorState = proxy({ - ...initialState, + ...createInitialState(), setActiveTab(tab: AdvisorTab) { advisorState.activeTab = tab }, @@ -68,7 +65,7 @@ export const advisorState = proxy({ advisorState.notificationFilterPriorities = [] }, reset() { - Object.assign(advisorState, initialState) + Object.assign(advisorState, createInitialState()) }, }) diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index f7e59494d948b..c083fa10e52c7 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -88,7 +88,7 @@ type StoredAiAssistantState = { model?: AssistantModel } -const INITIAL_AI_ASSISTANT: AiAssistantData = { +const createInitialAiAssistantData = (): AiAssistantData => ({ initialInput: '', sqlSnippets: undefined, suggestions: undefined, @@ -97,7 +97,7 @@ const INITIAL_AI_ASSISTANT: AiAssistantData = { activeChatId: undefined, model: undefined, context: {}, -} +}) const DB_NAME = 'ai-assistant-db' const DB_VERSION = 1 @@ -210,7 +210,7 @@ async function tryMigrateFromLocalStorage( projectRef: projectRef, activeChatId: parsedFromLocalStorage.activeChatId, chats: parsedFromLocalStorage.chats, - model: parsedFromLocalStorage.model ?? INITIAL_AI_ASSISTANT.model, + model: parsedFromLocalStorage.model ?? createInitialAiAssistantData().model, } } else { console.warn('Data in localStorage is not in the expected format, ignoring.') @@ -356,7 +356,7 @@ function createChatInstance( export const createAiAssistantState = (): AiAssistantState => { // Initialize with defaults, loading happens asynchronously in the provider - const initialState = { ...INITIAL_AI_ASSISTANT } + const initialState = createInitialAiAssistantData() const state: AiAssistantState = proxy({ ...initialState, // Spread initial values directly @@ -369,7 +369,7 @@ export const createAiAssistantState = (): AiAssistantState => { }, resetAiAssistantPanel: () => { - Object.assign(state, INITIAL_AI_ASSISTANT) + Object.assign(state, createInitialAiAssistantData()) }, setModel: (model: AssistantModel) => { @@ -414,10 +414,11 @@ export const createAiAssistantState = (): AiAssistantState => { } // Update non-chat related state based on options, falling back to current state, then initial - state.initialInput = options?.initialInput ?? INITIAL_AI_ASSISTANT.initialInput - state.sqlSnippets = options?.sqlSnippets ?? INITIAL_AI_ASSISTANT.sqlSnippets - state.suggestions = options?.suggestions ?? INITIAL_AI_ASSISTANT.suggestions - state.tables = options?.tables ?? INITIAL_AI_ASSISTANT.tables + const initialAiAssistantData = createInitialAiAssistantData() + state.initialInput = options?.initialInput ?? initialAiAssistantData.initialInput + state.sqlSnippets = options?.sqlSnippets ?? initialAiAssistantData.sqlSnippets + state.suggestions = options?.suggestions ?? initialAiAssistantData.suggestions + state.tables = options?.tables ?? initialAiAssistantData.tables return chatId }, @@ -561,7 +562,7 @@ export const createAiAssistantState = (): AiAssistantState => { state.model = storedModel && isKnownAssistantModelId(storedModel) ? storedModel - : INITIAL_AI_ASSISTANT.model + : createInitialAiAssistantData().model // Reset sync guards on any support chats (can't be mid-sync after reload) Object.values(state.chats).forEach((chat) => { @@ -698,22 +699,25 @@ export const AiAssistantStateContextProvider = ({ children }: PropsWithChildren) const unsubscribe = subscribe(state, () => { const snap = snapshot(state) + // Prepare state for IndexedDB const stateToSave: StoredAiAssistantState = { projectRef: project?.ref, activeChatId: snap.activeChatId, model: snap.model, chats: snap.chats - ? Object.entries(snap.chats).reduce((acc, [chatId, chat]) => { - // Limit messages before saving - return { - ...acc, - [chatId]: { - ...chat, - messages: chat.messages?.slice(-20) || [], - }, - } - }, {}) + ? (Object.entries(snap.chats) as Array<[string, ChatSession]>).reduce( + (acc, [chatId, chat]) => { + return { + ...acc, + [chatId]: { + ...chat, + messages: chat.messages?.slice(-20) || [], + }, + } + }, + {} as Record + ) : {}, } debouncedSaveAiState(stateToSave) diff --git a/apps/studio/state/editor-panel-state.tsx b/apps/studio/state/editor-panel-state.tsx index 79b41e586b516..723066a5d6ec9 100644 --- a/apps/studio/state/editor-panel-state.tsx +++ b/apps/studio/state/editor-panel-state.tsx @@ -24,7 +24,7 @@ type EditorPanelState = { pendingReset: boolean } -const initialState: EditorPanelState = { +const createInitialState = (): EditorPanelState => ({ value: safeSql``, templates: [], results: undefined, @@ -33,10 +33,10 @@ const initialState: EditorPanelState = { onChange: undefined, activeSnippetId: null, pendingReset: false, -} +}) export const editorPanelState = proxy({ - ...initialState, + ...createInitialState(), setValue(value: DisplayableSqlFragment) { editorPanelState.value = value editorPanelState.onChange?.(value) @@ -65,7 +65,7 @@ export const editorPanelState = proxy({ editorPanelState.pendingReset = true }, reset() { - Object.assign(editorPanelState, initialState) + Object.assign(editorPanelState, createInitialState()) }, }) diff --git a/apps/studio/state/sidebar-manager-state.tsx b/apps/studio/state/sidebar-manager-state.tsx index 1a3f1f4ac59e7..77a978b8f1513 100644 --- a/apps/studio/state/sidebar-manager-state.tsx +++ b/apps/studio/state/sidebar-manager-state.tsx @@ -36,16 +36,16 @@ type SidebarManagerState = SidebarManagerData & { toggleMaximise: () => void } -const INITIAL_SIDEBAR_MANAGER_DATA: SidebarManagerData = { +const createInitialSidebarManagerData = (): SidebarManagerData => ({ sidebars: {}, activeSidebar: undefined, pendingSidebarOpen: undefined, isMaximised: false, -} +}) const createSidebarManagerState = () => { const state: SidebarManagerState = proxy({ - ...INITIAL_SIDEBAR_MANAGER_DATA, + ...createInitialSidebarManagerData(), registerSidebar( id: string, diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index 1bb58b10c3ff4..d4fd8a846910e 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -1839,16 +1839,16 @@ function createStorageExplorerState({ export type StorageExplorerState = ReturnType -const DEFAULT_STATE_CONFIG = { +const createDefaultStateConfig = () => ({ projectRef: '', connectionString: '', resumableUploadUrl: '', clientEndpoint: '', bucket: {} as Bucket, -} +}) const StorageExplorerStateContext = createContext( - createStorageExplorerState(DEFAULT_STATE_CONFIG) + createStorageExplorerState(createDefaultStateConfig()) ) export const StorageExplorerStateContextProvider = ({ children }: PropsWithChildren) => { @@ -1856,7 +1856,7 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild const { data: bucket } = useSelectedBucket() const isPaused = project?.status === PROJECT_STATUS.INACTIVE - const [state, setState] = useState(() => createStorageExplorerState(DEFAULT_STATE_CONFIG)) + const [state, setState] = useState(() => createStorageExplorerState(createDefaultStateConfig())) const stateRef = useLatest(state) const { diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index 2b72256579bb5..c0ef9b95fe2d0 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -116,27 +116,27 @@ function getSavedRecentItems(ref: string): RecentItem[] { } } -const DEFAULT_TABS_STATE = { +const createDefaultTabsState = () => ({ activeTab: null as string | null, openTabs: [] as string[], tabsMap: {} as Record, previewTabId: undefined as string | undefined, recentItems: [], -} +}) const TABS_STORAGE_KEY = 'supabase_studio_tabs' const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}` function getSavedTabs(ref: string) { - if (!ref) return DEFAULT_TABS_STATE + if (!ref) return createDefaultTabsState() const stored = safeLocalStorage.getItem(getTabsStorageKey(ref)) - if (!stored) return DEFAULT_TABS_STATE + if (!stored) return createDefaultTabsState() try { - const parsed = JSON.parse( - stored ?? JSON.stringify(DEFAULT_TABS_STATE) - ) as typeof DEFAULT_TABS_STATE + const parsed = JSON.parse(stored ?? JSON.stringify(createDefaultTabsState())) as ReturnType< + typeof createDefaultTabsState + > if ( !parsed.openTabs || @@ -144,12 +144,12 @@ function getSavedTabs(ref: string) { !parsed.tabsMap || typeof parsed.tabsMap !== 'object' ) { - return DEFAULT_TABS_STATE + return createDefaultTabsState() } return parsed } catch (error) { - return DEFAULT_TABS_STATE + return createDefaultTabsState() } } diff --git a/apps/www/pages/features.tsx b/apps/www/pages/features.tsx index 078f575d2529c..a783330e7097e 100644 --- a/apps/www/pages/features.tsx +++ b/apps/www/pages/features.tsx @@ -23,6 +23,40 @@ import { breadcrumbListSchema, serializeJsonLd } from '@/lib/json-ld' type ViewMode = 'grid' | 'matrix' +// Relevance weights used to rank search results. Title matches are considered +// far more important than body content, so a feature whose title matches the +// query surfaces above features where the term only appears in the description. +// Title weights are deliberately larger than the maximum combined body score +// (SUBTITLE_INCLUDES + DESCRIPTION_INCLUDES), so any title match always ranks +// above a feature that only matches in its subtitle and/or description. +const SEARCH_WEIGHT = { + TITLE_STARTS_WITH: 5, + TITLE_INCLUDES: 4, + SUBTITLE_INCLUDES: 2, + DESCRIPTION_INCLUDES: 1, +} as const + +// Score a feature against a (lowercased) search term. Higher is more relevant. +// Returns 0 when nothing matches. +function getSearchScore(feature: (typeof features)[number], term: string): number { + if (!term) return 0 + + const title = feature.title.toLowerCase() + const subtitle = feature.subtitle.toLowerCase() + const description = feature.description.toLowerCase() + + let score = 0 + if (title.startsWith(term)) { + score += SEARCH_WEIGHT.TITLE_STARTS_WITH + } else if (title.includes(term)) { + score += SEARCH_WEIGHT.TITLE_INCLUDES + } + if (subtitle.includes(term)) score += SEARCH_WEIGHT.SUBTITLE_INCLUDES + if (description.includes(term)) score += SEARCH_WEIGHT.DESCRIPTION_INCLUDES + + return score +} + function FeaturesPage() { const router = useRouter() const [searchTerm, setSearchTerm] = useState((router?.query.q as string) || '') @@ -89,20 +123,31 @@ function FeaturesPage() { } // Filter features based on search term and selected products - const filteredFeatures = features.filter((feature) => { - const matchesSearch = - feature.title.toLowerCase().includes(searchTerm.toLowerCase()) || - feature.subtitle.toLowerCase().includes(searchTerm.toLowerCase()) || - feature.description.toLowerCase().includes(searchTerm.toLowerCase()) + const normalizedSearchTerm = searchTerm.toLowerCase() + const filteredFeatures = features + .filter((feature) => { + const matchesSearch = + !normalizedSearchTerm || getSearchScore(feature, normalizedSearchTerm) > 0 - const matchesProduct = - selectedProducts.length === 0 || - feature.products.some((product) => selectedProducts.includes(product)) + const matchesProduct = + selectedProducts.length === 0 || + feature.products.some((product) => selectedProducts.includes(product)) - const matchesSelfHosted = !showSelfHostedOnly || feature.status?.availableOnSelfHosted === true + const matchesSelfHosted = + !showSelfHostedOnly || feature.status?.availableOnSelfHosted === true - return matchesSearch && matchesProduct && matchesSelfHosted - }) + return matchesSearch && matchesProduct && matchesSelfHosted + }) + // When searching, rank by relevance (title matches weigh more than body + // content), falling back to alphabetical order for ties and no search. + .sort((a, b) => { + if (normalizedSearchTerm) { + const scoreDiff = + getSearchScore(b, normalizedSearchTerm) - getSearchScore(a, normalizedSearchTerm) + if (scoreDiff !== 0) return scoreDiff + } + return a.title > b.title ? 1 : -1 + }) const meta = { title: 'Supabase Features', @@ -265,61 +310,59 @@ function FeaturesPage() {

) : (
- {filteredFeatures - .sort((a, b) => (a.title > b.title ? 1 : -1)) - .map((feature) => ( - ( + + - -
- +
+ + {feature.status && ( +
+ + {stageLabel(feature.status.stage)} + +
+ )} +
+
+

+ {feature.title} +

+

+ {feature.subtitle} +

+
{feature.status && ( -
- - {stageLabel(feature.status.stage)} - -
+ + {stageLabel(feature.status.stage)} + )} + {feature.products.map((product) => ( + + {productLabel(product)} + + ))}
-
-

- {feature.title} -

-

- {feature.subtitle} -

-
- {feature.status && ( - - {stageLabel(feature.status.stage)} - - )} - {feature.products.map((product) => ( - - {productLabel(product)} - - ))} -
-
- - - ))} +
+ + + ))}
)}
diff --git a/packages/eslint-config-supabase/next.js b/packages/eslint-config-supabase/next.js index 8de68d8cb2cf2..049e34bacdd41 100644 --- a/packages/eslint-config-supabase/next.js +++ b/packages/eslint-config-supabase/next.js @@ -9,6 +9,7 @@ const tsparser = require('@typescript-eslint/parser') // Custom Supabase rules const noAwaitBeforeCopyToClipboard = require('./rules/no-await-before-copy-to-clipboard') +const requireExplicitTabIndex = require('./rules/require-explicit-tabindex') const compat = new FlatCompat({ baseDirectory: __dirname, @@ -20,6 +21,7 @@ const compat = new FlatCompat({ const supabasePlugin = { rules: { 'no-await-before-copy-to-clipboard': noAwaitBeforeCopyToClipboard, + 'require-explicit-tabindex': requireExplicitTabIndex, }, } @@ -41,6 +43,7 @@ const typescriptConfig = { rules: { '@typescript-eslint/no-explicit-any': 'warn', 'supabase/no-await-before-copy-to-clipboard': 'error', + 'supabase/require-explicit-tabindex': 'warn', }, } diff --git a/packages/eslint-config-supabase/rules/require-explicit-tabindex.js b/packages/eslint-config-supabase/rules/require-explicit-tabindex.js new file mode 100644 index 0000000000000..43b8d273b9be3 --- /dev/null +++ b/packages/eslint-config-supabase/rules/require-explicit-tabindex.js @@ -0,0 +1,93 @@ +/** + * ESLint rule requiring an explicit tabIndex on raw interactive elements. + * + * An explicit tabIndex={0} puts the control in sequential focus order even when + * the user agent only tabs to text fields by default (Safari / macOS Keyboard + * navigation). Prefer Button from `ui`, which sets this by default; when using + * raw + *
Save
+ * + * GOOD: + * + * + * + */ + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'Require explicit tabIndex on raw