Merge branch 'master' into feat/sort-via-column-header

This commit is contained in:
Jonathan Summers-Muir committed 2025-05-05 14:34:22 +08:00
commit 844c12ace6
299 files changed
+15339 -3925

No files matched your search

+2 -2
View File
@@ -63,7 +63,7 @@ jobs:
env:
BASE_REF: ${{ github.base_ref }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH_NAME: ${{ github.head_ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -o pipefail
run_lints() {
@@ -76,6 +76,6 @@ jobs:
LINT_EXIT_CODE=$?
set -e
if [[ $LINT_EXIT_CODE -ne 0 ]]; then
gh pr comment "$BRANCH_NAME" --body "$LINT_RESULTS"
gh pr comment "$PR_NUMBER" --body "$LINT_RESULTS"
exit 1
fi
+3
View File
@@ -133,3 +133,6 @@ gcloud.json
# sitemaps
# apps/www/public/*.xml
# apps/docs/public/*.xml
# CLI version file
.temp/cli-latest
+11
View File
@@ -720,6 +720,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"filter-bar-demo": {
name: "filter-bar-demo",
type: "components:example",
registryDependencies: ["filter-bar"],
component: React.lazy(() => import("@/registry/default/example/filter-bar-demo")),
source: "",
files: ["registry/default/example/filter-bar-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"hover-card-demo": {
name: "hover-card-demo",
type: "components:example",
+5
View File
@@ -117,6 +117,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/multi-select',
items: [],
},
{
title: 'Filter Bar',
href: '/docs/fragments/filter-bar',
items: [],
},
{
title: 'Logs Bar Chart',
href: '/docs/fragments/logs-bar-chart',
@@ -0,0 +1,214 @@
---
title: Filter Bar
description: An advanced filtering component with support for multiple conditions and operators.
component: true
fragment: true
---
<ComponentPreview
name="filter-bar-demo"
description="An advanced filtering component with support for multiple conditions and operators."
peekCode
showDottedGrid
wide
/>
## Usage
The Filter Bar component provides advanced filtering capabilities with support for multiple conditions, operators, and different field types. It can be used with both static and async options.
```tsx
const filterProperties = [
{
label: 'Name',
name: 'name',
type: 'string',
operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'],
},
{
label: 'Status',
name: 'status',
type: 'string',
options: ['active', 'inactive', 'pending'],
operators: ['=', '!='],
},
]
export function FilterDemo() {
const [filters, setFilters] = useState<FilterGroup>(initialFilters)
const [freeformText, setFreeformText] = useState('')
return (
<FilterBar
filterProperties={filterProperties}
freeformText={freeformText}
onFreeformTextChange={setFreeformText}
filters={filters}
onFilterChange={setFilters}
/>
)
}
```
## API Reference
### FilterProperty
```tsx
interface FilterProperty {
label: string
name: string
type: 'string' | 'number' | 'date' | 'boolean'
operators: string[]
options?: string[] | ((search?: string) => Promise<string[]> | string[])
}
```
### FilterGroup
```tsx
interface FilterGroup {
logicalOperator: 'AND' | 'OR'
conditions: (FilterCondition | FilterGroup)[]
}
```
### FilterCondition
```tsx
interface FilterCondition {
propertyName: string
value: string | number | boolean | Date
operator: string
}
```
### Component Props
| Prop | Type | Description |
| -------------------- | ------------------------------ | ---------------------------------------- |
| filterProperties | FilterProperty[] | Array of properties that can be filtered |
| filters | FilterGroup | Current filter state |
| onFilterChange | (filters: FilterGroup) => void | Callback when filters change |
| freeformText | string | Current free-form search text |
| onFreeformTextChange | (text: string) => void | Callback when free-form text changes |
| aiApiUrl | string? | Optional URL for AI-powered filtering |
## AI Integration
The Filter Bar component supports AI-powered filtering through an optional API endpoint. When `aiApiUrl` is provided, the component will send natural language queries to be converted into structured filters.
### API Endpoint
The AI API endpoint should accept POST requests with the following structure:
```typescript
// Request body
interface AIFilterRequest {
prompt: string // Natural language query
filterProperties: FilterProperty[] // Available filter properties
}
// Response body
interface AIFilterResponse {
logicalOperator: 'AND' | 'OR'
conditions: (FilterCondition | FilterGroup)[]
}
```
### Example API Implementation
```typescript
import { generateObject } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
// Define schemas for validation
const FilterProperty = z.object({
label: z.string(),
name: z.string(),
type: z.enum(['string', 'number', 'date', 'boolean']),
options: z.array(z.string()).optional(),
operators: z.array(z.string()).optional(),
})
const FilterCondition = z.object({
propertyName: z.string(),
value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
operator: z.string(),
})
type FilterGroupType = {
logicalOperator: 'AND' | 'OR'
conditions: Array<z.infer<typeof FilterCondition> | FilterGroupType>
}
const FilterGroup: z.ZodType<FilterGroupType> = z.lazy(() =>
z.object({
logicalOperator: z.enum(['AND', 'OR']),
conditions: z.array(z.union([FilterCondition, FilterGroup])),
})
)
export async function POST(req: Request) {
const { prompt, filterProperties } = await req.json()
const filterPropertiesString = JSON.stringify(filterProperties)
try {
const { object } = await generateObject({
model: openai('gpt-4-mini'),
schema: FilterGroup,
prompt: `Generate a filter group based on the following prompt: "${prompt}".
Use only these filter properties: ${filterPropertiesString}.
Each property has its own set of valid operators defined in the operators field.
Return a filter group with a logical operator ('AND'/'OR') and an array of conditions.
Each condition can be either a filter condition or another filter group.
Filter conditions should have the structure: { propertyName: string, value: string | number | boolean | null, operator: string }.
Ensure that the generated filters use only the provided property names and their corresponding operators.`,
})
// Validate that all propertyNames exist in filterProperties
const validatePropertyNames = (group: FilterGroupType): boolean => {
return group.conditions.every((condition) => {
if ('logicalOperator' in condition) {
return validatePropertyNames(condition as FilterGroupType)
}
const property = filterProperties.find(
(p: z.infer<typeof FilterProperty>) => p.name === condition.propertyName
)
if (!property) return false
// Validate operator is valid for this property
return property.operators?.includes(condition.operator) ?? false
})
}
if (!validatePropertyNames(object)) {
throw new Error('Invalid property names or operators in generated filter')
}
// Zod will throw an error if the object doesn't match the schema
const validatedFilters = FilterGroup.parse(object)
return Response.json(validatedFilters)
} catch (error: any) {
console.error('Error in AI filtering:', error)
return Response.json({ error: error.message || 'AI filtering failed' }, { status: 500 })
}
}
```
### Usage with AI
```tsx
export function FilterDemoWithAI() {
const [filters, setFilters] = useState<FilterGroup>(initialFilters)
return (
<FilterBar
filterProperties={filterProperties}
filters={filters}
onFilterChange={setFilters}
aiApiUrl="/api/filter-ai" // Enable AI filtering
/>
)
}
```
+5 -1
View File
@@ -1,5 +1,5 @@
# Supabase Design System
Last updated: 2025-03-05T12:59:50.911Z
Last updated: 2025-04-24T06:00:07.374Z
## Overview
The Design System used in Supabase Studio.
@@ -130,6 +130,8 @@ The Design System used in Supabase Studio.
- Displays a callout for user attention.
- [Assistant Chat](https://supabase-design-system.vercel.app/design-system/docs/fragments/assistant-chat)
- Bespoke Assistant chat text area, with support for a commands Popover.
- [Filter Bar](https://supabase-design-system.vercel.app/design-system/docs/fragments/filter-bar)
- An advanced filtering component with support for multiple conditions and operators.
- [Form Item Layout](https://supabase-design-system.vercel.app/design-system/docs/fragments/form-item-layout)
- A helper component that provides a layout for form items.
- [Info Tooltip](https://supabase-design-system.vercel.app/design-system/docs/fragments/info-tooltip)
@@ -144,6 +146,8 @@ The Design System used in Supabase Studio.
- Multiple selection component.
- [Text Confirm Dialog](https://supabase-design-system.vercel.app/design-system/docs/fragments/text-confirm-dialog)
- A modal dialog that interrupts the user with important content and expects a response.
- [Table of Contents (TOC)](https://supabase-design-system.vercel.app/design-system/docs/fragments/toc)
- List of page anchors for the current page.
- [Icons](https://supabase-design-system.vercel.app/design-system/docs/icons)
- Icons system breakdown. Copy values of Icons.
- [Introduction](https://supabase-design-system.vercel.app/design-system/docs/index)
@@ -0,0 +1,166 @@
import { format } from 'date-fns'
import { useState } from 'react'
import { Button, Button_Shadcn_, Calendar, Input_Shadcn_ } from 'ui'
import { CustomOptionProps, FilterBar, FilterGroup } from 'ui-patterns'
function CustomDatePicker({ onChange, onCancel, search }: CustomOptionProps) {
const [date, setDate] = useState<any | undefined>(
search
? {
from: new Date(search),
to: undefined,
}
: undefined
)
return (
<div className="w-full space-y-4">
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
className="w-full"
/>
<div className="flex justify-end gap-2 py-3 px-4 border-t">
<Button type="default" onClick={onCancel}>
Cancel
</Button>
<Button
type="primary"
onClick={() =>
onChange(
date?.from
? date.to
? `${format(date.from, 'yyyy-MM-dd')} - ${format(date.to, 'yyyy-MM-dd')}`
: format(date.from, 'yyyy-MM-dd')
: ''
)
}
>
Apply
</Button>
</div>
</div>
)
}
function CustomTimePicker({ onChange, onCancel, search }: CustomOptionProps) {
const [time, setTime] = useState(search || '')
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">Select Time</h3>
<Input_Shadcn_ type="time" value={time} onChange={(e) => setTime(e.target.value)} />
<div className="flex justify-end gap-2">
<Button_Shadcn_ variant="outline" onClick={onCancel}>
Cancel
</Button_Shadcn_>
<Button_Shadcn_ onClick={() => onChange(time)}>Apply</Button_Shadcn_>
</div>
</div>
)
}
function CustomRangePicker({ onChange, onCancel, search }: CustomOptionProps) {
const [range, setRange] = useState(search || '')
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">Select Range</h3>
<Input_Shadcn_ type="range" value={range} onChange={(e) => setRange(e.target.value)} />
<div className="flex justify-end gap-2">
<Button_Shadcn_ variant="outline" onClick={onCancel}>
Cancel
</Button_Shadcn_>
<Button_Shadcn_ onClick={() => onChange(range)}>Apply</Button_Shadcn_>
</div>
</div>
)
}
const filterProperties = [
{
label: 'Name',
name: 'name',
type: 'string' as const,
operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'],
},
{
label: 'Status',
name: 'status',
type: 'string' as const,
options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Pending', value: 'pending' },
],
operators: ['=', '!='],
},
{
label: 'Type',
name: 'type',
type: 'string' as const,
options: async (search?: string) => {
await new Promise((resolve) => setTimeout(resolve, 500))
const allOptions = ['user', 'admin', 'guest']
return search
? allOptions.filter((option) => option.toLowerCase().includes(search.toLowerCase()))
: allOptions
},
operators: ['=', '!='],
},
{
label: 'Time period',
name: 'created_at',
type: 'date' as const,
options: [
{ label: 'Today', value: format(new Date(), 'yyyy-MM-dd') },
{ label: 'Yesterday', value: format(new Date(Date.now() - 86400000), 'yyyy-MM-dd') },
{ label: 'Last 7 days', value: format(new Date(Date.now() - 7 * 86400000), 'yyyy-MM-dd') },
{ label: 'Last 30 days', value: format(new Date(Date.now() - 30 * 86400000), 'yyyy-MM-dd') },
{
label: 'Pick a date...',
component: (props: CustomOptionProps) => <CustomDatePicker {...props} />,
},
],
operators: ['=', '!=', '>', '<', '>=', '<='],
},
{
label: 'Priority',
name: 'priority',
type: 'number' as const,
options: {
component: (props: CustomOptionProps) => (
<div className="p-6">
<Button onClick={() => props.onChange('1')}>Custom value</Button>
</div>
),
},
triggerOnPropertyClick: true,
operators: ['=', '!=', '>', '<', '>=', '<='],
},
]
const initialFilters: FilterGroup = {
logicalOperator: 'AND',
conditions: [],
}
export default function FilterBarDemo() {
const [filters, setFilters] = useState<FilterGroup>(initialFilters)
const [freeformText, setFreeformText] = useState('')
return (
<div className="w-full">
<FilterBar
filterProperties={filterProperties}
freeformText={freeformText}
onFreeformTextChange={setFreeformText}
filters={filters}
onFilterChange={setFilters}
/>
</div>
)
}
+6
View File
@@ -467,6 +467,12 @@ export const examples: Registry = [
registryDependencies: ['dropdown-menu', 'radio-group'],
files: ['example/dropdown-menu-radio-group.tsx'],
},
{
name: 'filter-bar-demo',
type: 'components:example',
registryDependencies: ['filter-bar'],
files: ['example/filter-bar-demo.tsx'],
},
{
name: 'hover-card-demo',
type: 'components:example',
+1
View File
@@ -20,6 +20,7 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
**/__generated__/**/*
**/*/generated/**/*
!**/*/generated/.gitkeep
!**/*/generated/**/.gitkeep
@@ -0,0 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`/api/graphql schema snapshot > should match snapshot 1`] = `
"schema {
query: RootQueryType
}
type RootQueryType {
"""Get the GraphQL schema for this endpoint"""
schema: String!
}"
`;
+84
View File
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { POST } from './route'
describe('/api/graphql basic error statuses', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('should return error if request body is not valid JSON', async () => {
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: 'not json',
})
const response = await POST(request)
const json = await response.json()
expect(json.errors[0].message).toBe('Invalid request: Request body must be valid JSON')
})
it('should return error if request body is missing required fields', async () => {
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: JSON.stringify({ variables: {} }),
})
const response = await POST(request)
const json = await response.json()
expect(json.errors[0].message).toContain(
'Invalid request: Request body must be valid GraphQL request object'
)
})
it('should return error if query is not a string', async () => {
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: JSON.stringify({ query: 123 }),
})
const response = await POST(request)
const json = await response.json()
expect(json.errors[0].message).toContain(
'Invalid request: Request body must be valid GraphQL request object'
)
})
it('should return error for internal server errors', async () => {
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: JSON.stringify({ query: 'query { hello }' }),
})
vi.spyOn(request, 'json').mockImplementation(() => {
throw new Error('Unexpected error')
})
const response = await POST(request)
const json = await response.json()
expect(json.errors[0].message).toBe('Internal Server Error')
})
})
describe('/api/graphql schema snapshot', () => {
it('should match snapshot', async () => {
const schemaQuery = `
query {
schema
}
`
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: JSON.stringify({ query: schemaQuery }),
})
const response = await POST(request)
const json = await response.json()
expect(json.errors).toBeUndefined()
const {
data: { schema },
} = json
expect(schema).toMatchSnapshot()
})
})
+105
View File
@@ -0,0 +1,105 @@
import { type DocumentNode, graphql, GraphQLError, parse, specifiedRules, validate } from 'graphql'
import { createComplexityLimitRule } from 'graphql-validation-complexity'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ApiError, convertZodToInvalidRequestError, InvalidRequestError } from '~/app/api/utils'
import { BASE_PATH, IS_DEV } from '~/lib/constants'
import { rootGraphQLSchema } from '~/resources/rootSchema'
import { createQueryDepthLimiter } from './validators'
export const runtime = 'edge'
const MAX_DEPTH = 9
const validationRules = [
...specifiedRules,
createQueryDepthLimiter(MAX_DEPTH),
createComplexityLimitRule(1500, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
]
function isDevGraphiQL(request: Request) {
const origin = request.headers.get('Origin')
const referrer = request.headers.get('Referer')
return (
IS_DEV &&
origin.startsWith('http://localhost') &&
referrer === `${origin}${BASE_PATH ?? ''}/graphiql`
)
}
const graphQLRequestSchema = z.object({
query: z.string(),
variables: z.record(z.any()).optional(),
operationName: z.string().optional(),
})
async function handleGraphQLRequest(request: Request): Promise<NextResponse> {
const body = await request.json().catch((error) => {
throw new InvalidRequestError('Request body must be valid JSON', error)
})
const parsedBody = graphQLRequestSchema.safeParse(body)
if (!parsedBody.success) {
throw convertZodToInvalidRequestError(
parsedBody.error,
'Request body must be valid GraphQL request object'
)
}
const { query, variables, operationName } = parsedBody.data
const validationErrors = validateGraphQLRequest(query, isDevGraphiQL(request))
if (validationErrors.length > 0) {
return NextResponse.json({
errors: validationErrors.map((error) => ({
message: error.message,
locations: error.locations,
path: error.path,
})),
})
}
const result = await graphql({
schema: rootGraphQLSchema,
contextValue: { request },
source: query,
variableValues: variables,
operationName,
})
return NextResponse.json(result)
}
function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyArray<GraphQLError> {
let documentAST: DocumentNode
try {
documentAST = parse(query)
} catch (error: unknown) {
if (error instanceof GraphQLError) {
return [error]
} else {
throw error
}
}
const rules = isDevGraphiQL ? specifiedRules : validationRules
return validate(rootGraphQLSchema, documentAST, rules)
}
export async function POST(request: Request): Promise<NextResponse> {
try {
return await handleGraphQLRequest(request)
} catch (error: unknown) {
console.error(error)
if (error instanceof ApiError) {
return NextResponse.json({
errors: [{ message: error.isPrivate() ? 'Internal Server Error' : error.message }],
})
} else {
return NextResponse.json({
errors: [{ message: 'Internal Server Error' }],
})
}
}
}
@@ -0,0 +1,247 @@
import { buildSchema, parse, validate } from 'graphql'
import { describe, expect, test } from 'vitest'
import { createQueryDepthLimiter } from './validators'
describe('createQueryDepthLimiter', () => {
// Create a test schema
const schema = buildSchema(`
type Post {
id: ID!
title: String
content: String
author: User
comments: [Comment]
}
type User {
id: ID!
name: String
email: String
posts: [Post]
friends: [User]
}
type Comment {
id: ID!
text: String
author: User
post: Post
replies: [Comment]
}
type Query {
post(id: ID!): Post
user(id: ID!): User
posts: [Post]
users: [User]
}
`)
test('should not report error for queries under max depth', () => {
const query = `
query {
posts {
title
author {
name
}
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors).toHaveLength(0)
})
test('should report error for queries exceeding max depth', () => {
const query = `
query {
posts {
title
author {
name
posts {
title
}
}
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors.length).toBeGreaterThan(0)
expect(errors[0].message).toContain('Query exceeds maximum depth of 3')
})
test('should ignore __typename fields when calculating depth', () => {
const query = `
query {
posts {
__typename
title
author {
__typename
name
posts {
__typename
}
}
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors).toHaveLength(0)
})
test('should handle fragments correctly', () => {
const query = `
fragment UserDetails on User {
id
name
posts {
title
}
}
query {
users {
...UserDetails
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors).toHaveLength(0)
})
test('should report error for fragments exceeding max depth', () => {
const query = `
fragment UserDetails on User {
id
name
posts {
title
author {
name
}
}
}
query {
users {
...UserDetails
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors.length).toBeGreaterThan(0)
expect(errors[0].message).toContain(
'Fragment spread "UserDetails" causes query to exceed maximum depth'
)
})
test('should report error for fragment spreads causing excessive depth', () => {
const query = `
fragment PostDetails on Post {
title
content
}
fragment UserDetails on User {
name
posts {
...PostDetails
}
}
query {
users {
friends {
...UserDetails
}
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors.length).toBeGreaterThan(0)
})
test('should handle nested fragments correctly', () => {
const query = `
fragment CommentDetails on Comment {
text
author {
name
}
}
fragment PostDetails on Post {
title
comments {
...CommentDetails
}
}
query {
posts {
...PostDetails
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors.length).toBeGreaterThan(0)
})
test('should handle inline fragments', () => {
const query = `
query {
posts {
... on Post {
title
author {
name
}
}
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors).toHaveLength(0)
})
test('should detect circular fragment references', () => {
const query = `
fragment UserPosts on User {
posts {
author {
...UserPosts
}
}
}
query {
users {
...UserPosts
}
}
`
const queryAST = parse(query)
const maxDepth = 5
// This should not go into infinite recursion
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors.length).toBeGreaterThan(0)
})
})
+157
View File
@@ -0,0 +1,157 @@
import {
type FragmentDefinitionNode,
GraphQLError,
type SelectionNode,
type ValidationContext,
type ValidationRule,
} from 'graphql'
/**
* Creates a validator that limits the depth of a GraphQL query.
*
* @param maxDepth The maximum allowed depth
* @returns A validation rule that enforces depth limits
*/
export function createQueryDepthLimiter(maxDepth: number): ValidationRule {
const fragmentDepthMap = new Map<string, number>()
return function limitQueryDepth(context) {
return {
Document: {
leave() {
fragmentDepthMap.clear()
},
},
Field: {
enter(node, _key, _parent, path, ancestors) {
// Skip __typename and introspection fields
if (node.name.value === '__typename' || node.name.value.startsWith('__')) {
return
}
// Calculate the depth by counting the ancestors that are field nodes
const fieldAncestors = ancestors.filter(
(ancestor) => ancestor && 'kind' in ancestor && ancestor.kind === 'Field'
)
const depth = fieldAncestors.length + 1
// Check if this field exceeds the maximum maxDepth
if (depth > maxDepth) {
const pathStr = path.join('.')
context.reportError(
new GraphQLError(
`Query exceeds maximum depth of ${maxDepth}. Got depth ${depth} for ${pathStr}.`,
{ nodes: node }
)
)
}
},
},
FragmentDefinition: {
enter(node) {
calculateFragmentDepth(node, context, 0, fragmentDepthMap, maxDepth)
const depth = fragmentDepthMap.get(node.name.value)!
if (depth > maxDepth) {
context.reportError(
new GraphQLError(
`Fragment "${node.name.value}" exceeds maximum depth of ${maxDepth}.`,
{ nodes: node }
)
)
}
},
},
FragmentSpread: {
enter(node, _key, _parent, _path, ancestors) {
const fragmentName = node.name.value
const fragmentDepth = fragmentDepthMap.get(fragmentName) || 0
const fieldAncestors = ancestors.filter(
(ancestor) => ancestor && 'kind' in ancestor && ancestor.kind === 'Field'
)
const currentDepth = fieldAncestors.length
const totalDepth = currentDepth + fragmentDepth
if (totalDepth > maxDepth) {
context.reportError(
new GraphQLError(
`Fragment spread "${fragmentName}" causes query to exceed maximum depth of ${maxDepth}.`,
{ nodes: node }
)
)
}
},
},
}
}
}
/**
* Helper function to calculate the depth of a fragment
*/
function calculateFragmentDepth(
node: FragmentDefinitionNode,
context: ValidationContext,
currentDepth: number,
visitedFragments: Map<string, number>,
maxDepth: number
) {
const fragmentName = node.name.value
if (visitedFragments.has(fragmentName)) {
return
}
let maxFragmentDepth = 0
// Process all selections in the fragment
function processSelections(selections: ReadonlyArray<SelectionNode>, depth: number) {
for (const selection of selections) {
if (selection.kind === 'Field') {
// Skip __typename and introspection fields
if (selection.name.value === '__typename' || selection.name.value.startsWith('__')) {
continue
}
const newDepth = depth + 1
if (newDepth > maxFragmentDepth) {
maxFragmentDepth = newDepth
}
if (maxFragmentDepth > maxDepth) {
// Do not recurse anymore because we might go on indefinitely
continue
}
// If there are nested selections, process them
if (selection.selectionSet) {
processSelections(selection.selectionSet.selections, newDepth)
}
} else if (selection.kind === 'FragmentSpread') {
// Process fragment spreads
const spreadName = selection.name.value
const fragment = context.getFragment(spreadName)
if (!fragment) continue
const fragmentName = fragment.name.value
if (!visitedFragments.has(fragmentName)) {
calculateFragmentDepth(fragment, context, depth, visitedFragments, maxDepth)
}
const fragmentDepth = visitedFragments.get(fragmentName)!
const totalDepth = fragmentDepth + depth
if (totalDepth > maxFragmentDepth) {
maxFragmentDepth = totalDepth
}
} else if (selection.kind === 'InlineFragment' && selection.selectionSet) {
// Process inline fragments
processSelections(selection.selectionSet.selections, depth)
}
}
}
if (node.selectionSet) {
processSelections(node.selectionSet.selections, currentDepth)
}
visitedFragments.set(fragmentName, maxFragmentDepth)
}
+11 -8
View File
@@ -40,6 +40,9 @@ describe('_handleRevalidateRequest', () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:3000'
process.env.SUPABASE_SECRET_KEY = 'secret_key'
// Silence intentional console errors for cleaner test output
vi.spyOn(console, 'error').mockImplementation(() => {})
// Mock current date
mockDate = new Date('2023-01-01T12:00:00Z')
vi.setSystemTime(mockDate)
@@ -109,7 +112,7 @@ describe('_handleRevalidateRequest', () => {
headers: {
Authorization: 'Bearer basic_key',
},
body: JSON.stringify({ tags: ['tag1', 'tag2'] }),
body: JSON.stringify({ tags: ['graphql', 'wrappers'] }),
})
vi.mocked(headers).mockReturnValue(new Headers(request.headers))
@@ -119,8 +122,8 @@ describe('_handleRevalidateRequest', () => {
const response = await _handleRevalidateRequest(request)
expect(response.status).toBe(204)
expect(revalidateTag).toHaveBeenCalledTimes(2)
expect(revalidateTag).toHaveBeenCalledWith('tag1')
expect(revalidateTag).toHaveBeenCalledWith('tag2')
expect(revalidateTag).toHaveBeenCalledWith('graphql')
expect(revalidateTag).toHaveBeenCalledWith('wrappers')
})
it('should return 429 if last revalidation was less than 6 hours ago with basic permissions', async () => {
@@ -129,7 +132,7 @@ describe('_handleRevalidateRequest', () => {
headers: {
Authorization: 'Bearer basic_key',
},
body: JSON.stringify({ tags: ['tag1'] }),
body: JSON.stringify({ tags: ['graphql'] }),
})
vi.mocked(headers).mockReturnValue(new Headers(request.headers))
@@ -150,7 +153,7 @@ describe('_handleRevalidateRequest', () => {
headers: {
Authorization: 'Bearer basic_key',
},
body: JSON.stringify({ tags: ['tag1'] }),
body: JSON.stringify({ tags: ['graphql'] }),
})
vi.mocked(headers).mockReturnValue(new Headers(request.headers))
@@ -162,7 +165,7 @@ describe('_handleRevalidateRequest', () => {
const response = await _handleRevalidateRequest(request)
expect(response.status).toBe(204)
expect(revalidateTag).toHaveBeenCalledWith('tag1')
expect(revalidateTag).toHaveBeenCalledWith('graphql')
})
it('should revalidate regardless of last revalidation time with override permissions', async () => {
@@ -171,7 +174,7 @@ describe('_handleRevalidateRequest', () => {
headers: {
Authorization: 'Bearer override_key',
},
body: JSON.stringify({ tags: ['tag1'] }),
body: JSON.stringify({ tags: ['graphql'] }),
})
vi.mocked(headers).mockReturnValue(new Headers(request.headers))
@@ -183,6 +186,6 @@ describe('_handleRevalidateRequest', () => {
const response = await _handleRevalidateRequest(request)
expect(response.status).toBe(204)
expect(revalidateTag).toHaveBeenCalledWith('tag1')
expect(revalidateTag).toHaveBeenCalledWith('graphql')
})
})
+4 -6
View File
@@ -1,10 +1,10 @@
import { createClient } from '@supabase/supabase-js'
import { type Database } from 'common'
import { revalidateTag } from 'next/cache'
import { headers } from 'next/headers'
import { type NextRequest } from 'next/server'
import { z } from 'zod'
import { type Database } from 'common'
import { VALID_REVALIDATION_TAGS } from '~/features/helpers.fetch'
enum AuthorizationLevel {
Unauthorized,
@@ -13,7 +13,7 @@ enum AuthorizationLevel {
}
const requestBodySchema = z.object({
tags: z.array(z.string()),
tags: z.array(z.enum(VALID_REVALIDATION_TAGS)),
})
export const POST = handleError(_handleRevalidateRequest)
@@ -35,14 +35,12 @@ export async function _handleRevalidateRequest(request: NextRequest) {
}
let authorizationLevel = AuthorizationLevel.Unauthorized
const token = authorization.replace(/^Bearer\s+/, '')
const token = authorization.replace(/^Bearer /, '')
if (overrideKeys.includes(token)) {
authorizationLevel = AuthorizationLevel.Override
} else if (basicKeys.includes(token)) {
authorizationLevel = AuthorizationLevel.Basic
}
if (authorizationLevel === AuthorizationLevel.Unauthorized) {
return new Response('Invalid Authorization header', { status: 401 })
}
+40
View File
@@ -0,0 +1,40 @@
import { z, type ZodError } from 'zod'
export class ApiError extends Error {
constructor(message: string) {
super(message)
}
isPrivate() {
return true
}
statusCode() {
return 500
}
}
export class InvalidRequestError extends ApiError {
constructor(message: string, source?: unknown) {
super(`Invalid request: ${message}`)
}
isPrivate() {
return false
}
statusCode() {
return 400
}
}
export function convertZodToInvalidRequestError(
error: ZodError,
prelude?: string
): InvalidRequestError {
const issue = error.issues[0]
const pathStr = issue.path.join('.')
const message = `${prelude ? `${prelude}: ` : ''}${issue.message} at key "${pathStr}"`
return new InvalidRequestError(message, error)
}
+33
View File
@@ -0,0 +1,33 @@
'use client'
import dynamic from 'next/dynamic'
import { notFound } from 'next/navigation'
import { type ComponentProps } from 'react'
import { IS_DEV } from '~/lib/constants'
const LazyGraphiQL = dynamic(
async () => {
const { GraphiQL: GraphiQLPrimitive } = await import('graphiql')
const { createGraphiQLFetcher } = await import('@graphiql/toolkit')
await import('graphiql/graphiql.css')
return function GraphiQL(props: Omit<ComponentProps<typeof GraphiQLPrimitive>, 'fetcher'>) {
const fetcher = createGraphiQLFetcher({
url: 'http://localhost:3001/docs/api/graphql',
})
return <GraphiQLPrimitive fetcher={fetcher} {...props} />
}
},
{
loading: () => <div className="p-6">Loading GraphiQL...</div>,
ssr: false,
}
)
export default function GraphiQLPage() {
if (!IS_DEV) {
notFound()
}
return <LazyGraphiQL className="!h-[calc(100vh-var(--header-height))]" />
}
@@ -4,29 +4,97 @@ import { readFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import rehypeSlug from 'rehype-slug'
import emoji from 'remark-emoji'
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import {
genGuideMeta,
genGuidesStaticParams,
removeRedundantH1,
} from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import { fetchRevalidatePerDay } from '~/features/helpers.fetch'
import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
import { GUIDES_DIRECTORY, isValidGuideFrontmatter } from '~/lib/docs'
import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform'
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
import { octokit } from '~/lib/octokit'
export const dynamicParams = false
// We fetch these docs at build time from an external repo
const org = 'supabase'
const repo = 'wrappers'
const branch = 'main'
const docsDir = 'docs/catalog'
const externalSite = 'https://supabase.github.io/wrappers'
type TagQueryResponse = {
repository: {
refs: {
nodes:
| {
name: string
}[]
| null
pageInfo: {
hasNextPage: boolean
endCursor: string | null
}
}
}
}
const tagQuery = `
query TagQuery($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
refs(
refPrefix: "refs/tags/",
orderBy: {
field: TAG_COMMIT_DATE,
direction: DESC
},
first: 5,
after: $after
) {
nodes {
name
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`
async function getLatestRelease(after: string | null = null) {
try {
const {
repository: {
refs: {
nodes,
pageInfo: { hasNextPage, endCursor },
},
},
} = await octokit().graphql<TagQueryResponse>(tagQuery, {
owner: org,
name: repo,
after,
request: {
fetch: (url: RequestInfo | URL, options?: RequestInit) =>
fetch(url, { ...options, next: { tags: [REVALIDATION_TAGS.WRAPPERS] } }),
},
})
return (
nodes?.find((node) => node?.name?.match(/^docs_v\d+\.\d+\.\d+/))?.name ??
(hasNextPage && endCursor ? await getLatestRelease(endCursor) : null)
)
} catch (error) {
console.error(`Error fetching release tags for wrappers federated pages: ${error}`)
return null
}
}
// Each external docs page is mapped to a local page
const pageMap = [
{
@@ -193,10 +261,18 @@ const getContent = async (params: Params) => {
isExternal = true
let remoteFile: string
;({ remoteFile, meta } = federatedPage)
const repoPath = `${org}/${repo}/${branch}/${docsDir}/${remoteFile}`
editLink = `${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`
const response = await fetchRevalidatePerDay(`https://raw.githubusercontent.com/${repoPath}`)
const tag = await getLatestRelease()
if (!tag) {
throw new Error('No latest release found for federated wrappers pages')
}
const repoPath = `${org}/${repo}/${tag}/${docsDir}/${remoteFile}`
editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}`
const response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, {
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
})
const rawContent = await response.text()
const { content: contentWithoutFrontmatter } = matter(rawContent)
@@ -257,4 +333,4 @@ const generateStaticParams = async () => {
const generateMetadata = genGuideMeta(getContent)
export default WrappersDocs
export { generateStaticParams, generateMetadata }
export { generateMetadata, generateStaticParams }
@@ -5,11 +5,11 @@ import rehypeSlug from 'rehype-slug'
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import { fetchRevalidatePerDay_TEMP_TESTING } from '~/features/helpers.fetch'
import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform'
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
export const dynamicParams = false
@@ -135,8 +135,9 @@ const getContent = async ({ slug }: Params) => {
const editLink = newEditLink(`${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`)
const response = await fetchRevalidatePerDay_TEMP_TESTING(
`https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}`
const response = await fetch(
`https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}`,
{ next: { tags: [REVALIDATION_TAGS.GRAPHQL] } }
)
const content = await response.text()
@@ -1,11 +1,11 @@
import { IS_PROD } from 'common'
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
import {
getGuidesMarkdown,
genGuideMeta,
genGuidesStaticParams,
getGuidesMarkdown,
} from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
export const dynamicParams = false
import { getEmptyArray } from '~/features/helpers.fn'
type Params = { slug?: string[] }
@@ -16,10 +16,10 @@ const RealtimeGuidePage = async ({ params }: { params: Params }) => {
return <GuideTemplate {...data!} />
}
const generateStaticParams = genGuidesStaticParams('realtime')
const generateStaticParams = IS_PROD ? genGuidesStaticParams('realtime') : getEmptyArray
const generateMetadata = genGuideMeta((params: { slug?: string[] }) =>
getGuidesMarkdown(['realtime', ...(params.slug ?? [])])
)
export default RealtimeGuidePage
export { generateStaticParams, generateMetadata }
export { generateMetadata, generateStaticParams }
@@ -1,11 +1,11 @@
import { IS_PROD } from 'common'
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
import {
getGuidesMarkdown,
genGuideMeta,
genGuidesStaticParams,
getGuidesMarkdown,
} from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
export const dynamicParams = false
import { getEmptyArray } from '~/features/helpers.fn'
type Params = { slug?: string[] }
@@ -16,10 +16,10 @@ const StorageGuidePage = async ({ params }: { params: Params }) => {
return <GuideTemplate {...data!} />
}
const generateStaticParams = genGuidesStaticParams('storage')
const generateStaticParams = IS_PROD ? genGuidesStaticParams('storage') : getEmptyArray
const generateMetadata = genGuideMeta((params: { slug?: string[] }) =>
getGuidesMarkdown(['storage', ...(params.slug ?? [])])
)
export default StorageGuidePage
export { generateStaticParams, generateMetadata }
export { generateMetadata, generateStaticParams }
+13
View File
@@ -0,0 +1,13 @@
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
overwrite: true,
schema: './__generated__/schema.graphql',
generates: {
'__generated__/graphql.ts': {
plugins: ['typescript', 'typescript-resolvers'],
},
},
}
export default config
+2 -2
View File
@@ -18,7 +18,7 @@ import { Button, cn } from 'ui'
import { IS_PLATFORM } from '~/lib/constants'
import { useSendFeedbackMutation } from '~/lib/fetch/feedback'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import { getNotionTeam, getSanitizedTabParams } from './Feedback.utils'
import { getLinearTeam, getSanitizedTabParams } from './Feedback.utils'
import { type FeedbackFields, FeedbackModal } from './FeedbackModal'
const FeedbackButton = forwardRef<
@@ -134,7 +134,7 @@ function Feedback({ className }: { className?: string }) {
title,
// @ts-expect-error -- can't click this button without having a state.response
isHelpful: state.response === 'yes',
team: getNotionTeam(pathname),
team: getLinearTeam(pathname),
})
setModalOpen(false)
refocusButton()
+12 -12
View File
@@ -3,8 +3,8 @@ import { pick } from 'lodash'
/**
* Gets the Notion team to send feedback to based on the pathname.
*/
const getNotionTeam = (pathname: string) => {
const DEFAULT_TEAM = 'team-docs'
const getLinearTeam = (pathname: string) => {
const DEFAULT_TEAM = 'Docs'
// Pathname has format `/guides/(team)/**`
const pathParts = pathname.split('/')
@@ -13,26 +13,26 @@ const getNotionTeam = (pathname: string) => {
switch (pathParts[2]) {
case 'database':
return 'team-postgres'
return 'Postgres'
case 'auth':
return 'team-auth'
return 'Auth'
case 'storage':
return 'team-storage'
return 'Storage'
case 'functions':
return 'team-functions'
return 'Functions'
case 'realtime':
return 'team-realtime'
return 'Realtime'
case 'ai':
return 'team-ai'
return 'AI'
case 'local-development':
case 'self-hosting':
case 'deployment':
return 'team-dev-workflows'
return 'Dev Workflows'
case 'integrations':
return 'team-api'
return 'API'
case 'platform':
case 'monitoring-troubleshooting':
return 'team-infra'
return 'Infra'
default:
return DEFAULT_TEAM
}
@@ -51,4 +51,4 @@ const getSanitizedTabParams = () => {
return pick(Object.fromEntries(searchParams.entries()), queryGroups)
}
export { getNotionTeam, getSanitizedTabParams }
export { getLinearTeam, getSanitizedTabParams }
@@ -169,9 +169,9 @@ const Content = (props) => {
)
})}
</div>
) : (
) : x.url ? (
<ContentLink url={x.url} icon={x.icon} name={x.name} key={x.name} />
)}
) : null}
</div>
)
})}
@@ -52,7 +52,7 @@ hideToc: true
<StepHikeCompact.Step step={3}>
<StepHikeCompact.Details title="Declare Supabase Environment Variables">
Rename `.env.local.example` to `.env.local` and populate with [your project's URL and Anon Key](https://supabase.com/dashboard/project/_/settings/api).
Rename `.env.example` to `.env.local` and populate with [your project's URL and Anon Key](https://supabase.com/dashboard/project/_/settings/api).
</StepHikeCompact.Details>
@@ -14,7 +14,7 @@ To support Sign in with Apple, you need to configure the [Apple provider in the
There are three general ways to use Sign in with Apple, depending on the application you're trying to build:
- Sign in on the web or in web-based apps
- Using an OAuth flow initiated by Supabase Auth using the [Sign in with Apple REST API](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api).
- Using an OAuth flow initiated by Supabase Auth using the [Sign in with Apple REST API](https://developer.apple.com/documentation/signinwithapplerestapi).
- Using [Sign in with Apple JS](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js) directly in the browser, usually suitable for websites.
- Sign in natively inside iOS, macOS, watchOS or tvOS apps using [Apple's Authentication Services](https://developer.apple.com/documentation/authenticationservices)
@@ -35,7 +35,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
Sign in with Apple's OAuth flow is designed for web or browser based sign in methods. It can be used on web-based apps as well as websites, though some users can benefit by using Sign in with Apple JS directly.
Behind the scenes, Supabase Auth uses the [REST APIs](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api) provided by Apple.
Behind the scenes, Supabase Auth uses the [REST APIs](https://developer.apple.com/documentation/signinwithapplerestapi) provided by Apple.
<$Partial path="create_client_snippet.mdx" />
+16 -31
View File
@@ -37,15 +37,11 @@ If you are not able to use [Clerk's Connect with Supabase page](https://dashboar
<TabPanel id="ts" label="TypeScript">
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('https://<supabase-project>.supabase.co', 'SUPABASE_ANON_KEY', {
accessToken: () => {
return Clerk.session?.getToken()
},
})
```
<$CodeSample
path="/clerk/hooks/useSupabaseClient.ts"
lines={[[6, 14]]}
hideElidedLines={true}
/>
</TabPanel>
@@ -99,34 +95,23 @@ The recommended way to design RLS policies with Clerk is to use claims present i
### Example: Check user organization role
```sql
create policy "Only organization admins can insert in table"
on table_name
for insert
to authenticated
with check (
((select auth.jwt()->>'org_role') = 'org:admin')
and
(organization_id = (select auth.jwt()->>'org_id'))
);
```
<$CodeSample
path="/clerk/supabase/migrations/20250501155648_setup_database.sql"
lines={[[10, 18]]}
hideElidedLines={true}
/>
This RLS policy checks that the newly inserted row in the table has the user's declared organization ID (from the `org_id` session token claim) in the `organization_id` column. Additionally it ensures that they're an `org:admin`.
This RLS policy checks that the newly inserted row in the table has the user's declared organization ID in the `organization_id` column. Additionally it ensures that they're an `org:admin`.
This way only organization admins can add rows to the table, for organizations they're a member of.
### Example: Check user has passed second factor verification
```sql
create policy "Only users that have passed second factor verification can read from table"
on table_name
for select
as restrictive
to authenticated
using (
((select auth.jwt()->'fva'->>1) != '-1')
);
```
<$CodeSample
path="/clerk/supabase/migrations/20250501155648_setup_database.sql"
lines={[[28, 35]]}
hideElidedLines={true}
/>
This example uses a restrictive RLS policy checks that the [second factor verification](https://clerk.com/docs/guides/reverification) age element in the `fva` claim is not `'-1'` indicating the user has passed through second factor verification.
@@ -8,19 +8,9 @@ description: 'Block queries over a total cost limit'
## Enable the extension
`pg_plan_filter` can be enabled on a per connection basis:
The extension is already enabled by default via `shared_preload_libraries` setting.
{/* prettier-ignore */}
```sql
load 'plan_filter';
```
or for all connections:
{/* prettier-ignore */}
```sql
alter database some_db set session_preload_libraries = 'plan_filter';
```
You can follow the instructions below.
## API
@@ -66,7 +56,6 @@ Now we can choose a `statement_cost_filter` value between the total cost for the
{/* prettier-ignore */}
```sql
load 'plan_filter';
set plan_filter.statement_cost_limit = 50; -- between 2.49 and 135.0
select * from book where id = 1;
+4 -4
View File
@@ -417,7 +417,7 @@ from books;
```js
const { data, error } = await supabase.from('books').select(`
title,
description: metadata->description,
description: metadata->>description,
price: metadata->price,
low_age: metadata->ages->0,
high_age: metadata->ages->1
@@ -433,7 +433,7 @@ try await supabase
.select(
"""
title,
description: metadata->description,
description: metadata->>description,
price: metadata->price,
low_age: metadata->ages->0,
high_age: metadata->ages->1
@@ -448,7 +448,7 @@ try await supabase
```kotlin
val data = supabase.from("books").select(Columns.raw("""
title,
description: metadata->description,
description: metadata->>description,
price: metadata->price,
low_age: metadata->ages->0,
high_age: metadata->ages->1
@@ -461,7 +461,7 @@ val data = supabase.from("books").select(Columns.raw("""
```python
data = supabase.from_('books').select("""
title,
description: metadata->description,
description: metadata->>description,
price: metadata->price,
low_age: metadata->ages->0,
high_age: metadata->ages->1
@@ -27,7 +27,7 @@ This video demonstrates how you can create a new customer in Stripe each time a
## Creating a webhook
1. Create a new [Database Webhook](https://supabase.com/dashboard/project/_/integrations/hooks) in the Dashboard.
1. Create a new [Database Webhook](https://supabase.com/dashboard/project/_/integrations/webhooks/overview) in the Dashboard.
1. Give your Webhook a name.
1. Select the table you want to hook into.
1. Select one or more events (table inserts, updates, or deletes) you want to hook into.
@@ -303,7 +303,7 @@ supabase link
<StepHikeCompact>
<StepHikeCompact.Step step={3}>
<StepHikeCompact.Details title="Deploy database changes">
<StepHikeCompact.Details title="Deploy database migrations">
[Push](/docs/reference/cli/supabase-db-push) your migrations to the remote database.
</StepHikeCompact.Details>
@@ -318,4 +318,22 @@ supabase db push
</StepHikeCompact.Step>
</StepHikeCompact>
<StepHikeCompact>
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Deploy database seed data (optional)">
[Push](/docs/reference/cli/supabase-db-push) your migrations and seed the remote database.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```bash name=Terminal
supabase db push --include-seed
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
</StepHikeCompact>
Visiting your live project on [Supabase](https://supabase.com/dashboard/project/_), you'll see a new `employees` table, complete with the `department` column you added in the second migration above.
@@ -367,9 +367,9 @@ enum TransferError: Error {
<$CodeTabs>
```swift name=ProfileView.swift
+ import PhotosUI
+ import Storage
+ import Supabase
import PhotosUI
import Storage
import Supabase
import SwiftUI
struct ProfileView: View {
@@ -379,34 +379,34 @@ struct ProfileView: View {
@State var isLoading = false
+ @State var imageSelection: PhotosPickerItem?
+ @State var avatarImage: AvatarImage?
@State var imageSelection: PhotosPickerItem?
@State var avatarImage: AvatarImage?
var body: some View {
NavigationStack {
Form {
+ Section {
+ HStack {
+ Group {
+ if let avatarImage {
+ avatarImage.image.resizable()
+ } else {
+ Color.clear
+ }
+ }
+ .scaledToFit()
+ .frame(width: 80, height: 80)
+
+ Spacer()
+
+ PhotosPicker(selection: $imageSelection, matching: .images) {
+ Image(systemName: "pencil.circle.fill")
+ .symbolRenderingMode(.multicolor)
+ .font(.system(size: 30))
+ .foregroundColor(.accentColor)
+ }
+ }
+ }
Section {
HStack {
Group {
if let avatarImage {
avatarImage.image.resizable()
} else {
Color.clear
}
}
.scaledToFit()
.frame(width: 80, height: 80)
Spacer()
PhotosPicker(selection: $imageSelection, matching: .images) {
Image(systemName: "pencil.circle.fill")
.symbolRenderingMode(.multicolor)
.font(.system(size: 30))
.foregroundColor(.accentColor)
}
}
}
Section {
TextField("Username", text: $username)
@@ -440,10 +440,10 @@ struct ProfileView: View {
}
}
})
+ .onChange(of: imageSelection) { _, newValue in
+ guard let newValue else { return }
+ loadTransferable(from: newValue)
+ }
.onChange(of: imageSelection) { _, newValue in
guard let newValue else { return }
loadTransferable(from: newValue)
}
}
.task {
await getInitialProfile()
@@ -467,9 +467,9 @@ struct ProfileView: View {
fullName = profile.fullName ?? ""
website = profile.website ?? ""
+ if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
+ try await downloadImage(path: avatarURL)
+ }
if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
try await downloadImage(path: avatarURL)
}
} catch {
debugPrint(error)
@@ -481,7 +481,7 @@ struct ProfileView: View {
isLoading = true
defer { isLoading = false }
do {
+ let imageURL = try await uploadImage()
let imageURL = try await uploadImage()
let currentUser = try await supabase.auth.session.user
@@ -489,7 +489,7 @@ struct ProfileView: View {
username: username,
fullName: fullName,
website: website,
+ avatarURL: imageURL
avatarURL: imageURL
)
try await supabase
@@ -503,36 +503,36 @@ struct ProfileView: View {
}
}
+ private func loadTransferable(from imageSelection: PhotosPickerItem) {
+ Task {
+ do {
+ avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
+ } catch {
+ debugPrint(error)
+ }
+ }
+ }
+
+ private func downloadImage(path: String) async throws {
+ let data = try await supabase.storage.from("avatars").download(path: path)
+ avatarImage = AvatarImage(data: data)
+ }
+
+ private func uploadImage() async throws -> String? {
+ guard let data = avatarImage?.data else { return nil }
+
+ let filePath = "\(UUID().uuidString).jpeg"
+
+ try await supabase.storage
+ .from("avatars")
+ .upload(
+ filePath,
+ data: data,
+ options: FileOptions(contentType: "image/jpeg")
+ )
+
+ return filePath
+ }
private func loadTransferable(from imageSelection: PhotosPickerItem) {
Task {
do {
avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
} catch {
debugPrint(error)
}
}
}
private func downloadImage(path: String) async throws {
let data = try await supabase.storage.from("avatars").download(path: path)
avatarImage = AvatarImage(data: data)
}
private func uploadImage() async throws -> String? {
guard let data = avatarImage?.data else { return nil }
let filePath = "\(UUID().uuidString).jpeg"
try await supabase.storage
.from("avatars")
.upload(
filePath,
data: data,
options: FileOptions(contentType: "image/jpeg")
)
return filePath
}
}
```
@@ -15,6 +15,8 @@ Scopes restrict access to the specific [Supabase Management API endpoints](/docs
Scopes are set when you [create an OAuth app](/docs/guides/platform/oauth-apps/build-a-supabase-integration#create-an-oauth-app) in the Supabase Dashboard.
You can update scopes of your OAuth app at any time, but existing OAuth app users will need to re-authorize your app via the [OAuth flow](/docs/guides/integrations/build-a-supabase-integration#implementing-the-oauth-20-flow) to apply the new scopes.
## Available scopes
| Name | Type | Description |
@@ -52,7 +52,7 @@ Develop locally while running the Supabase stack on your machine.
</TabPanel><TabPanel id="pnpm" label="pnpm">
```sh
pnpm supabase init
pnpx supabase init
```
</TabPanel><TabPanel id="brew" label="brew">
@@ -82,7 +82,7 @@ Develop locally while running the Supabase stack on your machine.
</TabPanel><TabPanel id="pnpm" label="pnpm">
```sh
pnpm supabase start
pnpx supabase start
```
</TabPanel><TabPanel id="brew" label="brew">
@@ -42,6 +42,12 @@ create table "employees" (
</StepHikeCompact.Step>
</StepHikeCompact>
<Admonition type="note">
Make sure your local database is stopped before diffing your schema.
</Admonition>
<StepHikeCompact>
<StepHikeCompact.Step step={2}>
@@ -64,17 +70,11 @@ supabase db diff -f create_employees_table
</StepHikeCompact.Step>
</StepHikeCompact>
<Admonition type="note">
Make sure your local database is stopped before diffing your schema.
</Admonition>
<StepHikeCompact>
<StepHikeCompact.Step step={3}>
<StepHikeCompact.Details title="Start the local database">
The new migration file will be used when starting the database locally so you can validate your schema using the local Dashboard.
<StepHikeCompact.Details title="Start the local database and apply migrations">
Start the local database and then apply the migration file to see your schema changes in the local Dashboard.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
@@ -82,7 +82,7 @@ Make sure your local database is stopped before diffing your schema.
<$CodeTabs>
```bash name=Terminal
supabase start
supabase start && supabase migration up
```
</$CodeTabs>
@@ -5,7 +5,7 @@ title: 'Manage Monthly Active SSO Users usage'
## What you are charged for
You are charged for the number of distinct users who log in or refresh their token during the billing cycle using a SAML 2.0 compatible identity provider. Each unique user is counted only once per billing cycle, regardless of how many times they authenticate. These users are referred to as "SSO MAUs".
You are charged for the number of distinct users who log in or refresh their token during the billing cycle using a SAML 2.0 compatible identity provider (e.g. Google Workspace, Microsoft Active Directory). Each unique user is counted only once per billing cycle, regardless of how many times they authenticate. These users are referred to as "SSO MAUs".
### Example
@@ -5,7 +5,7 @@ title: 'Manage Monthly Active Third-Party Users usage'
## What you are charged for
You are charged for the number of distinct users who log in or refresh their token during the billing cycle using a third-party authentication provider. Each unique user is counted only once per billing cycle, regardless of how many times they authenticate. These users are referred to as "Third-Party MAUs".
You are charged for the number of distinct users who log in or refresh their token during the billing cycle using a third-party authentication provider (Clerk, Firebase Auth, Auth0, AWS Cognito). Each unique user is counted only once per billing cycle, regardless of how many times they authenticate. These users are referred to as "Third-Party MAUs".
### Example
@@ -5,7 +5,7 @@ title: 'Manage Monthly Active Users usage'
## What you are charged for
You are charged for the number of distinct users who log in or refresh their token during the billing cycle. Each unique user is counted only once per billing cycle, regardless of how many times they authenticate. These users are referred to as "MAUs".
You are charged for the number of distinct users who log in or refresh their token during the billing cycle (including Social Login with e.g. Google, Facebook, GitHub). Each unique user is counted only once per billing cycle, regardless of how many times they authenticate. These users are referred to as "MAUs".
### Example
+15 -15
View File
@@ -4,7 +4,13 @@ title: Upgrading
Supabase ships fast and we endeavor to add all new features to existing projects wherever possible. In some cases, access to new features require upgrading or migrating your Supabase project.
You can upgrade your project using `pg_upgrade` or by pausing and restoring your project.
<Admonition type="tip">
This guide refers to upgrading the Postgres version of your Supabase Project. For scaling your compute size, refer to the [Compute and Disk page](/docs/guides/platform/compute-and-disk).
</Admonition>
You can upgrade your project using in-place upgrades or by pausing and restoring your project.
<ShowUntil date="2024-12-17">
<Admonition type="tip">
@@ -14,33 +20,27 @@ The Migrating and Upgrading guide has been divided into two sections. To migrate
</Admonition>
</ShowUntil>
## `pg_upgrade`
<Admonition type="note">
This upgrade method is currently in Beta.
</Admonition>
## In-place upgrades
<Admonition type="note">
For security purposes, passwords for custom roles are not backed up and, following a restore, they
would need to be reset. See [here](/docs/guides/platform/backups#daily-backups) for more details
</Admonition>
pg_upgrade performs an in-place upgrade on your database. For projects larger than 1GB, pg_upgrade is generally faster than a pause+restore cycle, and the speed advantage grows with the size of the database.
In-place upgrades uses `pg_upgrade`. For projects larger than 1GB, this method is generally faster than a pause and restore cycle, and the speed advantage grows with the size of the database.
1. Plan for an appropriate downtime window, and ensure you have reviewed the [caveats](#caveats) section of this document before executing the upgrade.
1. Use the "Upgrade project" button on the [Infrastructure](https://supabase.com/dashboard/project/_/settings/infrastructure) section of your dashboard.
Additionally, if a pg_upgrade upgrade should fail, your original DB would be brought back up online and be able to service requests.
Additionally, if the upgrade should fail, your original database would be brought back up online and be able to service requests.
As a rough rule of thumb, pg_upgrade operates at ~100MBps (when executing an upgrade on your data). Using the size of your database, you can use this metric to derive an approximate sense of the downtime window necessary for the upgrade. During this window, you should plan for your DB and associated services to be unavailable.
As a rough rule of thumb, pg_upgrade operates at ~100MBps (when executing an upgrade on your data). Using the size of your database, you can use this metric to derive an approximate sense of the downtime window necessary for the upgrade. During this window, you should plan for your database and associated services to be unavailable.
## Pause and restore
<Admonition type="note">
We recommend using the pg_upgrade method, as it is faster, and more reliable. Additionally, only Free-tier projects are eligible to use the Pause + Restore method.
We recommend using the In-place upgrade method, as it is faster, and more reliable. Additionally, only Free-tier projects are eligible to use the Pause and Restore method.
</Admonition>
@@ -106,18 +106,18 @@ When upgrading, the Supabase platform will "right-size" your disk based on the c
#### Objects dependent on Postgres extensions
pg_upgrade does not support upgrading of databases containing reg\* data types referencing system OIDs.
In-place upgrades do not support upgrading of databases containing reg\* data types referencing system OIDs.
If you have created any objects that depend on the following extensions, you will need to recreate them after the upgrade.
#### `pg_cron` records
[pg_cron](https://github.com/citusdata/pg_cron#viewing-job-run-details) does not automatically clean up historical records. This can lead to extremely large `cron.job_run_details` tables if the records are not regularly pruned; you should clean unnecessary records from this table prior to an upgrade.
During the `pg_upgrade` process, the `pg_cron` extension gets dropped and recreated. Prior to this process, the `cron.job_run_details` table is duplicated to avoid losing historical logs. The instantaneous disk pressure created by duplicating an extremely large details table can cause at best unnecessary performance degradation, or at worst, upgrade process failures.
During an in-place upgrade, the `pg_cron` extension gets dropped and recreated. Prior to this process, the `cron.job_run_details` table is duplicated to avoid losing historical logs. The instantaneous disk pressure created by duplicating an extremely large details table can cause at best unnecessary performance degradation, or at worst, upgrade process failures.
#### Extensions
pg_upgrade does not currently support upgrading of databases using extensions older than the following versions:
In-place upgrades do not currently support upgrading of databases using extensions older than the following versions:
- TimescaleDB 2.16.1
- plv8 3.1.10
+2 -2
View File
@@ -11,7 +11,7 @@ import { QueryClientProvider } from './data/queryClient.client'
import { PageTelemetry } from './telemetry/telemetry.client'
import { ScrollRestoration } from './ui/helpers.scroll.client'
import { ThemeSandbox } from './ui/theme.client'
import { PromoToast } from 'ui-patterns'
// import { PromoToast } from 'ui-patterns'
/**
* Global providers that wrap the entire app
@@ -28,7 +28,7 @@ function GlobalProviders({ children }: PropsWithChildren) {
<CommandProvider>
<div className="flex flex-col">
<SiteLayout>
<PromoToast />
{/* <PromoToast /> */}
{children}
<DocsCommandMenu />
</SiteLayout>
+17 -4
View File
@@ -10,6 +10,7 @@
* path="/path/to/file.ts"
* lines={[1, 2], [5, 7]} // -1 may be used in end position as an alias for the last line, e.g., [1, -1]
* meta="utils/client.ts" // Optional, for displaying a file path on the code block
* hideElidedLines={true} // Optional, for hiding elided lines in the code block
* />
* ```
*
@@ -24,6 +25,7 @@
* path="/path/to/file.ts"
* lines={[1, 2], [5, 7]} // -1 may be used in end position as an alias for the last line, e.g., [1, -1]
* meta="utils/client.ts" // Optional, for displaying a file path on the code block
* hideElidedLines={true} // Optional, for hiding elided lines in the code block
* />
*/
@@ -77,6 +79,7 @@ const codeSampleExternalSchema = z.object({
path: z.string().transform((v) => (v.startsWith('/') ? v : `/${v}`)),
lines: linesValidator,
meta: z.string().optional(),
hideElidedLines: z.coerce.boolean().default(false),
})
type ICodeSampleExternal = z.infer<typeof codeSampleExternalSchema> & AdditionalMeta
@@ -88,6 +91,7 @@ const codeSampleInternalSchema = z.object({
path: z.string().transform((v) => (v.startsWith('/') ? v : `/${v}`)),
lines: linesValidator,
meta: z.string().optional(),
hideElidedLines: z.coerce.boolean().default(false),
})
type ICodeSampleInternal = z.infer<typeof codeSampleInternalSchema> & AdditionalMeta
@@ -147,6 +151,9 @@ async function fetchSourceCodeContent(tree: Root, deps: Dependencies) {
const path = getAttributeValue(node, 'path')
const lines = getAttributeValueExpression(getAttributeValue(node, 'lines'))
const meta = getAttributeValue(node, 'meta')
const hideElidedLines = getAttributeValueExpression(
getAttributeValue(node, 'hideElidedLines')
)
const result = codeSampleExternalSchema.safeParse({
external: isExternal,
@@ -156,6 +163,7 @@ async function fetchSourceCodeContent(tree: Root, deps: Dependencies) {
path,
lines,
meta,
hideElidedLines,
})
if (!result.success) {
@@ -186,12 +194,16 @@ async function fetchSourceCodeContent(tree: Root, deps: Dependencies) {
const path = getAttributeValue(node, 'path')
const lines = getAttributeValueExpression(getAttributeValue(node, 'lines'))
const meta = getAttributeValue(node, 'meta')
const hideElidedLines = getAttributeValueExpression(
getAttributeValue(node, 'hideElidedLines')
)
const result = codeSampleInternalSchema.safeParse({
external: isExternal,
path,
lines,
meta,
hideElidedLines,
})
if (!result.success) {
@@ -230,7 +242,7 @@ function rewriteNodes(contentMap: Map<MdxJsxFlowElement, [CodeSampleMeta, string
? `https://github.com/${meta.org}/${meta.repo}/blob/${meta.commit}${meta.path}`
: `https://github.com/supabase/supabase/blob/${process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA ?? 'master'}/examples${meta.path}`
const elidedContent = redactLines(content, meta.lines, lang)
const elidedContent = redactLines(content, meta.lines, lang, meta.hideElidedLines)
const replacementContent: MdxJsxFlowElement | Code = meta.codeHikeAncestor
? {
@@ -339,18 +351,19 @@ function matchLang(lang: string) {
function redactLines(
content: string,
lines: [number, number, ...unknown[]][],
lang: string | null
lang: string | null,
hideElidedLines: boolean = false
) {
const contentLines = content.split('\n')
const preservedLines = lines.reduce((acc, [start, end], index, arr) => {
if (index !== 0 || start !== 1) {
if (!hideElidedLines && (index !== 0 || start !== 1)) {
acc.push(_createElidedLine(lang, contentLines, start, end))
}
// Start and end are 1-indexed and inclusive
acc.push(...contentLines.slice(start - 1, end === -1 ? contentLines.length : end))
if (index === arr.length - 1 && end !== -1 && end !== contentLines.length) {
if (!hideElidedLines && index === arr.length - 1 && end !== -1 && end !== contentLines.length) {
acc.push(_createElidedLine(lang, contentLines, start, end))
}
+10 -2
View File
@@ -1,13 +1,14 @@
import matter from 'gray-matter'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { type ComponentProps } from 'react'
import CliGlobalFlagsHandler from '~/components/reference/enrichments/cli/CliGlobalFlagsHandler'
import { MDXRemoteBase } from '~/features/docs/MdxBase'
import { components } from '~/features/docs/MdxBase.shared'
import { RefSubLayout } from '~/features/docs/Reference.ui'
import { cache_fullProcess_withDevCacheBust } from '~/features/helpers.fs'
import { REF_DOCS_DIRECTORY } from '~/lib/docs'
import { CodeBlock } from '../ui/CodeBlock/CodeBlock'
async function getRefMarkdownInternal(relPath: string) {
const fullPath = join(REF_DOCS_DIRECTORY, relPath + '.mdx')
@@ -42,7 +43,14 @@ interface MDXRemoteRefsProps {
}
function MDXRemoteRefs({ source }: MDXRemoteRefsProps) {
const refComponents = { ...components, RefSubLayout, CliGlobalFlagsHandler }
const refComponents = {
...components,
// Override the CodeBlock used for normal guides to skip type generation
// because it is too resource-intensive
pre: (props: ComponentProps<typeof CodeBlock>) => <CodeBlock {...props} skipTypeGeneration />,
RefSubLayout,
CliGlobalFlagsHandler,
}
return <MDXRemoteBase source={source} components={refComponents} />
}
-15
View File
@@ -1,15 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useConsent } from 'ui-patterns/ConsentToast'
/**
* Helper client component to trigger the consent toast. It has to be a client component.
*/
export const TriggerConsentToast = () => {
const { triggerConsentToast } = useConsent()
useEffect(() => {
setTimeout(() => triggerConsentToast(), 1000)
}, [triggerConsentToast])
return null
}
+11
View File
@@ -8,6 +8,17 @@
import { ONE_DAY_IN_SECONDS } from './helpers.time'
export const REVALIDATION_TAGS = {
GRAPHQL: 'graphql',
WRAPPERS: 'wrappers',
} as const
// Casting to avoid problems with using this as a Zod enum, TypeScript does
// not recognize the casted type as a supertype of the original type
export const VALID_REVALIDATION_TAGS = Object.values(REVALIDATION_TAGS) as unknown as readonly [
string,
...string[],
]
function fetchWithNextOptions({
next,
cache,
+8 -2
View File
@@ -1,5 +1,10 @@
type RecObj<T extends object, K extends keyof T> = T[K] extends Array<T> ? T : never
const EMPTY_ARRAY = new Array(0)
export function getEmptyArray() {
return EMPTY_ARRAY
}
export function deepFilterRec<T extends object, K extends keyof T>(
arr: Array<RecObj<T, K>>,
recKey: K,
@@ -24,6 +29,7 @@ export function deepFilterRec<T extends object, K extends keyof T>(
)
}
export function pluckPromise<T, K extends keyof T>(promise: Promise<T>, key: K) {
return promise.then((data) => data[key])
export async function pluckPromise<T, K extends keyof T>(promise: Promise<T>, key: K) {
const data = await promise
return data[key]
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Creates a short random identifier
*/
export function nanoId(
/**
* The length of the identifier to generate
*/
length?: number
) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const l = length || 12
let id = ''
for (let i = 0; i < l; i++) {
id += chars.charAt(Math.floor(Math.random() * chars.length))
}
return id
}
@@ -1,11 +1,11 @@
'use client'
import { IS_PLATFORM, PageTelemetry as PageTelemetryImpl } from 'common'
import { useConsent } from 'ui-patterns/ConsentToast'
import { useConsentToast } from 'ui-patterns/consent'
import { API_URL } from '~/lib/constants'
const PageTelemetry = () => {
const { hasAcceptedConsent } = useConsent()
const { hasAcceptedConsent } = useConsentToast()
return (
<PageTelemetryImpl
@@ -25,17 +25,19 @@ export async function CodeBlock({
lineNumbers = true,
contents,
children,
skipTypeGeneration,
}: PropsWithChildren<{
className?: string
lang?: string
lineNumbers?: boolean
contents?: string
skipTypeGeneration?: boolean
}>) {
let code = (contents || extractCode(children)).trim()
const lang = tryToBundledLanguage(langSetting) || extractLang(children)
let twoslashed = null as null | Map<number, Map<number, Array<NodeHover>>>
if (TWOSLASHABLE_LANGS.includes(lang)) {
if (!skipTypeGeneration && TWOSLASHABLE_LANGS.includes(lang)) {
try {
const { code: editedCode, nodes } = twoslasher(code)
const hoverNodes: Array<NodeHover> = nodes.filter((node) => node.type === 'hover')
+1 -1
View File
@@ -8,7 +8,7 @@ import { fetchRevalidatePerDay } from '~/features/helpers.fetch'
let octokitInstance: Octokit
function octokit() {
export function octokit() {
if (!octokitInstance) {
// https://github.com/gr2m/universal-github-app-jwt?tab=readme-ov-file#converting-pkcs1-to-pkcs8
const privateKeyPkcs8 = crypto
+15 -5
View File
@@ -4,11 +4,11 @@
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
"predev": "pnpm run codegen:references && pnpm run codegen:examples",
"predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples",
"dev": "concurrently --kill-others \"next dev --port 3001\" \"pnpm run dev:watch:troubleshooting\"",
"dev:watch:troubleshooting": "node ./scripts/troubleshooting/watch.mjs",
"dev:secrets:pull": "AWS_PROFILE=supabase-dev node ../../scripts/getSecrets.js -n local/docs",
"prebuild": "pnpm run codegen:references && pnpm run codegen:examples",
"prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples",
"build": "next build",
"build:analyze": "ANALYZE=true next build",
"build:sitemap": "tsx ./internals/generate-sitemap.ts",
@@ -17,6 +17,7 @@
"start": "next start",
"lint": "next lint",
"lint:mdx": "supa-mdx-lint content --config ../../supa-mdx-lint.config.toml",
"pretypecheck": "pnpm run codegen:graphql",
"typecheck": "tsc --noEmit",
"pretest": "pnpm run codegen:examples",
"test": "vitest --exclude \"**/*.smoke.test.ts\"",
@@ -27,9 +28,10 @@
"last-changed": "tsx scripts/last-changed.ts",
"last-changed:reset": "pnpm run last-changed -- --reset",
"codegen:examples": "cp -r ../../examples ./examples",
"codegen:references": "tsx features/docs/Reference.generated.script.ts",
"codemod:frontmatter": "node ./scripts/codemod/mdx-meta.mjs && prettier --write \"content/**/*.mdx\"",
"clean": "rimraf node_modules features/docs/generated examples"
"codegen:graphql": "tsx ./scripts/graphqlSchema.ts && graphql-codegen --config codegen.ts",
"codegen:references": "tsx features/docs/Reference.generated.script.ts",
"clean": "rimraf node_modules features/docs/generated examples __generated__"
},
"dependencies": {
"@har-sdk/openapi-sampler": "^2.2.0",
@@ -55,6 +57,8 @@
"eslint-config-supabase": "workspace:*",
"framer-motion": "^11.0.3",
"github-slugger": "^2.0.0",
"graphql": "^16.10.0",
"graphql-validation-complexity": "^0.4.2",
"gray-matter": "^4.0.3",
"hast-util-has-property": "^2.0.1",
"hast-util-to-html": "^9.0.0",
@@ -105,15 +109,20 @@
"unist-util-filter": "^4.0.1",
"unist-util-visit": "^4.1.2",
"uuid": "^9.0.1",
"valtio": "^1.12.0",
"valtio": "catalog:",
"yaml": "^2.4.5",
"zod": "^3.22.4"
},
"devDependencies": {
"@aws-sdk/client-secrets-manager": "^3.410.0",
"@graphiql/toolkit": "^0.9.1",
"@graphql-codegen/cli": "5.0.5",
"@graphql-codegen/typescript": "4.1.6",
"@graphql-codegen/typescript-resolvers": "4.5.0",
"@supabase/supa-mdx-lint": "0.2.6-alpha",
"@types/common-tags": "^1.8.4",
"@types/estree": "1.0.5",
"@types/graphql-validation-complexity": "^0.4.4",
"@types/hast": "^2.3.4",
"@types/jsrsasign": "^10.5.12",
"@types/mdast": "^3.0.0",
@@ -130,6 +139,7 @@
"dotenv": "^16.0.3",
"ejs": "^3.1.10",
"globby": "^13.2.2",
"graphiql": "^3.8.3",
"openapi-types": "^12.1.3",
"shiki": "^3.2.1",
"simple-git": "^3.24.0",
+1
View File
@@ -32,6 +32,7 @@ David A. Ventimiglia
Deji I
Div Arora
Divit D
Eduardo Gurgel
Egor Romanov
Eleftheria Trivyzaki
Etienne Stalmans
@@ -0,0 +1,5 @@
export interface SearchResultInterface {
title?: string
href?: string
content?: string
}
@@ -0,0 +1,22 @@
import { GraphQLInterfaceType, GraphQLString } from 'graphql'
export const GRAPHQL_FIELD_SEARCH_GLOBAL = 'searchDocs'
export const GraphQLInterfaceTypeSearchResult = new GraphQLInterfaceType({
name: 'SearchResult',
description: 'Document that matches a search query',
fields: {
title: {
type: GraphQLString,
description: 'The title of the matching result',
},
href: {
type: GraphQLString,
description: 'The URL of the matching result',
},
content: {
type: GraphQLString,
description: 'The full content of the matching result',
},
},
})
+37
View File
@@ -0,0 +1,37 @@
import { SearchResultInterface } from '../globalSearch/globalSearchInterface'
export class GuideModel implements SearchResultInterface {
public title?: string
public href?: string
public content?: string
public subsections: Array<SubsectionModel>
constructor({
title,
href,
content,
subsections,
}: {
title?: string
href?: string
content?: string
subsections?: Array<{ title?: string; href?: string; content?: string }>
}) {
this.title = title
this.href = href
this.content = content
this.subsections = subsections?.map((subsection) => new SubsectionModel(subsection)) ?? []
}
}
export class SubsectionModel {
public title?: string
public href?: string
public content?: string
constructor({ title, href, content }: { title?: string; href?: string; content?: string }) {
this.title = title
this.href = href
this.content = content
}
}
+56
View File
@@ -0,0 +1,56 @@
import { GraphQLObjectType, GraphQLString } from 'graphql'
import { GraphQLInterfaceTypeSearchResult } from '../globalSearch/globalSearchSchema'
import { createCollectionType, GraphQLCollectionBuilder } from '../utils/connections'
import { GuideModel, SubsectionModel } from './guideModel'
export const GraphQLObjectTypeSubsection = new GraphQLObjectType({
name: 'Subsection',
isTypeOf: (value: unknown) => value instanceof SubsectionModel,
description: 'A content chunk taken from a larger document in the Supabase docs',
fields: {
title: {
type: GraphQLString,
description: 'The title of the subsection',
},
href: {
type: GraphQLString,
description: 'The URL of the subsection',
},
content: {
type: GraphQLString,
description: 'The content of the subsection',
},
},
})
export const GraphQLObjectTypeGuide = new GraphQLObjectType({
name: 'Guide',
interfaces: [GraphQLInterfaceTypeSearchResult],
isTypeOf: (value: unknown) => value instanceof GuideModel,
description:
'A document containing content from the Supabase docs. This is a guide, which might describe a concept, or explain the steps for using or implementing a feature.',
fields: {
title: {
type: GraphQLString,
description: 'The title of the document',
},
href: {
type: GraphQLString,
description: 'The URL of the document',
},
content: {
type: GraphQLString,
description:
'The full content of the document, including all subsections (both those matching and not matching any query string) and possibly more content',
},
subsections: {
type: createCollectionType(GraphQLObjectTypeSubsection, {
skipPageInfo: true,
description: 'A collection of content chunks from a larger document in the Supabase docs.',
}),
description:
'The subsections of the document. If the document is returned from a search match, only matching content chunks are returned. For the full content of the original document, use the content field in the parent Guide.',
resolve: (node: GuideModel) => GraphQLCollectionBuilder.create({ items: node.subsections }),
},
},
})
+34
View File
@@ -0,0 +1,34 @@
import {
GraphQLNonNull,
GraphQLObjectType,
GraphQLSchema,
GraphQLString,
printSchema,
} from 'graphql'
import { RootQueryTypeResolvers } from '~/__generated__/graphql'
const GRAPHQL_FIELD_INTROSPECT = 'schema' as const
type IntrospectResolver = RootQueryTypeResolvers[typeof GRAPHQL_FIELD_INTROSPECT]
const resolveIntrospect: IntrospectResolver = async () => {
const schema = printSchema(rootGraphQLSchema)
return schema
}
const introspectRoot = {
[GRAPHQL_FIELD_INTROSPECT]: {
description: 'Get the GraphQL schema for this endpoint',
type: new GraphQLNonNull(GraphQLString),
resolve: resolveIntrospect,
},
}
export const rootGraphQLSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'RootQueryType',
fields: {
...introspectRoot,
},
}),
})
+357
View File
@@ -0,0 +1,357 @@
import {
GraphQLBoolean,
GraphQLInt,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
type GraphQLOutputType,
GraphQLString,
} from 'graphql'
import { nanoId } from '~/features/helpers.misc'
/**
* Extracts the name from a a GraphQLOutputType.
*/
function extractNodeTypeName(
/**
* The GraphQL Output Type to extract the name from.
*/
nodeType: GraphQLOutputType
): string | undefined {
if ('name' in nodeType) {
return nodeType.name
} else {
return `AnonymousNode(${nanoId()})`
}
}
/**
* Creates an Edge type for a specific node type. An Edge type wraps the node
* alongside an optional associated cursor, for example:
*
* ```
* {
* edges: [
* {
* node: {
* id: '123',
* name: 'John Doe'
* },
* cursor: 'YXJyYXljb25uZWN0aW9uOjE='
* }
* ]
* }
* ```
*/
function createEdgeType(
nodeType: GraphQLOutputType,
{
name,
skipCursor = false,
}: {
/**
* The name of the created Edge node. If not provided, defaults to
* NameOfInnerNodeEdge.
*/
name?: string
/**
* Whether to skip the cursor field.
*/
skipCursor?: boolean
} = {}
): GraphQLObjectType {
const edgeName = name || `${extractNodeTypeName(nodeType)}Edge`
return new GraphQLObjectType({
name: edgeName,
description: `An edge in a collection of ${extractNodeTypeName(nodeType)}s`,
fields: {
node: {
type: new GraphQLNonNull(nodeType),
description: `The ${extractNodeTypeName(nodeType)} at the end of the edge`,
},
...(!skipCursor && {
cursor: {
type: new GraphQLNonNull(GraphQLString),
description: 'A cursor for use in pagination',
},
}),
},
})
}
/**
* Standard GraphQL type for pagination information on connections
*/
const PageInfoType = new GraphQLObjectType({
name: 'PageInfo',
description: 'Pagination information for a collection',
fields: {
hasNextPage: {
type: new GraphQLNonNull(GraphQLBoolean),
description: 'Whether there are more items after the current page',
},
hasPreviousPage: {
type: new GraphQLNonNull(GraphQLBoolean),
description: 'Whether there are more items before the current page',
},
startCursor: {
type: GraphQLString,
description: 'Cursor pointing to the start of the current page',
},
endCursor: {
type: GraphQLString,
description: 'Cursor pointing to the end of the current page',
},
},
})
/**
* Creates a Collection type for a specific node type. A collection type
* represents a list of nodes with pagination information, and has the shape:
*
* ```
* {
* edges: [
* {
* node: { ... },
* cursor: '...'
* },
* ...
* ],
* nodes: [ ... ],
* pageInfo: {
* hasNextPage: true,
* hasPreviousPage: false,
* startCursor: '...',
* endCursor: '...'
* }
* }
* ```
*/
export function createCollectionType(
nodeType: GraphQLOutputType,
{
name,
description,
skipPageInfo = false,
}: {
/**
* The name of the generated collection.
*
* If omitted, defaults to NameOfInnerNodeCollection.
*/
name?: string
/**
* A description of the collection that will be outputted in the generated
* schema as documentation.
*/
description?: string
/**
* Whether to skip the pageInfo field.
*/
skipPageInfo?: boolean
} = {}
): GraphQLObjectType {
const collectionName = name || `${extractNodeTypeName(nodeType)}Collection`
const edgeType = createEdgeType(nodeType, { skipCursor: skipPageInfo })
return new GraphQLObjectType({
name: collectionName,
description: description || `A collection of ${extractNodeTypeName(nodeType)}s`,
fields: {
edges: {
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(edgeType))),
description: 'A list of edges containing nodes in this collection',
},
nodes: {
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(nodeType))),
description: 'The nodes in this collection, directly accessible',
},
...(!skipPageInfo && {
pageInfo: {
type: new GraphQLNonNull(PageInfoType),
description: 'Pagination information',
},
}),
totalCount: {
type: new GraphQLNonNull(GraphQLInt),
description: 'The total count of items available in this collection',
},
},
})
}
/**
* Interface for standard pagination arguments for a GraphQL connection
*/
interface IPaginationArgs {
first?: number | null
after?: string | null
last?: number | null
before?: string | null
}
/**
* Standard pagination arguments for a GraphQL connection
*/
export const paginationArgs = {
first: {
type: GraphQLInt,
description: 'Returns the first n elements from the list',
},
after: {
type: GraphQLString,
description: 'Returns elements that come after the specified cursor',
},
last: {
type: GraphQLInt,
description: 'Returns the last n elements from the list',
},
before: {
type: GraphQLString,
description: 'Returns elements that come before the specified cursor',
},
}
/**
* Interface for a fetch definition used to fetch a collection of items for a
* GraphQL query. Takes standard pagination args and returns standard page
* information.
*/
interface CollectionFetch<ItemType, FetchArgs = unknown> {
fetch: (
args?: IPaginationArgs & {
additionalArgs?: FetchArgs
}
) => Promise<{
items: Array<ItemType>
totalCount: number
hasNextPage?: boolean
hasPreviousPage?: boolean
}>
args?: IPaginationArgs & {
additionalArgs?: FetchArgs
}
getCursor?: (item: ItemType, idx?: number) => string
items?: never
}
/**
* Interface for parameters used to build a collection of items from an array
* in memory
*/
interface CollectionInMemory<ItemType> {
items: Array<ItemType>
args?: IPaginationArgs
fetch?: never
getCursor?: never
}
/**
* Union type for parameters to build a collection. Can be a remote collection
* that needs to be fetched or a local one in memory.
*/
type CollectionBuildArgs<ItemType, FetchArgs = unknown> =
| CollectionFetch<ItemType, FetchArgs>
| CollectionInMemory<ItemType>
export class GraphQLCollectionBuilder {
static async create<ItemType, FetchArgs = unknown>(
options: CollectionBuildArgs<ItemType, FetchArgs>
) {
const { fetch, args = {}, getCursor, items } = options
if (items) {
return GraphQLCollectionBuilder.paginateArray({ items, args })
}
const result = await fetch(args)
const { items: fetchedItems, totalCount, hasNextPage = false, hasPreviousPage = false } = result
const edges = fetchedItems.map((item) => {
return { node: item, cursor: getCursor(item) }
})
return {
edges,
nodes: fetchedItems,
totalCount,
pageInfo: {
hasNextPage,
hasPreviousPage,
startCursor: edges.length > 0 ? edges[0].cursor : null,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
},
}
}
private static paginateArray<T>({ items, args }: CollectionInMemory<T>) {
const getCursor = (_item: T, idx: number) => String(idx)
const allEdges = items.map((item, idx) => {
return { node: item, cursor: getCursor(item, idx) }
})
const { edges, hasPreviousPage, hasNextPage } = getRequestedSlice(allEdges, args)
return {
edges,
nodes: edges.map((edge) => edge.node),
totalCount: allEdges.length,
pageInfo: {
hasNextPage,
hasPreviousPage,
startCursor: edges.length > 0 ? edges[0].cursor : null,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
},
}
}
}
interface TruncatedPageInfo<ItemType> {
edges: Array<ItemType>
hasNextPage: boolean
hasPreviousPage: boolean
}
function getRequestedSlice<ItemType>(
allEdges: Array<ItemType>,
pageArgs: IPaginationArgs
): TruncatedPageInfo<ItemType> {
let hasPreviousPage = false
let hasNextPage = false
let beforeIndex = allEdges.length
let afterIndex = -1
const requestedBefore = toNumber(pageArgs.before)
if (requestedBefore && requestedBefore >= 0 && requestedBefore < beforeIndex) {
beforeIndex = requestedBefore
hasNextPage = true
}
const requestedAfter = toNumber(pageArgs.after)
if (requestedAfter && requestedAfter >= 0) {
afterIndex = requestedAfter
hasPreviousPage = true
}
let edges = allEdges.slice(afterIndex + 1, beforeIndex)
if (pageArgs.first >= 0 && edges.length > pageArgs.first) {
edges = edges.slice(0, pageArgs.first)
hasNextPage = true
} else if (pageArgs.last >= 0 && edges.length > pageArgs.last) {
edges = edges.slice(edges.length - pageArgs.last)
hasPreviousPage = true
}
return {
edges,
hasNextPage,
hasPreviousPage,
}
}
function toNumber(value: string): number | undefined {
const num = Number(value)
return Number.isNaN(num) ? undefined : num
}
+27
View File
@@ -0,0 +1,27 @@
import { printSchema } from 'graphql'
import fs from 'node:fs'
import path from 'node:path'
import { rootGraphQLSchema } from '../resources/rootSchema'
async function generateGraphQLSchema() {
try {
const schemaString = printSchema(rootGraphQLSchema)
const outputDir = path.resolve(__dirname, '../__generated__')
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
const outputPath = path.resolve(outputDir, 'schema.graphql')
fs.writeFileSync(outputPath, schemaString)
console.log(`✅ Successfully generated GraphQL schema at ${outputPath}`)
} catch (error) {
console.error('🚨 Error generating GraphQL schema:', error)
process.exit(1)
}
}
if (require.main === module) {
generateGraphQLSchema()
}
+53 -1
View File
@@ -1,7 +1,7 @@
clispec: '001'
info:
id: cli
version: 2.22.4
version: 2.22.6
title: Supabase CLI
language: sh
source: https://github.com/supabase/cli
@@ -904,6 +904,17 @@ commands:
- id: supabase-secrets
title: supabase secrets
summary: Manage Supabase secrets
description: |
Provides tools for managing environment variables and secrets for your Supabase project.
This command group allows you to set, unset, and list secrets that are securely stored and
made available to Edge Functions as environment variables.
Secrets management through the CLI is useful for:
- Setting environment-specific configuration
- Managing sensitive credentials securely
Secrets can be set individually or loaded from .env files for convenience.
tags:
- management-api
links: []
@@ -958,6 +969,15 @@ commands:
- id: supabase-projects
title: supabase projects
summary: Manage Supabase projects
description: |
Provides tools for creating and managing your Supabase projects.
This command group allows you to list all projects in your organizations, create new projects,
delete existing projects, and retrieve API keys. These operations help you manage your Supabase
infrastructure programmatically without using the dashboard.
Project management via CLI is especially useful for automation scripts and when you need to
provision environments in a repeatable way.
tags:
- management-api
links: []
@@ -2492,6 +2512,17 @@ commands:
- id: supabase-gen-types
title: supabase gen types
summary: Generate types from Postgres schema
description: |
Automatically generates type definitions based on your Postgres database schema.
This command connects to your database (local or remote) and generates typed definitions
that match your database tables, views, and stored procedures. By default, it generates TypeScript
definitions, but also supports Go and Swift.
Generated types give you type safety and autocompletion when working with your database in code,
helping prevent runtime errors and improving developer experience.
The types respect relationships, constraints, and custom types defined in your database schema.
tags: []
links: []
usage: supabase gen types [flags]
@@ -2571,6 +2602,14 @@ commands:
- id: supabase-functions
title: supabase functions
summary: Manage Supabase Edge functions
description: |
Supabase Edge Functions are server-less functions that run close to your users.
Edge Functions allow you to execute custom server-side code without deploying or scaling a traditional server.
They're ideal for handling webhooks, custom API endpoints, data validation, and serving personalized content.
Edge Functions are written in TypeScript and run on Deno compatible edge runtime, which is a secure runtime
with no package management needed, fast cold starts, and built-in security.
tags:
- management-api
links: []
@@ -2656,6 +2695,15 @@ commands:
- id: supabase-functions-new
title: supabase functions new
summary: Create a new Function locally
description: |
Creates a new Edge Function with boilerplate code in the `supabase/functions` directory.
This command generates a starter TypeScript file with the necessary Deno imports and a
basic function structure. The function is created as a new directory with the name you specify,
containing an `index.ts` file with the function code.
After creating the function, you can edit it locally and then use `supabase functions serve`
to test it before deploying with `supabase functions deploy`.
tags: []
links: []
usage: supabase functions new <Function name>
@@ -3384,6 +3432,10 @@ commands:
- id: supabase-config-push
title: supabase config push
summary: Pushes local config.toml to the linked project
description: |
Updates the configurations of a linked Supabase project with the local `supabase/config.toml` file.
This command allows you to manage project configuration as code by defining settings locally and then pushing them to your remote project.
tags: []
links: []
usage: supabase config push
+8 -5
View File
@@ -62,10 +62,13 @@ export const SupabaseGrid = ({
{
keepPreviousData: true,
retryDelay: (retryAttempt, error: any) => {
if (error && error.message?.includes('does not exist')) {
onApplySorts([])
}
if (retryAttempt > 3) {
const doesNotExistError = error && error.message?.includes('does not exist')
const tooManyRequestsError = error.message?.includes('Too Many Requests')
const vaultError = error.message?.includes('query vault failed')
if (doesNotExistError) onApplySorts([])
if (retryAttempt > 3 || doesNotExistError || tooManyRequestsError || vaultError) {
return Infinity
}
return 5000
@@ -82,7 +85,7 @@ export const SupabaseGrid = ({
return (
<DndProvider backend={HTML5Backend} context={window}>
<div className="sb-grid h-full flex flex-col">
<Header sorts={sorts} filters={filters} customHeader={customHeader} />
<Header customHeader={customHeader} />
{children || (
<>
@@ -7,7 +7,8 @@ import { ReactNode, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import type { Filter, Sort } from 'components/grid/types'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import GridHeaderActions from 'components/interfaces/TableGridEditor/GridHeaderActions'
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -34,8 +35,8 @@ import {
Separator,
SonnerProgress,
} from 'ui'
import FilterPopover from './filter/FilterPopover'
import { SortPopover } from './sort'
import { FilterPopover } from './filter/FilterPopover'
import { SortPopover } from './sort/SortPopover'
// [Joshen] CSV exports require this guard as a fail-safe if the table is
// just too large for a browser to keep all the rows in memory before
// exporting. Either that or export as multiple CSV sheets with max n rows each
@@ -52,30 +53,24 @@ export const MAX_EXPORT_ROW_COUNT_MESSAGE = (
)
export type HeaderProps = {
sorts: Sort[]
filters: Filter[]
customHeader: ReactNode
}
const Header = ({ sorts: sortsProp, filters: filtersProp, customHeader }: HeaderProps) => {
const Header = ({ customHeader }: HeaderProps) => {
const snap = useTableEditorTableStateSnapshot()
const filters = filtersProp
const sorts = sortsProp
// const filters = filtersProp
// const sorts = sortsProp
return (
<div>
<div className="flex h-10 items-center justify-between bg-dash-sidebar dark:bg-surface-100 px-1.5 py-1.5 gap-2 overflow-x-auto ">
{customHeader ? (
<>{customHeader}</>
customHeader
) : snap.selectedRows.size > 0 ? (
<RowHeader />
) : (
<>
{snap.selectedRows.size > 0 ? (
<RowHeader sorts={sorts} filters={filters} />
) : (
<DefaultHeader />
)}
</>
<DefaultHeader />
)}
<GridHeaderActions table={snap.originalTable} />
</div>
@@ -226,11 +221,7 @@ const DefaultHeader = () => {
)
}
type RowHeaderProps = {
sorts: Sort[]
filters: Filter[]
}
const RowHeader = ({ sorts, filters }: RowHeaderProps) => {
const RowHeader = () => {
const { project } = useProjectContext()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
@@ -238,6 +229,9 @@ const RowHeader = ({ sorts, filters }: RowHeaderProps) => {
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const isImpersonatingRole = roleImpersonationState.role !== undefined
const { filters } = useTableFilter()
const { sorts } = useTableSort()
const [isExporting, setIsExporting] = useState(false)
const { data } = useTableRowsQuery({
@@ -2,13 +2,13 @@ import { useMemo } from 'react'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
import FilterPopoverPrimitive from './FilterPopoverPrimitive'
import { FilterPopoverPrimitive } from './FilterPopoverPrimitive'
export interface FilterPopoverProps {
portal?: boolean
}
const FilterPopover = ({ portal = true }: FilterPopoverProps) => {
export const FilterPopover = ({ portal = true }: FilterPopoverProps) => {
const { urlFilters, onApplyFilters } = useTableFilter()
// Convert string[] to Filter[]
@@ -20,5 +20,3 @@ const FilterPopover = ({ portal = true }: FilterPopoverProps) => {
<FilterPopoverPrimitive portal={portal} filters={filters} onApplyFilters={onApplyFilters} />
)
}
export default FilterPopover
@@ -2,8 +2,8 @@ import { isEqual } from 'lodash'
import { Filter as FilterIcon, Plus } from 'lucide-react'
import { KeyboardEvent, useCallback, useMemo, useState } from 'react'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import type { Filter } from 'components/grid/types'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
PopoverContent_Shadcn_,
@@ -20,7 +20,7 @@ export interface FilterPopoverPrimitiveProps {
portal?: boolean
}
const FilterPopoverPrimitive = ({
export const FilterPopoverPrimitive = ({
buttonText,
filters,
onApplyFilters,
@@ -134,5 +134,3 @@ const FilterPopoverPrimitive = ({
</Popover_Shadcn_>
)
}
export default FilterPopoverPrimitive
@@ -3,13 +3,13 @@ import { useMemo } from 'react'
import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import SortPopoverPrimitive from './SortPopoverPrimitive'
import { SortPopoverPrimitive } from './SortPopoverPrimitive'
export interface SortPopoverProps {
portal?: boolean
}
const SortPopover = ({ portal = true }: SortPopoverProps) => {
export const SortPopover = ({ portal = true }: SortPopoverProps) => {
const { urlSorts, onApplySorts } = useTableSort()
const tableState = useTableEditorTableStateSnapshot()
const tableName = tableState?.table?.name || ''
@@ -21,5 +21,3 @@ const SortPopover = ({ portal = true }: SortPopoverProps) => {
return <SortPopoverPrimitive portal={portal} sorts={sorts} onApplySorts={onApplySorts} />
}
export default SortPopover
@@ -30,7 +30,7 @@ export interface SortPopoverPrimitiveProps {
* To avoid issues with drag-and-drop reconciliation, we use a special sync mechanism
* that properly detects external vs. internal updates.
*/
const SortPopoverPrimitive = ({
export const SortPopoverPrimitive = ({
buttonText,
sorts,
onApplySorts,
@@ -234,5 +234,3 @@ const SortPopoverPrimitive = ({
</Popover_Shadcn_>
)
}
export default SortPopoverPrimitive
@@ -1,2 +0,0 @@
export { default as SortPopover } from './SortPopover'
export { default as SortPopoverPrimitive } from './SortPopoverPrimitive'
@@ -46,35 +46,10 @@ The `useTableSort` hook manages sort state with these responsibilities:
Key design aspects:
- Uses safe snapshot ONLY to get table name for formatting
- Handles applying table name to sort objects
- Maintains URL parameters as source of truth
- Forwards sort changes to URL and triggers application-specific side effects
### `useSafeTableEditorSnapshot`
```typescript
// Returns: Partial<TableEditorTableState>
```
This hook exists is to satisfy React's rules of hooks:
React hooks CANNOT be called conditionally. Since `useTableSort` is a hook that needs table state, we cannot write:
```typescript
// THIS VIOLATES REACT'S RULES - NOT ALLOWED
function useTableSort() {
const { sorts: urlSorts } = useTableEditorFiltersSort()
// Conditional hook usage is forbidden in React!
const snap = hasTableContext ? useTableEditorTableStateSnapshot() : null
// ...rest of hook
}
```
Instead, we created `useSafeTableEditorSnapshot` which unconditionally calls the original snapshot hook and handles error cases. This allows `useTableSort` to always call the hook safely regardless of context.
## Component Implementation
### FilterPopoverPrimitive and SortPopoverPrimitive
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { saveTableEditorStateToLocalStorage } from 'components/grid/SupabaseGrid.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useCallback } from 'react'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
/**
@@ -8,30 +9,22 @@ import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
*/
export function useSaveTableEditorState() {
const { project } = useProjectContext()
// Use the safe snapshot hook - it returns a guaranteed object (real or default)
const snap = useTableEditorTableStateSnapshot()
const saveDataAndTriggerSideEffects = useCallback(
(dataToSave: { filters?: string[]; sorts?: string[] }) => {
const projectRef = project?.ref
// No need for top-level snap check, safeSnap always returns an object.
if (!projectRef) {
console.warn(
return console.warn(
'[useSaveTableEditorState] ProjectRef missing, cannot save or trigger side effects.'
)
return
}
// --- Side Effects & Saving ---
// Call methods directly. If snap is the default object, these are safe no-ops.
try {
// These calls are safe because defaultSnapshot provides no-op functions
snap.setPage?.(1)
snap.setEnforceExactCount?.(false)
snap.setPage(1)
snap.setEnforceExactCount(false)
// Local Storage Save
// Use optional chaining as table/name might be undefined in defaultSnapshot
const tableName = snap.table?.name
const schema = snap.table?.schema
@@ -43,15 +36,13 @@ export function useSaveTableEditorState() {
...dataToSave,
})
} else {
// Log if saving is skipped because table name is missing (likely due to default snap)
console.warn('[useSaveTableEditorState] Table name missing, skipping localStorage save.')
}
} catch (error) {
// Catch unexpected errors during property access or method calls
console.error('[useSaveTableEditorState] Error during interaction with snapshot:', error)
}
},
[snap, project] // Depend on the safe snap and project
[snap, project]
)
const saveFiltersAndTriggerSideEffects = useCallback(
@@ -1,7 +1,8 @@
import { useCallback } from 'react'
import { filtersToUrlParams, formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
import type { Filter } from 'components/grid/types'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
import { filtersToUrlParams, formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
import { useSaveTableEditorState } from './useSaveTableEditorState'
/**
@@ -18,7 +19,6 @@ export function useTableFilter() {
(appliedFilters: Filter[]) => {
const newUrlFilters = filtersToUrlParams(appliedFilters)
setParams((prevParams) => ({ ...prevParams, filter: newUrlFilters }))
// Trigger save AND side effects using the dedicated hook
saveFiltersAndTriggerSideEffects(newUrlFilters)
},
[setParams, saveFiltersAndTriggerSideEffects]
@@ -1,7 +1,8 @@
import { formatSortURLParams, sortsToUrlParams } from 'components/grid/SupabaseGrid.utils'
import { useCallback, useMemo } from 'react'
import type { Sort } from 'components/grid/types'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
import { useCallback, useMemo } from 'react'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import { useSaveTableEditorState } from './useSaveTableEditorState'
@@ -17,9 +18,7 @@ export function useTableSort() {
const snap = useTableEditorTableStateSnapshot()
const { saveSortsAndTriggerSideEffects } = useSaveTableEditorState()
const tableName = useMemo(() => {
return snap.table?.name || ''
}, [snap])
const tableName = useMemo(() => snap.table?.name || '', [snap])
const sorts = useMemo(() => {
return formatSortURLParams(tableName, urlSorts)
@@ -27,14 +26,13 @@ export function useTableSort() {
const onApplySorts = useCallback(
(appliedSorts: Sort[]) => {
const localTableName = snap.table?.name || ''
if (!localTableName) {
console.warn('[useTableSort] Table name missing in callback, cannot apply sort correctly.')
return
if (!tableName) {
return console.warn(
'[useTableSort] Table name missing in callback, cannot apply sort correctly.'
)
}
const sortsWithTable = appliedSorts.map((sort) => ({ ...sort, table: localTableName }))
const sortsWithTable = appliedSorts.map((sort) => ({ ...sort, table: tableName }))
const newUrlSorts = sortsToUrlParams(sortsWithTable)
setParams((prevParams) => ({ ...prevParams, sort: newUrlSorts }))
@@ -1,24 +1,27 @@
import { Toggle } from 'ui'
import { useConsentState } from 'common'
import Panel from 'components/ui/Panel'
import { useSendResetMutation } from 'data/telemetry/send-reset-mutation'
import { useAppStateSnapshot } from 'state/app-state'
export const AnalyticsSettings = () => {
const snap = useAppStateSnapshot()
const { hasAccepted, acceptAll, denyAll } = useConsentState()
const { mutate: sendReset } = useSendResetMutation()
const onToggleOptIn = () => {
const value = !snap.isOptedInTelemetry ? 'true' : 'false'
snap.setIsOptedInTelemetry(value === 'true')
if (value === 'false') sendReset()
if (hasAccepted) {
denyAll()
sendReset()
} else {
acceptAll()
}
}
return (
<Panel title={<h5 key="panel-title">Analytics</h5>}>
<Panel.Content>
<Toggle
checked={snap.isOptedInTelemetry}
checked={hasAccepted}
onChange={onToggleOptIn}
label="Opt-in to send telemetry data from the dashboard"
descriptionText="By opting into sending telemetry data, Supabase can improve the overall dashboard user experience"
@@ -2,6 +2,7 @@ import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import SVG from 'react-inlinesvg'
import { DEFAULT_SIDEBAR_BEHAVIOR } from 'components/interfaces/Sidebar'
import Panel from 'components/ui/Panel'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { BASE_PATH, LOCAL_STORAGE_KEYS } from 'lib/constants'
@@ -19,7 +20,6 @@ import {
Theme,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { DEFAULT_SIDEBAR_BEHAVIOR } from 'components/interfaces/Sidebar'
export const ThemeSettings = () => {
const [mounted, setMounted] = useState(false)
@@ -51,7 +51,7 @@ export const ThemeSettings = () => {
>
{singleThemes.map((theme: Theme) => (
<RadioGroupLargeItem_Shadcn_
className="grow p-3"
className="p-3"
key={theme.value}
value={theme.value}
label={theme.name}
@@ -7,6 +7,7 @@ import { toast } from 'sonner'
import * as z from 'zod'
import { useParams } from 'common'
import { ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import NoPermission from 'components/ui/NoPermission'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
@@ -14,9 +15,6 @@ import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Card,
CardContent,
@@ -25,14 +23,12 @@ import {
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
WarningIcon,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { isSmtpEnabled } from '../SmtpForm/SmtpForm.utils'
import { ScaffoldSection } from 'components/layouts/Scaffold'
const RateLimits = () => {
const { ref: projectRef } = useParams()
@@ -199,7 +195,7 @@ const RateLimits = () => {
</p>
<div className="mt-3">
<Button asChild type="default" size="tiny">
<Link href={`/project/${projectRef}/settings/auth`}>
<Link href={`/project/${projectRef}/auth/smtp`}>
View SMTP settings
</Link>
</Button>
@@ -375,7 +371,7 @@ const RateLimits = () => {
<p className="mt-1">Enable anonymous logins to update this rate limit</p>
<div className="mt-3">
<Button asChild type="default" size="tiny">
<Link href={`/project/${projectRef}/settings/auth`}>
<Link href={`/project/${projectRef}/auth/providers`}>
View auth settings
</Link>
</Button>
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { toast } from 'sonner'
import {
Button,
cn,
Collapsible,
DropdownMenu,
DropdownMenuContent,
@@ -70,7 +71,7 @@ const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => {
canBypassRls,
}}
onSubmit={onSaveChanges}
className={[
className={cn(
'bg-surface-100',
'hover:bg-overlay-hover',
'data-open:bg-selection',
@@ -80,8 +81,8 @@ const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => {
'-space-y-px overflow-hidden',
'border border-t-0 first:border-t first:!mt-0 hover:border-t hover:-mt-[1px] shadow transition hover:z-50',
'first:rounded-tl first:rounded-tr',
'last:rounded-bl last:rounded-br',
].join(' ')}
'last:rounded-bl last:rounded-br'
)}
>
{({ values, initialValues, handleReset }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
@@ -133,9 +134,7 @@ const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => {
{!disabled && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1">
<MoreVertical />
</Button>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" className="w-[120px]">
<DropdownMenuItem
@@ -221,7 +221,7 @@ const TableList = ({
icon={<Filter />}
/>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-56" side="bottom" align="center">
<PopoverContent_Shadcn_ className="p-0 w-56" side="bottom" align="center" portal={true}>
<div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
<p className="text-xs">Show entity types</p>
<div className="flex flex-col">
@@ -9,27 +9,33 @@ import { Button, SidePanel } from 'ui'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import InformationBox from 'components/ui/InformationBox'
import SqlEditor from 'components/ui/SqlEditor'
import type { DatabaseFunction } from 'data/database-functions/database-functions-query'
import { HelpCircle, Terminal, ChevronDown } from 'lucide-react'
import {
useDatabaseFunctionsQuery,
type DatabaseFunction,
} from 'data/database-functions/database-functions-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { ChevronDown, HelpCircle, Terminal } from 'lucide-react'
export interface ChooseFunctionFormProps {
triggerFunctions: DatabaseFunction[]
visible: boolean
onChange: (id: number) => void
setVisible: (value: boolean) => void
onChange: (fn: DatabaseFunction) => void
}
const ChooseFunctionForm = ({
triggerFunctions,
visible,
onChange,
setVisible,
}: ChooseFunctionFormProps) => {
const ChooseFunctionForm = ({ visible, onChange, setVisible }: ChooseFunctionFormProps) => {
const project = useSelectedProject()
const { data = [] } = useDatabaseFunctionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const triggerFunctions = data.filter((fn) => fn.return_type === 'trigger')
const hasPublicSchemaFunctions = triggerFunctions.length >= 1
const functionSchemas = lodashMap(uniqBy(triggerFunctions, 'schema'), 'schema')
function selectFunction(id: number) {
onChange(id)
const selectFunction = (id: number) => {
const fn = triggerFunctions.find((x) => x.id === id)
if (!!fn) onChange(fn)
setVisible(!visible)
}
@@ -1,792 +0,0 @@
import { has, isEmpty, mapValues, union, without } from 'lodash'
import { makeAutoObservable } from 'mobx'
import { observer, useLocalObservable } from 'mobx-react-lite'
import { createContext, useContext, useEffect, useState } from 'react'
import SVG from 'react-inlinesvg'
import { toast } from 'sonner'
import { Badge, Button, Checkbox, Input, Listbox, SidePanel } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import FormEmptyBox from 'components/ui/FormBoxEmpty'
import NoTableState from 'components/ui/States/NoTableState'
import {
DatabaseFunction,
useDatabaseFunctionsQuery,
} from 'data/database-functions/database-functions-query'
import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation'
import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-mutation'
import { useTablesQuery } from 'data/tables/tables-query'
import { BASE_PATH } from 'lib/constants'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { PauseCircle, PlayCircle, Terminal } from 'lucide-react'
import type { Dictionary } from 'types'
import ChooseFunctionForm from './ChooseFunctionForm'
class CreateTriggerFormState {
id: number | undefined
originalName: string | undefined
// @ts-ignore
activation: { value: string; error?: string }
// @ts-ignore
enabledMode: { value: string }
// @ts-ignore
events: { value: string[]; error?: string }
// @ts-ignore
functionName: { value: string; error?: string }
// @ts-ignore
functionSchema: { value: string; error?: string }
// @ts-ignore
orientation: { value: string; error?: string }
// @ts-ignore
name: { value: string; error?: string }
// @ts-ignore
schema: { value: string }
// @ts-ignore
table: { value: string }
// @ts-ignore
tableId: { value: number; error?: string }
// @ts-ignore
condition: { value: string }
constructor() {
makeAutoObservable(this)
this.reset()
}
get requestBody() {
return {
id: this.id,
activation: this.activation.value,
enabled_mode: this.enabledMode.value,
events: this.events.value,
function_name: this.functionName.value,
function_schema: this.functionSchema.value,
orientation: this.orientation.value,
name: this.name.value,
schema: this.schema.value,
table: this.table.value,
}
}
reset(trigger?: Dictionary<any>) {
this.id = trigger?.id
this.originalName = trigger?.name
this.activation = { value: trigger?.activation ?? 'BEFORE' }
this.enabledMode = { value: trigger?.enabled_mode ?? 'ORIGIN' }
this.events = { value: trigger?.events ?? [] }
this.functionName = { value: trigger?.function_name ?? '' }
this.functionSchema = { value: trigger?.function_schema ?? '' }
this.orientation = { value: trigger?.orientation ?? 'STATEMENT' }
this.name = { value: trigger?.name ?? '' }
this.schema = { value: trigger?.schema ?? '' }
this.table = { value: trigger?.table ?? '' }
this.tableId = { value: trigger?.table_id ?? '' }
this.condition = { value: trigger?.condition ?? '' }
}
update(state: Dictionary<any>) {
this.activation = state.activation
this.enabledMode = state.enabledMode
this.events = state.events
this.functionName = state.functionName
this.functionSchema = state.functionSchema
this.orientation = state.orientation
this.name = state.name
this.schema = state.schema
this.table = state.table
this.tableId = state.tableId
}
}
interface ICreateTriggerStore {
chooseFunctionFormVisible: boolean
formState: CreateTriggerFormState
meta: any
tables: Dictionary<any>[]
onFormChange: ({ key, value }: { key: string; value: any }) => void
onSelectFunction: (fn: DatabaseFunction) => void
setChooseFunctionFormVisible: (value: boolean) => void
setDefaultSelectedTable: () => void
setTables: (value: any[]) => void
validateForm: () => boolean
}
class CreateTriggerStore implements ICreateTriggerStore {
chooseFunctionFormVisible = false
formState = new CreateTriggerFormState()
meta = null
tables = []
isDirty = false
constructor() {
makeAutoObservable(this)
}
get isEditing() {
return this.formState.id != undefined
}
setChooseFunctionFormVisible = (value: boolean) => {
this.chooseFunctionFormVisible = value
}
// set first table as default selection
setDefaultSelectedTable = () => {
if (this.tables?.length != 0) {
this.formState.table.value = (this.tables[0] as any).name
this.formState.schema.value = (this.tables[0] as any).schema
this.formState.tableId.value = (this.tables[0] as any).id
}
}
setisDirty = (value: boolean) => {
this.isDirty = value
}
setTables = (value: any[]) => {
this.tables = value
.sort((a, b) => a.schema.localeCompare(b.schema))
.filter((a) => !PROTECTED_SCHEMAS.includes(a.schema)) as any
this.setDefaultSelectedTable()
}
onFormChange = ({ key, value }: { key: string; value: any }) => {
this.isDirty = true
if (has(this.formState, key)) {
const temp = (this.formState as any)[key]
// @ts-ignore
this.formState[key] = { ...temp, value, error: undefined }
} else {
// @ts-ignore
this.formState[key] = { value }
}
}
onSelectFunction = (fn: DatabaseFunction) => {
this.formState.functionName.value = fn.name
this.formState.functionSchema.value = fn.schema
}
validateForm = () => {
let isValidated = true
const _state = mapValues(this.formState, (x: { value: any }, key: string) => {
switch (key) {
case 'name': {
if (isEmpty(x.value) || hasWhitespace(x.value)) {
isValidated = false
return { ...x, error: 'Invalid trigger name' }
} else {
return x
}
}
case 'activation': {
if (isEmpty(x.value)) {
isValidated = false
return { ...x, error: 'you have an error' }
} else {
return x
}
}
case 'events': {
if (isEmpty(x.value)) {
isValidated = false
return { ...x, error: 'Select at least 1 event' }
} else {
return x
}
}
case 'tableId': {
if (isEmpty(`${x.value}`)) {
isValidated = false
return { ...x, error: 'You must choose a table' }
} else {
return x
}
}
default:
return x
}
})
if (!isValidated) {
this.formState.update(_state)
}
return isValidated
}
}
function hasWhitespace(value: string) {
return /\s/.test(value)
}
const CreateTriggerContext = createContext<ICreateTriggerStore | null>(null)
interface CreateTriggerProps {
trigger?: any
visible: boolean
setVisible: (value: boolean) => void
}
const CreateTrigger = ({ trigger, visible, setVisible }: CreateTriggerProps) => {
const { project } = useProjectContext()
const [isClosingPanel, setIsClosingPanel] = useState(false)
const _localState = useLocalObservable(() => new CreateTriggerStore())
useTablesQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
},
{
onSuccess(tables) {
if (_localState.tables.length <= 0) {
_localState.setTables(tables)
}
},
}
)
const { data } = useDatabaseFunctionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const triggerFunctions = (data ?? []).filter((fn) => fn.return_type === 'trigger')
const { mutate: createDatabaseTrigger, isLoading: isCreating } =
useDatabaseTriggerCreateMutation()
const { mutate: updateDatabaseTrigger, isLoading: isUpdating } =
useDatabaseTriggerUpdateMutation()
useEffect(() => {
_localState.setisDirty(false)
if (trigger) {
_localState.formState.reset(trigger)
} else {
_localState.formState.reset()
_localState.setDefaultSelectedTable()
}
}, [visible, trigger])
async function handleSubmit() {
if (!project) return console.error('Project is required')
if (_localState.validateForm()) {
const body = _localState.formState.requestBody
if (_localState.isEditing && body.id) {
updateDatabaseTrigger(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
id: body.id,
payload: body,
},
{
onSuccess: () => {
toast.success(`Successfully updated trigger ${body.name}`)
setVisible(!visible)
},
onError: (error) => {
toast.error(`Failed to update trigger: ${error.message}`)
},
}
)
} else {
createDatabaseTrigger(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
payload: body,
},
{
onSuccess: () => {
toast.success(`Successfully created trigger ${body.name}`)
setVisible(!visible)
},
onError: (error) => {
toast.error(`Failed to create trigger: ${error.message}`)
},
}
)
}
}
}
const hasPublicTables = _localState.tables.length >= 1
const isClosingSidePanel = () => {
_localState.isDirty ? setIsClosingPanel(true) : setVisible(!visible)
}
return (
<>
<SidePanel
size="large"
visible={visible}
onCancel={isClosingSidePanel}
header={
_localState.formState.id
? `Edit '${_localState.formState.originalName}' trigger`
: 'Add a new Trigger'
}
hideFooter={!hasPublicTables}
className={
_localState.chooseFunctionFormVisible
? 'hooks-sidepanel mr-16 transform transition-all duration-300 ease-in-out'
: 'hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out'
}
loading={isCreating || isUpdating}
onConfirm={handleSubmit}
>
{hasPublicTables ? (
<div>
<CreateTriggerContext.Provider value={_localState}>
<div className="my-6 space-y-10">
{_localState.isEditing ? (
<div className="space-y-6 px-6">
<InputName />
<SelectEnabledMode />
{_localState.formState.condition.value && <DisplayCondition />}
</div>
) : (
<>
<div className="px-6">
<InputName />
</div>
<SidePanel.Separator />
<div className="space-y-12 px-6">
<h5>Conditions to fire trigger</h5>
<ListboxTable />
<CheckboxEvents />
<ListboxActivation />
<SelectOrientation />
</div>
<SidePanel.Separator />
<FunctionForm />
</>
)}
</div>
<ChooseFunctionForm
triggerFunctions={triggerFunctions}
visible={_localState.chooseFunctionFormVisible}
setVisible={_localState.setChooseFunctionFormVisible}
onChange={(id: number) => {
const selectedFn = (data ?? []).find((fn) => fn.id === id)
if (selectedFn) _localState.onSelectFunction(selectedFn)
}}
/>
</CreateTriggerContext.Provider>
<ConfirmationModal
visible={isClosingPanel}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPanel(false)}
onConfirm={() => {
setIsClosingPanel(false)
setVisible(!visible)
}}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes
will be lost.
</p>
</ConfirmationModal>
</div>
) : (
<NoTableState message="You will need to create a table first before you can make a trigger" />
)}
</SidePanel>
</>
)
}
export default observer(CreateTrigger)
const InputName = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<Input
id="name"
label="Name of trigger"
layout="horizontal"
placeholder="Name of trigger"
value={_localState!.formState.name.value}
onChange={(e) =>
_localState!.onFormChange({
key: 'name',
value: e.target.value,
})
}
size="small"
error={_localState!.formState.name.error}
descriptionText="The name is also stored as the actual postgres name of the trigger. Do not use spaces/whitespace."
/>
)
})
const SelectEnabledMode = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<Listbox
id="enabled-mode"
label="Enabled mode"
layout="horizontal"
value={_localState!.formState.enabledMode.value}
onChange={(value) =>
_localState!.onFormChange({
key: 'enabledMode',
value: value,
})
}
size="small"
descriptionText="Determines if a trigger should or should not fire. Can also be used to disable a trigger, but not delete it."
>
<Listbox.Option
addOnBefore={({ active, selected }: any) => {
return (
<div className="h-3 w-3 rounded-full border border-green-700 bg-green-900 shadow-sm"></div>
)
}}
value="ORIGIN"
label="Origin"
>
Origin
<span className="block text-foreground-lighter">This is a default behavior</span>
</Listbox.Option>
<Listbox.Option
addOnBefore={({ active, selected }: any) => {
return (
<div className="h-3 w-3 rounded-full border border-green-700 bg-green-900 shadow-sm"></div>
)
}}
value="REPLICA"
label="Replica"
>
Replica
<span className="block text-foreground-lighter">
Will only fire if the session is in “replica” mode
</span>
</Listbox.Option>
<Listbox.Option
addOnBefore={({ active, selected }: any) => {
return (
<div className="h-3 w-3 rounded-full border border-green-700 bg-green-900 shadow-sm"></div>
)
}}
value="ALWAYS"
label="Always"
>
Always
<span className="block text-foreground-lighter">
Will fire regardless of the current replication role
</span>
</Listbox.Option>
<Listbox.Option
addOnBefore={({ active, selected }: any) => {
return (
<div className="h-3 w-3 rounded-full border border-red-700 bg-red-900 shadow-sm"></div>
)
}}
value="DISABLED"
label="Disabled"
>
Disabled
<span className="block text-foreground-lighter">Will not fire</span>
</Listbox.Option>
</Listbox>
)
})
const DisplayCondition = observer(() => {
const _localState = useContext(CreateTriggerContext)
return (
<div className="text-sm leading-4 grid gap-2 md:grid md:grid-cols-12">
<div className="flex flex-col space-y-2 col-span-4">
<label className="block text-foreground-light text-sm leading-4 break-all">Condition</label>
</div>
<div className="col-span-8 h-[100px]">
<CodeEditor
isReadOnly
autofocus={false}
id={`trigger-condition-${_localState!.formState.id}`}
language="pgsql"
defaultValue={_localState!.formState.condition.value}
/>
<div className="mt-2 text-foreground-lighter leading-normal text-sm">
This condition must be met for the trigger to fire.
<br />
<span className="text-foreground">
To update the condition, you must drop and recreate this trigger.
</span>
</div>
</div>
</div>
)
})
const SelectOrientation = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<Listbox
id="orientation"
label="Orientation"
layout="horizontal"
value={_localState!.formState.orientation.value}
onChange={(value) =>
_localState!.onFormChange({
key: 'orientation',
value: value,
})
}
size="small"
descriptionText="Identifies whether the trigger fires once for each processed row or once for each statement"
>
<Listbox.Option value="ROW" label="Row">
Row
<span className="block text-foreground-lighter">fires once for each processed row</span>
</Listbox.Option>
<Listbox.Option value="STATEMENT" label="Statement">
Statement
<span className="block text-foreground-lighter">fires once for each statement</span>
</Listbox.Option>
</Listbox>
)
})
const ListboxTable = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<Listbox
id="table"
label="Table"
layout="horizontal"
value={_localState!.formState.tableId.value}
onChange={(id) => {
const _table = _localState!.tables.find((x) => x.id === id)
if (_table) {
_localState!.onFormChange({
key: 'table',
value: _table.name,
})
_localState!.onFormChange({
key: 'schema',
value: _table.schema,
})
_localState!.onFormChange({
key: 'tableId',
value: id,
})
}
}}
size="small"
error={_localState!.formState.tableId.error}
descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
>
{_localState!.tables.map((x) => {
return (
<Listbox.Option
id={x.id}
key={x.id}
value={x.id}
label={x.name}
addOnBefore={() => (
<div className="flex items-center justify-center rounded bg-foreground p-1 text-background">
<SVG
src={`${BASE_PATH}/img/table-editor.svg`}
style={{ width: `16px`, height: `16px`, strokeWidth: '1px' }}
preProcessor={(code) =>
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
}
/>
</div>
)}
>
<div className="flex flex-row items-center space-x-1">
<p className="text-sm text-foreground-light">{x.schema}</p>
<p className="text-foreground">{x.name}</p>
</div>
</Listbox.Option>
)
})}
</Listbox>
)
})
const CheckboxEvents = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
// @ts-ignore
<Checkbox.Group
name="events"
label="Events"
id="events"
labelOptional="The type of events that will trigger your trigger"
layout="horizontal"
descriptionText="These are the events that are watched by the trigger, only the events selected above will fire the trigger on the table you've selected."
size="small"
onChange={(e) => {
const temp = _localState!.formState.events.value
const value = e.target.checked
? union(temp, [e.target.value])
: without(temp, e.target.value)
_localState!.onFormChange({
key: 'events',
value: value,
})
}}
error={_localState!.formState.events.error}
>
<Checkbox
value="INSERT"
label="Insert"
description={'Any insert operation on the table'}
checked={_localState!.formState.events.value.includes('INSERT')}
/>
<Checkbox
value="UPDATE"
label="Update"
description="Any update operation, of any column in the table"
checked={_localState!.formState.events.value.includes('UPDATE')}
/>
<Checkbox
value="DELETE"
label="Delete"
description="Any deletion of a record"
checked={_localState!.formState.events.value.includes('DELETE')}
/>
</Checkbox.Group>
)
})
const ListboxActivation = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<Listbox
id="activation"
label="Trigger type"
descriptionText="This determines when your Hook fires"
onChange={(_value) => {
_localState!.onFormChange({
key: 'activation',
value: _value,
})
}}
value={_localState!.formState.activation.value}
layout="horizontal"
size="small"
error={_localState!.formState.activation.error}
>
<Listbox.Option
id={'before'}
value={'BEFORE'}
label={'Before the event'}
addOnBefore={() => (
<div className="flex items-center justify-center rounded bg-foreground p-1 text-background">
<PauseCircle strokeWidth={2} size="18" />
</div>
)}
>
<div className="flex flex-col">
<span>{'before'}</span>
<span className="block text-foreground-lighter">
Trigger fires before the operation is attempted
</span>
</div>
</Listbox.Option>
<Listbox.Option
id={'after'}
value={'AFTER'}
label={'After the event'}
addOnBefore={() => (
<div className="flex items-center justify-center rounded bg-green-1200 p-1 text-background">
<PlayCircle strokeWidth={2} size="18" />
</div>
)}
>
<div className="flex flex-col">
<span>{'after'}</span>
<span className="block text-foreground-lighter">
Trigger fires after the operation has completed
</span>
</div>
</Listbox.Option>
</Listbox>
)
})
const FunctionForm = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<div className="space-y-4">
<div className="space-y-6 px-6">
<h5>Function to trigger</h5>
</div>
<div className="px-6">
{isEmpty(_localState!.formState.functionName.value) ? (
<FunctionEmpty />
) : (
<FunctionWithArguments />
)}
</div>
</div>
)
})
const FunctionEmpty = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<button
type="button"
onClick={() => _localState!.setChooseFunctionFormVisible(true)}
className={[
'relative w-full',
'rounded',
'border border-default',
'bg-surface-200 px-5 py-1',
'shadow-sm transition-all',
'hover:border-strong hover:bg-overlay-hover',
].join(' ')}
>
<FormEmptyBox
icon={<Terminal size={14} strokeWidth={2} />}
text="Choose a function to trigger"
/>
</button>
)
})
const FunctionWithArguments = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<>
<div
className={[
'relative w-full',
'flex items-center justify-between',
'space-x-3 px-5 py-4',
'border border-default',
'rounded shadow-sm transition-shadow',
].join(' ')}
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-foreground text-background focus-within:bg-opacity-10">
<Terminal size="18" strokeWidth={2} width={14} />
</div>
<div className="flex items-center gap-2">
<p className="text-foreground-light">{_localState!.formState.functionName.value}</p>
<div>
<Badge>{_localState!.formState.functionSchema.value}</Badge>
</div>
</div>
</div>
<Button type="default" onClick={() => _localState!.setChooseFunctionFormVisible(true)}>
Change function
</Button>
</div>
</>
)
})
@@ -1,3 +1,4 @@
import { PostgresTrigger } from '@supabase/postgres-meta'
import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -5,12 +6,12 @@ import { useDatabaseTriggerDeleteMutation } from 'data/database-triggers/databas
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
interface DeleteTriggerProps {
trigger?: any
trigger?: PostgresTrigger
visible: boolean
setVisible: (value: boolean) => void
}
const DeleteTrigger = ({ trigger, visible, setVisible }: DeleteTriggerProps) => {
export const DeleteTrigger = ({ trigger, visible, setVisible }: DeleteTriggerProps) => {
const { project } = useProjectContext()
const { id, name, schema } = trigger ?? {}
@@ -45,7 +46,7 @@ const DeleteTrigger = ({ trigger, visible, setVisible }: DeleteTriggerProps) =>
loading={isLoading}
confirmLabel={`Delete trigger ${name}`}
confirmPlaceholder="Type in name of trigger"
confirmString={name}
confirmString={name ?? ''}
text={
<>
This will delete your trigger called{' '}
@@ -59,5 +60,3 @@ const DeleteTrigger = ({ trigger, visible, setVisible }: DeleteTriggerProps) =>
/>
)
}
export default DeleteTrigger
@@ -0,0 +1,448 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Terminal } from 'lucide-react'
import { useEffect, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import * as z from 'zod'
import { PostgresTrigger } from '@supabase/postgres-meta'
import FormBoxEmpty from 'components/ui/FormBoxEmpty'
import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation'
import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-mutation'
import { useTablesQuery } from 'data/tables/tables-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import {
Button,
Checkbox_Shadcn_,
cn,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Separator,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import ChooseFunctionForm from './ChooseFunctionForm'
import {
TRIGGER_ENABLED_MODES,
TRIGGER_EVENTS,
TRIGGER_ORIENTATIONS,
TRIGGER_TYPES,
} from './Triggers.constants'
const formId = 'create-trigger'
const FormSchema = z.object({
name: z
.string()
.min(1, 'Please provide a name for your trigger')
.regex(/^\S+$/, 'Name should not contain spaces or whitespaces'),
schema: z.string(),
table: z.string(),
activation: z.string(),
enabled_mode: z.string(),
orientation: z.string(),
function_name: z.string(),
function_schema: z.string(),
events: z.array(z.string()).min(1, 'Please select at least one event'),
// For UI handling, not to be passed to the final request
tableId: z.string().optional(),
})
const defaultValues = {
name: '',
schema: '',
table: '',
activation: 'AFTER',
orientation: 'ROW',
function_name: '',
function_schema: '',
enabled_mode: 'ORIGIN',
events: [],
}
interface TriggerSheetProps {
selectedTrigger?: PostgresTrigger
open: boolean
setOpen: (val: boolean) => void
}
export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetProps) => {
const project = useSelectedProject()
const [showFunctionSelector, setShowFunctionSelector] = useState(false)
const { mutate: createDatabaseTrigger, isLoading: isCreating } = useDatabaseTriggerCreateMutation(
{
onSuccess: (res) => {
toast.success(`Successfully created trigger ${res.name}`)
setOpen(false)
},
onError: (error) => {
toast.error(`Failed to create trigger: ${error.message}`)
},
}
)
const { mutate: updateDatabaseTrigger, isLoading: isUpdating } = useDatabaseTriggerUpdateMutation(
{
onSuccess: (res) => {
toast.success(`Successfully updated trigger ${res.name}`)
setOpen(false)
},
onError: (error) => {
toast.error(`Failed to update trigger: ${error.message}`)
},
}
)
const { data = [], isSuccess } = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const tables = data
.sort((a, b) => a.schema.localeCompare(b.schema))
.filter((a) => !PROTECTED_SCHEMAS.includes(a.schema))
const isEditing = !!selectedTrigger
const form = useForm<z.infer<typeof FormSchema>>({
mode: 'onSubmit',
reValidateMode: 'onSubmit',
resolver: zodResolver(FormSchema),
defaultValues,
})
const { function_name, function_schema } = form.watch()
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
if (!project) return console.error('Project is required')
const { tableId, ...payload } = values
if (isEditing) {
updateDatabaseTrigger({
projectRef: project?.ref,
connectionString: project?.connectionString,
id: selectedTrigger.id,
payload: { name: payload.name, enabled_mode: payload.enabled_mode },
})
} else {
createDatabaseTrigger({
projectRef: project?.ref,
connectionString: project?.connectionString,
payload,
})
}
}
useEffect(() => {
if (open && isSuccess) {
form.clearErrors()
if (isEditing) {
form.reset(selectedTrigger)
} else {
form.reset({
...defaultValues,
tableId: tables[0].id.toString(),
table: tables[0].name,
schema: tables[0].schema,
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isSuccess])
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent size="lg" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>Create a new database trigger</SheetTitle>
</SheetHeader>
<Form_Shadcn_ {...form}>
<form
id={formId}
className="flex-1 flex flex-col gap-y-6 overflow-auto py-6"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
name="name"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Name of trigger"
description="Do not use spaces/whitespace."
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Name of trigger" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{isEditing ? (
<FormField_Shadcn_
name="enabled_mode"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Enabled mode"
description="Determines if a trigger should or should not fire. Can also be used to disable a trigger, but not delete it."
>
<FormControl_Shadcn_>
<Select_Shadcn_ defaultValue={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_ className="col-span-8">
{
TRIGGER_ENABLED_MODES.find((option) => option.value === field.value)
?.label
}
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{TRIGGER_ENABLED_MODES.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
<p className="text-foreground">{option.label}</p>
<p className="text-foreground-lighter">{option.description}</p>
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
) : (
<>
<Separator />
<FormField_Shadcn_
name="tableId"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Table"
description="Trigger will watch for changes on this table"
>
<FormControl_Shadcn_>
<Select_Shadcn_
defaultValue={field.value}
onValueChange={(val) => {
const table = tables.find((x) => x.id.toString() === val)
if (table) {
form.setValue('table', table.name)
form.setValue('schema', table.schema)
}
}}
>
<SelectTrigger_Shadcn_ className="col-span-8">
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{tables.map((table) => (
<SelectItem_Shadcn_ key={table.id} value={table.id.toString()}>
<span className="text-foreground-light">{table.schema}.</span>
<span className="text-foreground">{table.name}</span>
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
name="events"
control={form.control}
render={() => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Events"
description="These are the events that are watched by the trigger, only the events selected above will fire the trigger on the table you've selected."
>
{TRIGGER_EVENTS.map((event) => (
<FormField_Shadcn_
key={event.value}
control={form.control}
name="events"
render={({ field }) => (
<FormItemLayout
hideMessage
layout="flex"
label={event.label}
description={event.description}
>
<FormControl_Shadcn_>
<Checkbox_Shadcn_
className="translate-y-[2px]"
checked={field.value?.includes(event.value)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, event.value])
: field.onChange(
field.value?.filter((value) => value !== event.value)
)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
))}
</FormItemLayout>
)}
/>
<FormField_Shadcn_
name="activation"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Trigger type"
description="Determines when your trigger fires"
>
<FormControl_Shadcn_>
<Select_Shadcn_ defaultValue={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_ className="col-span-8">
{TRIGGER_TYPES.find((option) => option.value === field.value)?.label}
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{TRIGGER_TYPES.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
<p className="text-foreground">{option.label}</p>
<p className="text-foreground-lighter">{option.description}</p>
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
name="orientation"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="px-5"
layout="horizontal"
label="Orientation"
description="Identifies whether the trigger fires once for each processed row or once for each statement"
>
<FormControl_Shadcn_>
<Select_Shadcn_ defaultValue={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_ className="col-span-8">
{
TRIGGER_ORIENTATIONS.find((option) => option.value === field.value)
?.label
}
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{TRIGGER_ORIENTATIONS.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
<p className="text-foreground">{option.label}</p>
<p className="text-foreground-lighter">{option.description}</p>
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Separator />
<div className="px-5 flex flex-col gap-y-2">
<p className="text-smn">Function to trigger</p>
{function_name.length === 0 ? (
<button
type="button"
className={cn(
'relative w-full rounded border border-default',
'bg-surface-200 px-5 py-1 shadow-sm transition-all',
'hover:border-strong hover:bg-overlay-hover'
)}
onClick={() => setShowFunctionSelector(true)}
>
<FormBoxEmpty
icon={<Terminal size={14} strokeWidth={2} />}
text="Choose a function to trigger"
/>
</button>
) : (
<div
className={cn(
'relative w-full flex items-center justify-between',
'space-x-3 px-5 py-4 border border-default',
'rounded shadow-sm transition-shadow'
)}
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-foreground text-background focus-within:bg-opacity-10">
<Terminal size="18" strokeWidth={2} width={14} />
</div>
<p>
<span className="text-sm text-foreground-light">{function_schema}</span>
.<span className="text-sm text-foreground">{function_name}</span>
</p>
</div>
<Button type="default" onClick={() => setShowFunctionSelector(true)}>
Change function
</Button>
</div>
)}
</div>
</>
)}
</form>
</Form_Shadcn_>
<SheetFooter className="shrink-0">
<Button
type="default"
htmlType="reset"
disabled={isCreating || isUpdating}
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button form={formId} htmlType="submit" loading={isCreating || isUpdating}>
Create trigger
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<ChooseFunctionForm
visible={showFunctionSelector}
setVisible={setShowFunctionSelector}
onChange={(fn) => {
form.setValue('function_name', fn.name)
form.setValue('function_schema', fn.schema)
}}
/>
</>
)
}
@@ -0,0 +1,46 @@
export const TRIGGER_EVENTS = [
{ value: 'INSERT', label: 'Insert', description: 'Any insert operation on the table' },
{
value: 'UPDATE',
label: 'Update',
description: 'Any update operation of any column on the table',
},
{ value: 'DELETE', label: 'Delete', description: 'Any delete operation of a record' },
]
export const TRIGGER_TYPES = [
{
value: 'BEFORE',
label: 'Before the event',
description: 'Trigger fires before the operation is attempted',
},
{
value: 'AFTER',
label: 'After the event',
description: 'Trigger fires before the operation has completed',
},
]
export const TRIGGER_ORIENTATIONS = [
{
value: 'ROW',
label: 'Row',
description: 'Fires once for each processed row',
},
{
value: 'STATEMENT',
label: 'Statement',
description: 'Fires once for each statement',
},
]
export const TRIGGER_ENABLED_MODES = [
{ value: 'ORIGIN', label: 'Origin', description: 'This is the default behaviour' },
{
value: 'REPLICA',
label: 'Replica',
description: 'Will only fire if the session is in "replica" mode',
},
{ value: 'ALWAYS', label: 'Always', description: 'Will fire regardless of the replication role' },
{ value: 'DISABLED', label: 'Disabled', description: 'Will not fire' },
]
@@ -1,6 +1,7 @@
import { PostgresTrigger } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { noop, partition } from 'lodash'
import { Search } from 'lucide-react'
import { Plus, Search } from 'lucide-react'
import { useState } from 'react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -23,8 +24,8 @@ import TriggerList from './TriggerList'
interface TriggersListProps {
createTrigger: () => void
editTrigger: (trigger: any) => void
deleteTrigger: (trigger: any) => void
editTrigger: (trigger: PostgresTrigger) => void
deleteTrigger: (trigger: PostgresTrigger) => void
}
const TriggersList = ({
@@ -111,6 +112,7 @@ const TriggersList = ({
<div className="flex items-center gap-x-2">
<ButtonTooltip
disabled={!canCreateTriggers}
icon={<Plus />}
onClick={() => createTrigger()}
className="flex-grow"
tooltip={{
@@ -122,8 +124,9 @@ const TriggersList = ({
},
}}
>
Create a new trigger
New trigger
</ButtonTooltip>
<ButtonTooltip
type="default"
disabled={!canCreateTriggers}
@@ -10,9 +10,6 @@ export { default as PublicationsTables } from './Publications/PublicationsTables
export { default as BackupsList } from './Backups/BackupsList'
export { default as CreateTrigger } from './Triggers/CreateTrigger'
export { default as DeleteTrigger } from './Triggers/DeleteTrigger'
export { default as CreateFunction } from './Functions/CreateFunction'
export { default as DeleteFunction } from './Functions/DeleteFunction'
@@ -13,6 +13,14 @@ export type ComputeInstanceAddonVariantId =
| 'ci_8xlarge'
| 'ci_12xlarge'
| 'ci_16xlarge'
| 'ci_24xlarge'
| 'ci_24xlarge_optimized_cpu'
| 'ci_24xlarge_optimized_memory'
| 'ci_24xlarge_high_memory'
| 'ci_48xlarge'
| 'ci_48xlarge_optimized_cpu'
| 'ci_48xlarge_optimized_memory'
| 'ci_48xlarge_high_memory'
>
| 'ci_nano'
@@ -28,6 +36,14 @@ export type ComputeInstanceSize =
| '8XL'
| '12XL'
| '16XL'
| '24XL'
| '24XL - Optimized CPU'
| '24XL - Optimized Memory'
| '24XL - High Memory'
| '48XL'
| '48XL - Optimized CPU'
| '48XL - Optimized Memory'
| '48XL - High Memory'
export type InfraInstanceSize =
| 'pico'
@@ -42,6 +58,14 @@ export type InfraInstanceSize =
| '8xlarge'
| '12xlarge'
| '16xlarge'
| '24xlarge'
| '24xlarge_optimized_memory'
| '24xlarge_optimized_cpu'
| '24xlarge_high_memory'
| '48xlarge'
| '48xlarge_optimized_memory'
| '48xlarge_optimized_cpu'
| '48xlarge_high_memory'
export interface DiskManagementMessage {
message: string
@@ -307,6 +307,14 @@ export const mapComputeSizeNameToAddonVariantId = (
'8xlarge': 'ci_8xlarge',
'12xlarge': 'ci_12xlarge',
'16xlarge': 'ci_16xlarge',
'24xlarge': 'ci_24xlarge',
'24xlarge_optimized_memory': 'ci_24xlarge_optimized_memory',
'24xlarge_optimized_cpu': 'ci_24xlarge_optimized_cpu',
'24xlarge_high_memory': 'ci_24xlarge_high_memory',
'48xlarge': 'ci_48xlarge',
'48xlarge_optimized_memory': 'ci_48xlarge_optimized_memory',
'48xlarge_optimized_cpu': 'ci_48xlarge_optimized_cpu',
'48xlarge_high_memory': 'ci_48xlarge_high_memory',
}[computeSize ?? 'nano'] as ComputeInstanceAddonVariantId
}
@@ -325,6 +333,14 @@ export const mapAddOnVariantIdToComputeSize = (
ci_8xlarge: '8XL',
ci_12xlarge: '12XL',
ci_16xlarge: '16XL',
ci_24xlarge: '24XL',
ci_24xlarge_optimized_memory: '24XL - Optimized Memory',
ci_24xlarge_optimized_cpu: '24XL - Optimized CPU',
ci_24xlarge_high_memory: '24XL - High Memory',
ci_48xlarge: '48XL',
ci_48xlarge_optimized_memory: '48XL - Optimized Memory',
ci_48xlarge_optimized_cpu: '48XL - Optimized CPU',
ci_48xlarge_high_memory: '48XL - High Memory',
}[addonVariantId] as ComputeInstanceSize
}
@@ -29,6 +29,7 @@ import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { GB, PROJECT_STATUS } from 'lib/constants'
import { CloudProvider } from 'shared-data'
import {
Button,
cn,
@@ -55,7 +56,6 @@ import { DiskCountdownRadial } from './ui/DiskCountdownRadial'
import { DiskType, RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3 } from './ui/DiskManagement.constants'
import { NoticeBar } from './ui/NoticeBar'
import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection'
import { CloudProvider } from 'shared-data'
export function DiskManagementForm() {
// isLoading is used to avoid a useCheckPermissions() race condition
@@ -114,6 +114,8 @@ export function DiskManagementForm() {
setRefetchInterval(false)
toast.success('Disk configuration changes have been successfully applied!')
}
} else {
setRefetchInterval(2000)
}
},
enabled: project != null && !isFlyArchitecture,
@@ -71,6 +71,14 @@ export const COMPUTE_BASELINE_IOPS = {
ci_8xlarge: 40000,
ci_12xlarge: 50000,
ci_16xlarge: 80000,
ci_24xlarge: 120000,
ci_24xlarge_optimized_cpu: 120000,
ci_24xlarge_optimized_memory: 120000,
ci_24xlarge_high_memory: 120000,
ci_48xlarge: 240000,
ci_48xlarge_optimized_cpu: 240000,
ci_48xlarge_optimized_memory: 240000,
ci_48xlarge_high_memory: 240000,
}
export const COMPUTE_MAX_IOPS = {
ci_nano: 11800,
@@ -84,7 +92,16 @@ export const COMPUTE_MAX_IOPS = {
ci_8xlarge: 40000,
ci_12xlarge: 50000,
ci_16xlarge: 80000,
ci_24xlarge: 120000,
ci_24xlarge_optimized_cpu: 120000,
ci_24xlarge_optimized_memory: 120000,
ci_24xlarge_high_memory: 120000,
ci_48xlarge: 240000,
ci_48xlarge_optimized_cpu: 240000,
ci_48xlarge_optimized_memory: 240000,
ci_48xlarge_high_memory: 240000,
}
export const COMPUTE_BASELINE_THROUGHPUT = {
ci_nano: 43,
ci_micro: 87,
@@ -97,7 +114,16 @@ export const COMPUTE_BASELINE_THROUGHPUT = {
ci_8xlarge: 9500,
ci_12xlarge: 14250,
ci_16xlarge: 19000,
ci_24xlarge: 30000,
ci_24xlarge_optimized_cpu: 30000,
ci_24xlarge_optimized_memory: 30000,
ci_24xlarge_high_memory: 30000,
ci_48xlarge: 40000,
ci_48xlarge_optimized_cpu: 40000,
ci_48xlarge_optimized_memory: 40000,
ci_48xlarge_high_memory: 40000,
}
export const COMPUTE_MAX_THROUGHPUT = {
ci_nano: 2085,
ci_micro: 2085,
@@ -110,6 +136,14 @@ export const COMPUTE_MAX_THROUGHPUT = {
ci_8xlarge: 9500,
ci_12xlarge: 14250,
ci_16xlarge: 19000,
ci_24xlarge: 30000,
ci_24xlarge_optimized_cpu: 30000,
ci_24xlarge_optimized_memory: 30000,
ci_24xlarge_high_memory: 30000,
ci_48xlarge: 40000,
ci_48xlarge_optimized_cpu: 40000,
ci_48xlarge_optimized_memory: 40000,
ci_48xlarge_high_memory: 40000,
}
export const RESTRICTED_COMPUTE_FOR_IOPS_ON_GP3 = ['ci_nano', 'ci_micro', 'ci_small', 'ci_medium']
@@ -79,10 +79,12 @@ const WrapperRow = ({ wrapper }: WrapperRowProps) => {
/>
</Badge>
<Badge className="pl-5 rounded-l-none gap-2 h-6 font-mono text-[0.75rem]">
<Table2 size={12} strokeWidth={1.5} className="text-foreground-lighter/50" />
{table.schema}.{table.table_name}
</Badge>
<Link href={`/project/${ref}/editor/${table.id}`}>
<Badge className="transition hover:bg-surface-300 pl-5 rounded-l-none gap-2 h-6 font-mono text-[0.75rem]">
<Table2 size={12} strokeWidth={1.5} className="text-foreground-lighter/50" />
{table.schema}.{table.table_name}
</Badge>
</Link>
</div>
)
})}
@@ -240,7 +240,7 @@ export const lintInfoMap: LintInfo[] = [
name: 'password_requirements_min_length',
title: 'Minimum password length not set or inadequate',
icon: <Ruler className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
link: ({ projectRef }) => `/project/${projectRef}/auth/providers?provider=Email`,
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/platform/going-into-prod#security',
category: 'security',
@@ -258,7 +258,7 @@ export const lintInfoMap: LintInfo[] = [
name: 'auth_leaked_password_protection',
title: 'Leaked Password Protection Disabled',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
link: ({ projectRef }) => `/project/${projectRef}/auth/providers?provider=Email`,
linkText: 'View settings',
docsLink:
'https://supabase.com/docs/guides/auth/password-security#password-strength-and-leaked-password-protection',
@@ -268,7 +268,7 @@ export const lintInfoMap: LintInfo[] = [
name: 'auth_insufficient_mfa_options',
title: 'Insufficient MFA Options',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
link: ({ projectRef }) => `/project/${projectRef}/auth/mfa`,
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/auth/auth-mfa',
category: 'security',
@@ -277,7 +277,7 @@ export const lintInfoMap: LintInfo[] = [
name: 'auth_password_policy_missing',
title: 'Password Policy Missing',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
link: ({ projectRef }) => `/project/${projectRef}/auth/providers?provider=Email`,
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/auth/password-security',
category: 'security',
@@ -295,7 +295,7 @@ export const lintInfoMap: LintInfo[] = [
name: 'no_backup_admin',
title: 'No Backup Admin Detected',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
link: ({ projectRef }) => `/project/${projectRef}/auth/mfa`,
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/auth/auth-mfa',
category: 'security',
@@ -1,235 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useEffect } from 'react'
import { toast } from 'sonner'
import {
ScaffoldSection,
ScaffoldSectionContent,
ScaffoldSectionDetail,
} from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent } from 'components/ui/Forms/FormSection'
import NoPermission from 'components/ui/NoPermission'
import PartnerManagedResource from 'components/ui/PartnerManagedResource'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { Form, Input, Listbox } from 'ui'
import { COUNTRIES } from './BillingAddress.constants'
const BillingAddress = () => {
const { slug } = useParams()
const selectedOrganization = useSelectedOrganization()
const canReadBillingAddress = useCheckPermissions(
PermissionAction.BILLING_READ,
'stripe.customer'
)
const canUpdateBillingAddress = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.customer'
)
const { data, error, isLoading, isSuccess, isError } = useOrganizationCustomerProfileQuery(
{ slug },
{ enabled: canReadBillingAddress }
)
const { mutate: updateCustomerProfile, isLoading: isUpdating } =
useOrganizationCustomerProfileUpdateMutation()
const formId = 'billing-address-form'
const { city, country, line1, line2, postal_code, state } = data?.address ?? {}
const initialValues = { city, country, line1, line2, postal_code, state }
const validate = (values: any) => {
const errors = {} as any
if (
(values.line1 || values.line2 || values.postal_code || values.state || values.city) &&
!values.country
) {
errors['country'] = 'Please select a country'
}
if (
(values.country || values.line2 || values.postal_code || values.state || values.city) &&
!values.line1
) {
errors['line1'] = 'Please provide an address line'
}
return errors
}
const onSubmit = async (values: any, { resetForm }: any) => {
if (!slug) return console.error('Slug is required')
const address = !values.line1 ? null : values
updateCustomerProfile(
{ slug, address },
{
onSuccess: () => {
toast.success('Successfully updated billing address')
resetForm({ values, initialValues: values })
},
}
)
}
return (
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky space-y-2 top-12 pr-3">
<p className="text-foreground text-base m-0">Billing Address</p>
<p className="text-sm text-foreground-light m-0">
This will be reflected in every upcoming invoice, past invoices are not affected
</p>
</div>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
{selectedOrganization?.managed_by !== undefined &&
selectedOrganization?.managed_by !== 'supabase' ? (
<PartnerManagedResource
partner={selectedOrganization?.managed_by}
resource="Billing Addresses"
cta={{
installationId: selectedOrganization?.partner_id,
}}
/>
) : !canReadBillingAddress ? (
<NoPermission resourceText="view this organization's billing address" />
) : (
<>
{isLoading && (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{isError && (
<AlertError
subject="Failed to retrieve organization customer profile"
error={error as any}
/>
)}
{isSuccess && (
<Form
validateOnBlur
id={formId}
initialValues={initialValues}
validate={validate}
onSubmit={onSubmit}
>
{({ values, initialValues, handleReset, resetForm }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess && data !== undefined) {
const { city, country, line1, line2, postal_code, state } =
data?.address ?? {}
const values = { city, country, line1, line2, postal_code, state }
resetForm({ values, initialValues: values })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess])
return (
<FormPanel
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdating}
hasChanges={hasChanges}
handleReset={handleReset}
helper={
!canUpdateBillingAddress
? "You need additional permissions to manage this organization's billing address"
: undefined
}
/>
</div>
}
>
<FormSection>
<FormSectionContent fullWidth loading={false} className="!gap-2">
<Input
id="line1"
name="line1"
placeholder="Address line 1"
disabled={!canUpdateBillingAddress}
/>
<Input
id="line2"
name="line2"
placeholder="Address line 2"
disabled={!canUpdateBillingAddress}
/>
<div className="flex space-x-2">
<Listbox
className="w-full"
id="country"
name="country"
placeholder="Country"
disabled={!canUpdateBillingAddress}
>
<Listbox.Option label="---" key="empty" value="">
---
</Listbox.Option>
{COUNTRIES.map((country) => (
<Listbox.Option
label={country.name}
key={country.code}
value={country.code}
>
{country.name}
</Listbox.Option>
))}
</Listbox>
<Input
className="w-full"
id="postal_code"
name="postal_code"
placeholder="Postal code"
disabled={!canUpdateBillingAddress}
/>
</div>
<div className="flex space-x-2">
<Input
className="w-full"
id="city"
name="city"
placeholder="City"
disabled={!canUpdateBillingAddress}
/>
<Input
className="w-full"
id="state"
name="state"
placeholder="State"
disabled={!canUpdateBillingAddress}
/>
</div>
</FormSectionContent>
</FormSection>
</FormPanel>
)
}}
</Form>
)}
</>
)}
</ScaffoldSectionContent>
</ScaffoldSection>
)
}
export default BillingAddress
@@ -11,19 +11,17 @@ import {
import AlertError from 'components/ui/AlertError'
import NoPermission from 'components/ui/NoPermission'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import SparkBar from 'components/ui/SparkBar'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import UpcomingInvoice from './UpcomingInvoice'
import SparkBar from 'components/ui/SparkBar'
const BillingBreakdown = () => {
const { slug: orgSlug } = useParams()
const canReadSubscriptions = useCheckPermissions(
PermissionAction.BILLING_READ,
'stripe.subscriptions'
)
const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } =
useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions')
const {
data: subscription,
@@ -74,7 +72,7 @@ const BillingBreakdown = () => {
</div>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
{!canReadSubscriptions ? (
{isPermissionsLoaded && !canReadSubscriptions ? (
<NoPermission resourceText="view this organization's upcoming invoice" />
) : (
<>
@@ -0,0 +1,178 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import {
ScaffoldSection,
ScaffoldSectionContent,
ScaffoldSectionDetail,
} from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import NoPermission from 'components/ui/NoPermission'
import PartnerManagedResource from 'components/ui/PartnerManagedResource'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
import {
useAsyncCheckProjectPermissions,
useCheckPermissions,
} from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { Button, Card, CardFooter, Form_Shadcn_ as Form } from 'ui'
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
import { TAX_IDS } from './TaxID.constants'
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
export const BillingCustomerData = () => {
const { slug } = useParams()
const selectedOrganization = useSelectedOrganization()
const { isSuccess: isPermissionsLoaded, can: canReadBillingCustomerData } =
useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.customer')
const canUpdateBillingCustomerData = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.customer'
)
const {
data: customerProfile,
error,
isLoading,
isSuccess,
} = useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingCustomerData })
const {
data: taxId,
error: errorLoadingTaxId,
isLoading: isLoadingTaxId,
isSuccess: loadedTaxId,
} = useOrganizationTaxIdQuery({ slug })
const initialCustomerData = useMemo(
() => ({
...customerProfile?.address,
billing_name: customerProfile?.billing_name,
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
tax_id_name: taxId
? TAX_IDS.find(
(option) => option.type === taxId.type && option.countryIso2 === taxId.country
)?.name || ''
: '',
}),
[customerProfile, taxId]
)
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
const [isSubmitting, setIsSubmitting] = useState(false)
const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({
initialCustomerData,
onCustomerDataChange: async (data) => {
setIsSubmitting(true)
try {
await updateCustomerProfile({
slug,
address: data.address,
billing_name: data.billing_name,
})
await updateTaxId({ slug, taxId: data.tax_id })
toast.success('Successfully updated billing data')
setIsSubmitting(false)
} catch (error: any) {
toast.error(`Failed updating billing data: ${error.message}`)
setIsSubmitting(false)
}
},
})
const isSubmitDisabled = !isDirty || !canUpdateBillingCustomerData || isSubmitting
return (
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky space-y-2 top-12 pr-3">
<p className="text-foreground text-base m-0">Billing Address &amp; Tax ID</p>
<p className="text-sm text-foreground-light m-0">
This will be reflected in every upcoming invoice, past invoices are not affected
</p>
</div>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
{selectedOrganization?.managed_by !== undefined &&
selectedOrganization?.managed_by !== 'supabase' ? (
<PartnerManagedResource
partner={selectedOrganization?.managed_by}
resource="Billing Addresses"
cta={{
installationId: selectedOrganization?.partner_id,
}}
/>
) : isPermissionsLoaded && !canReadBillingCustomerData ? (
<NoPermission resourceText="view this organization's billing address" />
) : (
<>
{(isLoading || isLoadingTaxId) && (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{(error || errorLoadingTaxId) && (
<AlertError
subject="Failed to retrieve organization customer profile"
error={(error || errorLoadingTaxId) as any}
/>
)}
{isSuccess && loadedTaxId && (
<Card>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<BillingCustomerDataForm
className="p-8"
form={form}
disabled={!canUpdateBillingCustomerData}
/>
<CardFooter className="border-t justify-end px-8">
{!canUpdateBillingCustomerData && (
<span className="text-sm text-foreground-lighter mr-auto">
You need additional permissions to manage this organization's billing
address
</span>
)}
<div className="flex items-center gap-2">
<Button type="default" onClick={handleReset} disabled={isSubmitDisabled}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
disabled={isSubmitDisabled}
loading={isSubmitting}
>
Save
</Button>
</div>
</CardFooter>
</form>
</Form>
</Card>
)}
</>
)}
</ScaffoldSectionContent>
</ScaffoldSection>
)
}
@@ -0,0 +1,215 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Pencil } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import AlertError from 'components/ui/AlertError'
import NoPermission from 'components/ui/NoPermission'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSectionSeparator,
DialogTitle,
Form_Shadcn_ as Form,
Label_Shadcn_ as Label,
} from 'ui'
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
import { TAX_IDS } from './TaxID.constants'
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
export const BillingCustomerDataExistingOrgDialog = () => {
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const { slug } = useSelectedOrganization() ?? {}
const canReadBillingCustomerData = useCheckPermissions(
PermissionAction.BILLING_READ,
'stripe.customer'
)
const canUpdateBillingCustomerData = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.customer'
)
const {
data: customerProfile,
error,
isLoading,
isSuccess,
} = useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingCustomerData })
const {
data: taxId,
error: errorTaxId,
isLoading: isLoadingTaxId,
isSuccess: isSuccessTaxId,
} = useOrganizationTaxIdQuery({ slug })
const initialCustomerData = useMemo(
() => ({
...customerProfile?.address,
billing_name: customerProfile?.billing_name,
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
tax_id_name: taxId
? TAX_IDS.find(
(option) => option.type === taxId.type && option.countryIso2 === taxId.country
)?.name || ''
: '',
}),
[customerProfile, taxId]
)
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({
initialCustomerData,
onCustomerDataChange: async (data) => {
setIsSubmitting(true)
try {
await updateCustomerProfile({
slug,
address: data.address ?? undefined,
billing_name: data.billing_name,
})
await updateTaxId({ slug, taxId: data.tax_id })
toast.success('Successfully updated billing data')
setOpen(false)
setIsSubmitting(false)
} catch (error: any) {
toast.error(`Failed updating billing data: ${error.message}`)
setIsSubmitting(false)
}
},
})
const handleClose = () => {
handleReset()
setOpen(false)
}
const getAddressSummary = () => {
if (!customerProfile?.address?.line1) return 'Optionally add a billing address'
const parts = [
customerProfile?.billing_name,
customerProfile?.address?.line1,
customerProfile?.address?.city,
customerProfile?.address?.state,
customerProfile?.address?.country,
].filter(Boolean)
return parts.join(', ')
}
const isSubmitDisabled = !isDirty || !canUpdateBillingCustomerData || isSubmitting
return (
<>
<div>
<Label htmlFor="billing-address-btn" className="text-foreground-light block mb-0">
Billing Address / Tax Id
</Label>
{!canReadBillingCustomerData ? (
<NoPermission resourceText="view this organization's billing address" />
) : (
<>
{(isLoading || isLoadingTaxId) && (
<div className="space-y-2">
<ShimmeringLoader />
</div>
)}
{(error || errorTaxId) && (
<AlertError
subject="Failed to retrieve organization customer profile"
error={(error || errorTaxId) as any}
/>
)}
{isSuccess && isSuccessTaxId && (
<div className="flex items-center justify-between">
<p className="text-xs text-foreground">{getAddressSummary()}</p>
<Button
id="billing-address-btn"
onClick={() => setOpen(true)}
type="text"
aria-label="Edit"
size={'tiny'}
className="w-8 h-8 p-0 text-foreground-light"
disabled={!canUpdateBillingCustomerData}
>
<Pencil size={14} strokeWidth={1.5} />
</Button>
</div>
)}
</>
)}
</div>
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) setOpen(false)
else setOpen(true)
}}
>
<DialogContent size={'large'}>
<DialogHeader>
<DialogTitle>Billing Address &amp; Tax Id</DialogTitle>
<DialogDescription>
This will be reflected in every upcoming invoice, past invoices are not affected
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<BillingCustomerDataForm
className="p-5"
form={form}
disabled={!canUpdateBillingCustomerData}
/>
<DialogFooter className="justify-end">
{!canUpdateBillingCustomerData && (
<span className="text-sm text-foreground-lighter">
You need additional permissions to manage this organization's billing address
</span>
)}
<div className="flex items-center gap-2">
<Button type="default" onClick={handleClose}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
disabled={isSubmitDisabled}
loading={isSubmitting}
>
Save
</Button>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,338 @@
import { Check, ChevronsUpDown, X } from 'lucide-react'
import { UseFormReturn } from 'react-hook-form'
import { z } from 'zod'
import { useState } from 'react'
import {
Button,
cn,
Command_Shadcn_ as Command,
CommandEmpty_Shadcn_ as CommandEmpty,
CommandGroup_Shadcn_ as CommandGroup,
CommandInput_Shadcn_ as CommandInput,
CommandItem_Shadcn_ as CommandItem,
CommandList_Shadcn_ as CommandList,
FormControl_Shadcn_ as FormControl,
FormField_Shadcn_ as FormField,
FormMessage_Shadcn_ as FormMessage,
Input_Shadcn_ as Input,
Input_Shadcn_,
Popover_Shadcn_ as Popover,
PopoverContent_Shadcn_ as PopoverContent,
PopoverTrigger_Shadcn_ as PopoverTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { COUNTRIES } from './BillingAddress.constants'
import { TAX_IDS } from './TaxID.constants'
interface BillingCustomerDataFormProps {
form: UseFormReturn<BillingCustomerDataFormValues>
disabled?: boolean
className?: string
}
// Define the expected form values structure and validation schema
export const BillingCustomerDataSchema = z
.object({
billing_name: z.string().min(3, 'Name must be at least 3 letters long'),
line1: z.string().optional(),
line2: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
postal_code: z.string().optional(),
country: z.string().optional(),
tax_id_type: z.string(),
tax_id_value: z.string(),
tax_id_name: z.string(),
})
.refine(
(data) => {
// its fine to just set the name, but once any other field is set, requires full address
const hasAnyField = data.line1 || data.line2 || data.city || data.state || data.postal_code
// If any field has value, country and line1 must have values.
return !hasAnyField || (!!data.country && !!data.line1)
},
{
message: 'Country and Address line 1 are required if any other field is provided.',
path: ['line1'],
}
)
.refine((data) => !(!!data.line1 && !data.country), {
message: 'Please select a country',
path: ['country'],
})
.refine((data) => !(!!data.country && !data.line1), {
message: 'Please provide an address line 1',
path: ['line1'],
})
export type BillingCustomerDataFormValues = z.infer<typeof BillingCustomerDataSchema>
export const BillingCustomerDataForm = ({
form,
disabled = false,
className,
}: BillingCustomerDataFormProps) => {
const [showCountriesPopover, setShowCountriesPopover] = useState(false)
const [showTaxIDsPopover, setShowTaxIDsPopover] = useState(false)
const onSelectTaxIdType = (name: string) => {
const selectedTaxIdOption = TAX_IDS.find((option) => option.name === name)
if (!selectedTaxIdOption) return
form.setValue('tax_id_type', selectedTaxIdOption.type)
form.setValue('tax_id_value', '')
form.setValue('tax_id_name', name)
}
const onRemoveTaxId = () => {
form.setValue('tax_id_name', '', { shouldDirty: true })
form.setValue('tax_id_type', '', { shouldDirty: true })
form.setValue('tax_id_value', '', { shouldDirty: true })
}
const { tax_id_name } = form.watch()
const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
return (
<div className={cn('flex flex-col space-y-4', className)}>
<FormField
control={form.control}
name="billing_name"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormControl>
<Input {...field} placeholder="Name" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="line1"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormControl>
<Input {...field} placeholder="Address line 1" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="line2"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormControl>
<Input {...field} placeholder="Address line 2 (Optional)" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="country"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<Popover open={showCountriesPopover} onOpenChange={setShowCountriesPopover}>
<PopoverTrigger asChild>
<FormControl>
<Button
type="default"
role="combobox"
size="medium"
disabled={disabled}
className={cn(
'w-full justify-between h-[34px]',
!field.value && 'text-muted'
)}
iconRight={
<ChevronsUpDown
className="ml-2 h-4 w-4 shrink-0 opacity-50"
strokeWidth={1.5}
/>
}
>
{field.value
? COUNTRIES.find((country) => country.code === field.value)?.name ||
'Select country'
: 'Select country'}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent sameWidthAsTrigger className="p-0" align="start">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{COUNTRIES.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() => {
form.setValue('country', country.code, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
setShowCountriesPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
field.value === country.code ? 'opacity-100' : 'opacity-0'
)}
/>
{country.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="postal_code"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormControl>
<Input {...field} placeholder="Postal code" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="city"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormControl>
<Input {...field} placeholder="City" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="state"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormControl>
<Input {...field} placeholder="State / Province" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
)}
/>
</div>
<div className="grid grid-cols-2 gap-x-2 w-full items-end">
<FormField
name="tax_id_name"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage layout="vertical" label="Tax ID">
<Popover open={showTaxIDsPopover} onOpenChange={setShowTaxIDsPopover}>
<PopoverTrigger asChild>
<FormControl>
<Button
type="default"
role="combobox"
size="medium"
disabled={disabled}
className={cn(
'w-full justify-between h-[34px]',
!selectedTaxId && 'text-muted'
)}
iconRight={
<ChevronsUpDown
className="ml-2 h-4 w-4 shrink-0 opacity-50"
strokeWidth={1.5}
/>
}
>
{selectedTaxId
? `${selectedTaxId.country} - ${selectedTaxId.name}`
: 'Select tax ID'}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent sameWidthAsTrigger className="p-0" align="start">
<Command>
<CommandInput placeholder="Search tax ID..." />
<CommandList>
<CommandEmpty>No tax ID found.</CommandEmpty>
<CommandGroup>
{TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map(
(option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItemLayout>
)}
/>
{selectedTaxId && (
<div className="flex items-center space-x-2">
<FormField
name="tax_id_value"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage className="w-full">
<FormControl>
<Input_Shadcn_
{...field}
placeholder={selectedTaxId?.placeholder}
disabled={disabled}
/>
</FormControl>
</FormItemLayout>
)}
/>
<Button type="text" className="px-1" icon={<X />} onClick={() => onRemoveTaxId()} />
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,122 @@
import { FocusTrap } from '@headlessui/react'
import { Pencil } from 'lucide-react'
import { useState } from 'react'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_ as Form,
} from 'ui'
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
import { FormCustomerData, useBillingCustomerDataForm } from './useBillingCustomerDataForm'
interface BillingCustomerDataNewOrgDialogProps {
onCustomerDataChange: (data: FormCustomerData) => void
}
export const BillingCustomerDataNewOrgDialog = ({
onCustomerDataChange,
}: BillingCustomerDataNewOrgDialogProps) => {
const [open, setOpen] = useState(false)
const handleDialogClose = () => {
setOpen(false)
}
const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({
onCustomerDataChange,
})
const handleClose = () => {
handleReset()
handleDialogClose()
}
const getAddressSummary = () => {
const { line1, city, state, country, billing_name } = form.getValues()
if (!line1) return 'Optionally add a billing address'
const parts = [billing_name, line1, city, state, country].filter(Boolean)
return parts.join(', ')
}
const isSubmitDisabled = !isDirty
return (
<div className="flex items-center justify-between">
<label className="text-sm text-foreground-light" htmlFor="billing-address-btn">
{getAddressSummary()}
</label>
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) handleDialogClose()
else setOpen(true)
}}
>
<DialogTrigger asChild>
<Button
id="billing-address-btn"
onClick={() => setOpen(true)}
type="text"
aria-label="Edit"
size={'tiny'}
className="w-8 h-8 p-0 text-foreground-light"
>
<Pencil size={14} strokeWidth={1.5} />
</Button>
</DialogTrigger>
<DialogContent size="large">
<DialogHeader>
<DialogTitle>Billing Address &amp; Tax Id</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
{/*
[Joshen] There's something odd going on with using Dialog and RHF FormField here
where the focus keeps going to the Dialog when tabbing across the input fields, hence the FocusTrap
What's weirder is that once you've cycled the focus through all the inputs at least once, the focus
then no longer goes to the Dialog thereafter
FWIW it's likely happening across the dashboard whereever we're using Dialog and FormField
*/}
<FocusTrap>
<Form {...form}>
<form
id="new-org-billing-data-form"
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit(handleSubmit)(e)
// Only close the dialog if the form is valid
if (!Object.keys(form.formState.errors).length) {
handleDialogClose()
}
}}
>
<BillingCustomerDataForm className="p-5" form={form} />
<DialogFooter className="justify-end">
<div className="flex items-center gap-2">
<Button type="default" onClick={handleClose}>
Cancel
</Button>
<Button type="primary" htmlType="submit" disabled={isSubmitDisabled}>
Continue
</Button>
</div>
</DialogFooter>
</form>
</Form>
</FocusTrap>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,131 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { BillingCustomerDataFormValues, BillingCustomerDataSchema } from './BillingCustomerDataForm'
import { TAX_IDS } from './TaxID.constants'
import { sanitizeTaxIdValue } from './TaxID.utils'
import { components } from 'api-types'
export type FormCustomerData = {
address: components['schemas']['CustomerResponse']['address'] | undefined
billing_name: string
tax_id: components['schemas']['TaxIdResponse']['tax_id'] | null
}
interface UseBillingAddressFormProps {
initialCustomerData?: Partial<BillingCustomerDataFormValues> | null
onCustomerDataChange: (data: FormCustomerData) => void
}
export function useBillingCustomerDataForm({
initialCustomerData,
onCustomerDataChange,
}: UseBillingAddressFormProps) {
const form = useForm<BillingCustomerDataFormValues>({
resolver: zodResolver(BillingCustomerDataSchema),
defaultValues: {
billing_name: initialCustomerData?.billing_name || '',
line1: initialCustomerData?.line1 || '',
line2: initialCustomerData?.line2 || '',
city: initialCustomerData?.city || '',
state: initialCustomerData?.state || '',
postal_code: initialCustomerData?.postal_code || '',
country: initialCustomerData?.country || '',
tax_id_type: initialCustomerData?.tax_id_type || '',
tax_id_value: initialCustomerData?.tax_id_value || '',
tax_id_name: initialCustomerData?.tax_id_name || '',
},
})
// Update form when initialAddress changes
useEffect(() => {
if (initialCustomerData) {
form.reset({
billing_name: initialCustomerData.billing_name || '',
line1: initialCustomerData.line1 || '',
line2: initialCustomerData.line2 || '',
city: initialCustomerData.city || '',
state: initialCustomerData.state || '',
postal_code: initialCustomerData.postal_code || '',
country: initialCustomerData.country || '',
tax_id_type: initialCustomerData?.tax_id_type || '',
tax_id_value: initialCustomerData?.tax_id_value || '',
tax_id_name: initialCustomerData?.tax_id_name || '',
})
}
}, [initialCustomerData])
const handleSubmit = async (values: BillingCustomerDataFormValues) => {
const trimmedValues = Object.entries(values).reduce((acc, [key, value]) => {
acc[key as keyof BillingCustomerDataFormValues] =
typeof value === 'string' ? value.trim() : value
return acc
}, {} as BillingCustomerDataFormValues)
const addressPayload = !trimmedValues.line1 ? null : trimmedValues
const selectedTaxId = TAX_IDS.find((option) => option.name === values.tax_id_name)
onCustomerDataChange({
address: addressPayload
? {
line1: trimmedValues.line1!,
line2: trimmedValues.line2,
city: trimmedValues.city,
state: trimmedValues.state,
postal_code: trimmedValues.postal_code,
country: trimmedValues.country!,
}
: undefined,
billing_name: trimmedValues.billing_name,
tax_id:
selectedTaxId && values.tax_id_type?.length && values.tax_id_value?.length
? {
type: values.tax_id_type,
value: sanitizeTaxIdValue({
value: values.tax_id_value,
name: form.getValues().tax_id_name,
}),
country: selectedTaxId?.countryIso2,
}
: null,
})
}
const handleReset = () => {
if (initialCustomerData) {
form.reset({
billing_name: initialCustomerData.billing_name || '',
line1: initialCustomerData.line1 || '',
line2: initialCustomerData.line2 || '',
city: initialCustomerData.city || '',
state: initialCustomerData.state || '',
postal_code: initialCustomerData.postal_code || '',
country: initialCustomerData.country || '',
tax_id_type: initialCustomerData?.tax_id_type || '',
tax_id_value: initialCustomerData?.tax_id_value || '',
tax_id_name: initialCustomerData?.tax_id_name || '',
})
} else {
form.reset({
line1: '',
line2: '',
city: '',
state: '',
postal_code: '',
country: '',
tax_id_type: '',
tax_id_value: '',
tax_id_name: '',
})
}
}
return {
form,
handleSubmit,
handleReset,
isDirty: form.formState.isDirty,
}
}
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff. Show more