mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 12:55:06 +03:00
Merge branch 'master' into patch-3
This commit is contained in:
112 files changed
+3260
-1174
No files matched your search
@@ -117,10 +117,6 @@ await waitForApiResponse(page, 'pg-meta', ref, 'tables')
|
||||
// ✅ Acceptable - waiting for client-side debounce
|
||||
await page.getByRole('textbox').fill('search term')
|
||||
await page.waitForTimeout(300) // Allow debounce to complete
|
||||
|
||||
// ✅ Acceptable - waiting for clipboard API
|
||||
await page.evaluate(() => navigator.clipboard.readText())
|
||||
await page.waitForTimeout(500)
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
@@ -274,6 +270,22 @@ import {
|
||||
} from '../utils/wait-for-response.js'
|
||||
```
|
||||
|
||||
### Use the existing assertions utilities
|
||||
|
||||
#### Clipboard assertions
|
||||
|
||||
```ts
|
||||
// ❌ Avoid - brittle hard coded timeout
|
||||
await page.evaluate(() => navigator.clipboard.readText())
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// ✅ Good - this utility function uses Playwright auto-retries mechanisms
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: 'expectedValue'
|
||||
})
|
||||
```
|
||||
|
||||
## API Mocking
|
||||
|
||||
### Mock APIs for isolated testing
|
||||
|
||||
@@ -14,7 +14,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
return new NextResponse(content, {
|
||||
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600',
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return new NextResponse('Not found', { status: 404 })
|
||||
|
||||
@@ -146,18 +146,27 @@ const urlTransform: (lints: Array<{ path: string }>) => UrlTransformFunction = (
|
||||
const getLints = async () => {
|
||||
const octokit = new Octokit({ request: { fetch: fetchRevalidatePerDay } })
|
||||
|
||||
const response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', {
|
||||
owner: org,
|
||||
repo: repo,
|
||||
path: docsDir,
|
||||
ref: branch,
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
})
|
||||
let response: Awaited<ReturnType<typeof octokit.request>>
|
||||
try {
|
||||
response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', {
|
||||
owner: org,
|
||||
repo: repo,
|
||||
path: docsDir,
|
||||
ref: branch,
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to fetch database advisors lint list from GitHub (network error): ${err}`
|
||||
)
|
||||
}
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw Error(`Could not get contents of repo ${org}/${repo}`)
|
||||
throw new Error(
|
||||
`Failed to fetch ${org}/${repo}/contents/${docsDir} docs from GitHub: ${response.status}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!Array.isArray(response.data)) {
|
||||
@@ -170,12 +179,21 @@ const getLints = async () => {
|
||||
|
||||
const lints = await Promise.all(
|
||||
lintsList.map(async ({ path }) => {
|
||||
const fileResponse = await fetchRevalidatePerDay(
|
||||
`https://raw.githubusercontent.com/${org}/${repo}/${branch}/${path}`
|
||||
)
|
||||
let fileResponse: Response
|
||||
try {
|
||||
fileResponse = await fetchRevalidatePerDay(
|
||||
`https://raw.githubusercontent.com/${org}/${repo}/${branch}/${path}`
|
||||
)
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to fetch database advisors lint file ${path} from GitHub (network error): ${err}`
|
||||
)
|
||||
}
|
||||
|
||||
if (fileResponse.status >= 400) {
|
||||
throw Error(`Could not get contents of file ${org}/${repo}/${path}`)
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
`Failed to fetch ${org}/${repo}/${branch}/${path} docs from GitHub: ${response.status}`
|
||||
)
|
||||
}
|
||||
|
||||
const content = await fileResponse.text()
|
||||
|
||||
@@ -360,10 +360,22 @@ const getContent = async (params: Params) => {
|
||||
const repoPath = `${org}/${repo}/${tag}/${docsDir}/${remoteFile}`
|
||||
editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}`
|
||||
|
||||
const response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, {
|
||||
cache: 'force-cache',
|
||||
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
|
||||
})
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, {
|
||||
cache: 'force-cache',
|
||||
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch wrappers docs from GitHub (network error): ${err}`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch wrappers docs from GitHub: ${response.status} ${response.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
const rawContent = await response.text()
|
||||
|
||||
assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/`
|
||||
|
||||
@@ -64,7 +64,7 @@ const pageMap = [
|
||||
title: 'Computed Fields',
|
||||
subtitle: 'Using Postgres Computed Fields with GraphQL.',
|
||||
},
|
||||
remoteFile: 'computed-fields.md',
|
||||
remoteFile: 'computed_fields.md',
|
||||
},
|
||||
{
|
||||
slug: 'configuration',
|
||||
|
||||
@@ -30,7 +30,15 @@ function AiTools({ className }: { className?: string }) {
|
||||
|
||||
try {
|
||||
const res = await fetch(mdUrl)
|
||||
const text = await res.text()
|
||||
let text: string
|
||||
|
||||
if (res.ok) {
|
||||
text = await res.text()
|
||||
} else {
|
||||
// Default to HTML content within the article when no .md file is available.
|
||||
text = document.getElementById('sb-docs-guide-main-article')?.innerHTML ?? ''
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
|
||||
@@ -773,6 +773,12 @@ export const auth: NavMenuConstant = {
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Custom OAuth/OIDC Providers',
|
||||
url: '/guides/auth/custom-oauth-providers',
|
||||
enabled: allAuthProvidersEnabled,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Anonymous Sign-Ins',
|
||||
url: '/guides/auth/auth-anonymous',
|
||||
@@ -1547,6 +1553,11 @@ export const graphql: NavMenuConstant = {
|
||||
{ name: 'API', url: '/guides/graphql/api', items: [] },
|
||||
{ name: 'Views', url: '/guides/graphql/views', items: [] },
|
||||
{ name: 'Functions', url: '/guides/graphql/functions', items: [] },
|
||||
{
|
||||
name: 'Computed Fields',
|
||||
url: '/guides/graphql/computed-fields',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Configuration & Customization',
|
||||
url: '/guides/graphql/configuration',
|
||||
|
||||
@@ -6,6 +6,12 @@ Supabase Auth works with many popular Auth methods, including Social and Phone A
|
||||
|
||||
<AuthProviders type="social" />
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
You can also add any OAuth2 or OIDC-compatible identity provider using [Custom OAuth/OIDC Providers](/docs/guides/auth/custom-oauth-providers).
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Phone Auth
|
||||
|
||||
<AuthProviders type="phone" />
|
||||
@@ -372,7 +372,7 @@ Email sending depends on two settings: Email Provider and Auth Hook status.
|
||||
|
||||
When `email_action_type` is `email_change`, the hook payload can include one or two OTPs and their hashes. This depends on your [Secure Email Change](/dashboard/project/_/auth/providers?provider=Email) setting.
|
||||
|
||||
- Secure Email Change enabled: two OTPs are generated, one for the current email (`user.email`) and one for the new email (`user.email_new`). You must send two emails.
|
||||
- Secure Email Change enabled: two OTPs are generated, one for the current email (`user.email`) and one for the new email (`user.new_email`). You must send two emails.
|
||||
- Secure Email Change disabled: only one OTP is generated for the new email. You send a single email.
|
||||
|
||||
<Admonition type="caution" title="Counterintuitive field naming">
|
||||
@@ -380,7 +380,7 @@ When `email_action_type` is `email_change`, the hook payload can include one or
|
||||
The token hash field names are reversed due to backward compatibility. Pay careful attention to which token/hash pair goes with which email address:
|
||||
|
||||
- `token_hash_new` → use with the **current** email address (`user.email`) and `token`
|
||||
- `token_hash` → use with the **new** email address (`user.email_new`) and `token_new`
|
||||
- `token_hash` → use with the **new** email address (`user.new_email`) and `token_new`
|
||||
|
||||
Do not assume the `_new` suffix refers to the new email address.
|
||||
|
||||
@@ -391,7 +391,7 @@ Do not assume the `_new` suffix refers to the new email address.
|
||||
When Secure Email Change is enabled (both token/hash pairs present):
|
||||
|
||||
- Send to **current** email address (`user.email`): use `token` with `token_hash_new`
|
||||
- Send to **new** email address (`user.email_new`): use `token_new` with `token_hash`
|
||||
- Send to **new** email address (`user.new_email`): use `token_new` with `token_hash`
|
||||
|
||||
When Secure Email Change is **disabled** (only one token/hash pair present):
|
||||
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
---
|
||||
id: 'custom-oauth-providers'
|
||||
title: 'Custom OAuth/OIDC Providers'
|
||||
description: 'Add any OAuth2 or OIDC-compatible identity provider to your Supabase project'
|
||||
---
|
||||
|
||||
Custom OAuth/OIDC providers let you integrate any standards-compliant identity provider with Supabase Auth, beyond the ones Supabase supports out of the box.
|
||||
|
||||
Each custom provider uses a `custom:` prefix in its identifier (for example, `custom:my-idp` or `custom:github-enterprise`). This prefix distinguishes custom providers from built-in providers.
|
||||
|
||||
There are two provider types:
|
||||
|
||||
- **OAuth2**: for generic OAuth2 providers where you supply the authorization, token, and userinfo endpoints manually.
|
||||
- **OIDC**: for providers that support [OpenID Connect](https://openid.net/connect/) discovery. You supply only the issuer URL and endpoints are resolved automatically.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
You can add up to 3 custom providers per project. If you need more, [contact support](/dashboard/support/new).
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Creating a provider
|
||||
|
||||
The create form displays a read-only **Callback URL**. Copy this URL and configure it as the redirect/callback URI in your external identity provider before completing setup.
|
||||
|
||||
### OAuth2 provider
|
||||
|
||||
Use an OAuth2 provider when your identity provider does not support OpenID Connect discovery. You must supply the authorization, token, and userinfo endpoint URLs explicitly.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="dashboard"
|
||||
queryGroup="setup-method"
|
||||
>
|
||||
<TabPanel id="dashboard" label="Dashboard">
|
||||
|
||||
1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard.
|
||||
2. Click **New Provider**. Select **Manual configuration** as the configuration method.
|
||||
3. Enter a unique identifier (must start with `custom:`, for example `custom:my-oauth-provider`).
|
||||
4. Enter the provider's **Client ID** and **Client Secret**.
|
||||
5. Enter the **Authorization URL**, **Token URL**, and **UserInfo URL**.
|
||||
6. Click **Create and enable provider**.
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase.auth.admin.customProviders.createProvider({
|
||||
provider_type: 'oauth2',
|
||||
identifier: 'custom:my-oauth-provider',
|
||||
name: 'My OAuth Provider',
|
||||
client_id: 'your-client-id',
|
||||
client_secret: 'your-client-secret',
|
||||
authorization_url: 'https://provider.example.com/oauth/authorize',
|
||||
token_url: 'https://provider.example.com/oauth/token',
|
||||
userinfo_url: 'https://provider.example.com/oauth/userinfo',
|
||||
scopes: ['profile', 'email'],
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### OIDC provider
|
||||
|
||||
Use an OIDC provider when your identity provider supports OpenID Connect. Supply the `issuer` URL and the discovery document, JWKS, and endpoints are resolved automatically.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="dashboard"
|
||||
queryGroup="setup-method"
|
||||
>
|
||||
<TabPanel id="dashboard" label="Dashboard">
|
||||
|
||||
1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard.
|
||||
2. Click **New Provider**. Select **Auto-discovery (OIDC)** as the configuration method.
|
||||
3. Enter a unique identifier (must start with `custom:`, for example `custom:my-regional-provider`).
|
||||
4. Enter the provider's **Client ID** and **Client Secret**.
|
||||
5. Enter the **Issuer URL**. The discovery document and endpoints are resolved automatically.
|
||||
6. Click **Create and enable provider**.
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase.auth.admin.customProviders.createProvider({
|
||||
provider_type: 'oidc',
|
||||
identifier: 'custom:my-regional-provider',
|
||||
name: 'Regional Provider',
|
||||
client_id: 'your-client-id',
|
||||
client_secret: 'your-client-secret',
|
||||
issuer: 'https://auth.example.com',
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
OIDC providers have the following automatic behavior:
|
||||
|
||||
- The discovery document is fetched from `{issuer}/.well-known/openid-configuration` (or from `discovery_url` if set).
|
||||
- The `openid` scope is always included. It is automatically added if missing from the `scopes` array.
|
||||
- ID tokens are verified against the provider's JWKS (fetched from the discovery document's `jwks_uri`).
|
||||
|
||||
## Provider identifiers
|
||||
|
||||
Every custom provider identifier must start with the `custom:` prefix. Identifiers are 2–50 characters, lowercase alphanumeric with hyphens and colons allowed. Examples:
|
||||
|
||||
- `custom:my-provider`
|
||||
- `custom:github-enterprise`
|
||||
|
||||
## User sign-in
|
||||
|
||||
Once a custom provider is created and enabled, users sign in via the standard OAuth authorize endpoint:
|
||||
|
||||
```
|
||||
GET https://your-project.supabase.co/auth/v1/authorize?provider=custom:my-provider
|
||||
```
|
||||
|
||||
Or using the Supabase client libraries:
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="js"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'custom:my-provider',
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="flutter" label="Flutter">
|
||||
|
||||
```dart
|
||||
await supabase.auth.signInWithOAuth(
|
||||
OAuthProvider('custom:my-provider'),
|
||||
);
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
```swift
|
||||
try await supabase.auth.signInWithOAuth(
|
||||
provider: "custom:my-provider",
|
||||
redirectTo: URL(string: "my-custom-scheme://my-app-host")
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="kotlin" label="Kotlin">
|
||||
|
||||
```kotlin
|
||||
supabase.auth.signInWith(CustomProvider("custom:my-provider"))
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## Managing providers
|
||||
|
||||
### List providers
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="dashboard"
|
||||
queryGroup="setup-method"
|
||||
>
|
||||
<TabPanel id="dashboard" label="Dashboard">
|
||||
|
||||
Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard. All custom providers are listed under **Custom OAuth Providers**.
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```js
|
||||
// List all custom providers
|
||||
const { data, error } = await supabase.auth.admin.customProviders.listProviders()
|
||||
|
||||
// Filter by provider type
|
||||
const { data, error } = await supabase.auth.admin.customProviders.listProviders({
|
||||
type: 'oidc',
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### Update a provider
|
||||
|
||||
Update any provider fields except `provider_type` and `identifier`. Only provided fields are changed (partial update). To rotate a client secret, update only the `client_secret` field.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="dashboard"
|
||||
queryGroup="setup-method"
|
||||
>
|
||||
<TabPanel id="dashboard" label="Dashboard">
|
||||
|
||||
1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard.
|
||||
2. Click the three-dot menu (⋮) next to the provider and select **Update**.
|
||||
3. Modify the fields you want to change.
|
||||
4. Click **Update provider**.
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase.auth.admin.customProviders.updateProvider(
|
||||
'custom:my-provider',
|
||||
{
|
||||
name: 'Updated Provider Name',
|
||||
scopes: ['profile', 'email', 'groups'],
|
||||
enabled: false,
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### Delete a provider
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="dashboard"
|
||||
queryGroup="setup-method"
|
||||
>
|
||||
<TabPanel id="dashboard" label="Dashboard">
|
||||
|
||||
1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard.
|
||||
2. Click the three-dot menu (⋮) next to the provider and select **Delete**.
|
||||
3. Confirm the deletion.
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase.auth.admin.customProviders.deleteProvider(
|
||||
'custom:my-provider'
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## Advanced configuration
|
||||
|
||||
### PKCE
|
||||
|
||||
PKCE (Proof Key for Code Exchange) is enabled by default (`pkce_enabled: true`) for all custom providers. The auth server automatically generates a code challenge and verifier during the authorization flow, protecting against authorization code interception attacks. This is handled entirely server-side, no client-side PKCE logic is needed.
|
||||
|
||||
To disable PKCE for a specific provider, set `pkce_enabled: false` when creating or updating it. This is not recommended unless the identity provider does not support PKCE.
|
||||
|
||||
### Authorization params
|
||||
|
||||
Extra query parameters appended to the provider's authorization URL during the OAuth flow. All values must be strings.
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "consent",
|
||||
"access_type": "offline",
|
||||
"login_hint": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
The following reserved parameters are managed by the auth server and cannot be overridden: `client_id`, `client_secret`, `redirect_uri`, `response_type`, `state`, `code_challenge`, `code_challenge_method`, `code_verifier`, `nonce`.
|
||||
|
||||
### Multi-platform apps
|
||||
|
||||
If your app uses different client IDs for different platforms (for example, web vs mobile), use `acceptable_client_ids` to list additional client IDs that should be accepted for audience validation in OIDC ID tokens:
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase.auth.admin.customProviders.createProvider({
|
||||
provider_type: 'oidc',
|
||||
identifier: 'custom:multi-platform-app',
|
||||
name: 'Multi-Platform App',
|
||||
client_id: 'web-client-id',
|
||||
client_secret: 'your-client-secret',
|
||||
issuer: 'https://app.example.com',
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
acceptable_client_ids: ['ios-client-id', 'android-client-id'],
|
||||
})
|
||||
```
|
||||
|
||||
### Email-optional providers
|
||||
|
||||
By default, providers must return an email address. Set `email_optional` to `true` when creating or updating a provider to allow sign-in without an email. This applies to both OAuth2 and OIDC providers.
|
||||
|
||||
### OIDC-specific options
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `discovery_url` | `string` | `null` | Override the discovery document URL if the provider uses a non-standard location. |
|
||||
| `skip_nonce_check` | `bool` | `false` | Skip nonce validation on ID tokens. Use only for providers that do not support nonce. |
|
||||
|
||||
## Error reference
|
||||
|
||||
| Error code | HTTP status | Description |
|
||||
| --- | --- | --- |
|
||||
| `validation_failed` | 400 | Invalid parameters: missing required fields, bad format, reserved params, or invalid URLs. |
|
||||
| `conflict` | 400 | A provider with the same identifier already exists. |
|
||||
| `over_custom_provider_quota` | 400 | Maximum number of custom providers reached. |
|
||||
| `custom_provider_not_found` | 404 | No provider exists with the given identifier. |
|
||||
@@ -45,6 +45,12 @@ Supabase supports a suite of social providers. Follow these guides to configure
|
||||
</NavData>
|
||||
</div>
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
Need to integrate with a provider not listed here? You can add any OAuth2 or OIDC-compatible provider using [Custom OAuth/OIDC Providers](/docs/guides/auth/custom-oauth-providers).
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Provider tokens
|
||||
|
||||
You can use the provider token and provider refresh token returned to make API calls to the OAuth provider. For example, you can use the Google provider token to access Google APIs on behalf of your user.
|
||||
|
||||
@@ -6,71 +6,90 @@ description: 'Things to do before making your app publicly available'
|
||||
|
||||
After developing your project and deciding it's production ready, you should run through this checklist to ensure that your project:
|
||||
|
||||
- is secure
|
||||
- won't falter under the expected load
|
||||
- remains available whilst in production
|
||||
- [Is secure](#security)
|
||||
- [Won't falter under the expected load](#performance)
|
||||
- [Remains available whilst in production](#availability)
|
||||
|
||||
## Security
|
||||
|
||||
- Ensure RLS is enabled
|
||||
<Admonition type="tip">
|
||||
|
||||
Check and review issues in your database using [Security Advisor](/dashboard/project/_/database/security-advisor).
|
||||
|
||||
</Admonition>
|
||||
|
||||
- Ensure you have enabled row level security (RLS) on all tables from the [**Database > Tables**](/dashboard/project/_/database/tables) section of the Supabase Dashboard.
|
||||
- Tables that do not have RLS enabled with reasonable policies allow any client to access and modify their data. This is usually not what you want.
|
||||
- [Learn more about RLS](/docs/guides/database/postgres/row-level-security).
|
||||
- Enable replication on tables containing sensitive data by enabling Row Level Security (RLS) and setting row security policies:
|
||||
- Go to the Authentication > Policies page in the Supabase Dashboard to enable RLS and create security policies.
|
||||
- Go to the Database > Publications page in the Supabase Dashboard to manage replication tables.
|
||||
- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) (see: [dashboard](/dashboard/project/_/auth/policies))
|
||||
- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for your database (see: [dashboard](/dashboard/project/_/database/settings#network-restrictions)).
|
||||
- Ensure that your Supabase Account is protected with multi-factor authentication (MFA).
|
||||
- If using a GitHub sign-in, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app.
|
||||
- Enable replication on tables containing sensitive data by enabling RLS and setting row security policies:
|
||||
- Go to the [**Authentication > Policies**](/dashboard/project/_/auth/policies) section of the Supabase Dashboard to enable RLS and create security policies.
|
||||
- Go to the [**Database > Publications**](/dashboard/project/_/database/publications) section of the Supabase Dashboard to manage replication tables.
|
||||
- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) from the [**Database > Settings > SSL Configuration**](/dashboard/project/_/database/settings#ssl-configuration) section of the dashboard.
|
||||
- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for the database from the [**Database > Settings > Network Restrictions**](/dashboard/project/_/database/settings#network-restrictions) section of the dashboard.
|
||||
- Ensure that you protect your Supabase Account with multi-factor authentication (MFA).
|
||||
- If using a GitHub sign-in, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication).
|
||||
- Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app.
|
||||
- If using email+password sign-in, set up [MFA for your Supabase account](/docs/guides/platform/multi-factor-authentication#enable-mfa).
|
||||
- Enable [MFA enforcement on your organization](/docs/guides/platform/network-restrictions). This ensures all users must have a valid MFA backed session to interact with organization and project resources.
|
||||
- Consider [adding multiple owners on your Supabase org](/dashboard/org/_/team). This ensures that if one of the owners is unreachable or loses access to their account, you still have Owner access to your org.
|
||||
- Ensure email confirmations are [enabled](/dashboard/project/_/auth/providers) in the `Settings > Auth` page.
|
||||
- Ensure that you've [set the expiry](/dashboard/project/_/auth/providers) for one-time passwords (OTPs) to a reasonable value that you are comfortable with. We recommend setting this to 3600 seconds (1 hour) or lower.
|
||||
- Increase the length of the OTP if you need a higher level of entropy.
|
||||
- Consider enabling [MFA enforcement on your organization](/docs/guides/platform/mfa/org-mfa-enforcement). This ensures all users must have a valid MFA-backed session to interact with organization and project resources.
|
||||
- Consider adding multiple owners on your Supabase org from [the **Organization > Team**](/dashboard/org/_/team) section of the Supabase Dashboard. This ensures that if one of the owners is unreachable or loses access to their account, you still have Owner access to your org.
|
||||
- Enable email confirmations in the [**Authentication > Providers**](/dashboard/project/_/auth/providers) section of the dashboard.
|
||||
- Set the expiry in the [**Authentication > Providers**](/dashboard/project/_/auth/providers) section of the dashboard for one-time passwords (OTPs) to a reasonable value that you are comfortable with.
|
||||
- We recommend setting this to 3600 seconds (1 hour) or lower.
|
||||
- Increase the length of the OTP if you need a higher level of entropy.
|
||||
- If your application requires a higher level of security, consider setting up [multi-factor authentication](/docs/guides/auth/auth-mfa) (MFA) for your users.
|
||||
- Use a custom SMTP server for auth emails so that your users can see that the mails are coming from a trusted domain (preferably the same domain that your app is hosted on). Grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc.
|
||||
- Consider how _you_ might abuse your service as an attacker, and take steps to mitigate it.
|
||||
- Review these [common cybersecurity threats](https://auth0.com/docs/security/prevent-threats).
|
||||
- Check and review issues in your database using [Security Advisor](/dashboard/project/_/database/security-advisor).
|
||||
|
||||
## Performance
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
Check and review issues in your database using [Performance Advisor](/dashboard/project/_/database/performance-advisor).
|
||||
|
||||
</Admonition>
|
||||
|
||||
- Ensure that you have suitable indices to cater to your common query patterns
|
||||
- [Learn more about indexes in Postgres](https://www.enterprisedb.com/postgres-tutorials/overview-postgresql-indexes).
|
||||
- `pg_stat_statements` can help you [identify hot or slow queries](https://www.virtual-dba.com/blog/postgresql-performance-identifying-hot-and-slow-queries/).
|
||||
- Perform load testing (preferably on a staging env)
|
||||
- Tools like [k6](https://k6.io/) can simulate traffic from many different users.
|
||||
- Upgrade your database if you require more resources. If you need anything beyond what is listed, contact enterprise@supabase.io.
|
||||
- Upgrade your database if you require more resources. If you need anything beyond what is listed in [the compute and disk table](/docs/guides/platform/compute-and-disk), contact [enterprise@supabase.io](mailto:enterprise@supabase.io).
|
||||
- If you are expecting a surge in traffic (for a big launch) and are on a Team or Enterprise Plan, [contact support](/dashboard/support/new) with more details about your launch and we'll help keep an eye on your project.
|
||||
- If you expect your database size to be > 4 GB, [enable](/dashboard/project/_/settings/addons?panel=pitr) the Point in Time Recovery (PITR) add-on. Daily backups can take up resources from your database when the backup is in progress. PITR is more resource efficient, since only the changes to the database are backed up.
|
||||
- Check and review issues in your database using [Performance Advisor](/dashboard/project/_/database/performance-advisor).
|
||||
- If you expect your database size to be > 4 GB, enable the Point in Time Recovery (PITR) add-on in the [**Settings > Add-ons**](/dashboard/project/_/settings/addons?panel=pitr) section of the dashboard.
|
||||
|
||||
## Availability
|
||||
|
||||
- Use your own SMTP credentials so that you have full control over the deliverability of your transactional auth emails (see Settings > Auth)
|
||||
- you can grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc. You can refer to our [SMTP guide](/docs/guides/auth/auth-smtp) for more details.
|
||||
- The default rate limit for auth emails when using a custom SMTP provider is 30 new users per hour, if doing a major public announcement you will likely require more than this.
|
||||
- Applications on the Free Plan that exhibit extremely low activity in a 7 day period may be paused by Supabase to save on server resources.
|
||||
- You can restore paused projects from the Supabase dashboard.
|
||||
- Upgrade to Pro to guarantee that your project will not be paused for inactivity.
|
||||
- Use your own SMTP credentials so that you have full control over the deliverability of your transactional auth emails in the [**Authentication > Emails > SMTP Settings**](/dashboard/project/_/auth/smtp) section of the dashboard.
|
||||
- You can grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc. Read our [SMTP guide](/docs/guides/auth/auth-smtp) for more setup details.
|
||||
- The default rate limit for auth emails when using a custom SMTP provider is _30 new users per hour_. If you are doing a major public announcement, you will likely require more than this.
|
||||
- We may pause applications on the Free Plan that exhibit low activity in a 7-day period to save on server resources.
|
||||
- You can restore paused projects from [the Supabase dashboard](/dashboard/project/_).
|
||||
- Upgrade to Pro to guarantee that we won't pause your project for inactivity.
|
||||
- Database backups are not available for download for Free Plan projects.
|
||||
- You can set up your own backup systems using tools like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump) or [wal-g](https://github.com/wal-g/wal-g).
|
||||
- Nightly backups for Pro Plan projects are available on the Supabase dashboard for up to 7 days.
|
||||
- Point-in-Time Recovery (PITR) allows a project to be backed up at much shorter intervals. This provides users an option to restore to any chosen point of up to seconds in granularity. If a lower RPO is required, enable PITR.
|
||||
- Read [the Database Backups guide](/docs/guides/platform/backups) for more options and retention details.
|
||||
- If you need a lower recovery point objective (RPO), enable Point-in-Time Recovery (PITR). PITR allows you to back up a project at shorter intervals. This provides users an option to restore to any chosen point in time with second-level granularity.
|
||||
- Supabase Projects use disks that offer 99.8-99.9% durability by default.
|
||||
- Use Read Replicas if you require availability resilience to a disk failure event
|
||||
- Use [Read Replicas](/docs/guides/platform/read-replicas) if you require availability resilience to a disk failure event
|
||||
- Use PITR if you require durability resilience to a disk failure event
|
||||
- Upgrading to the Supabase Pro Plan will give you [access to our support team](/dashboard/support/new).
|
||||
- Upgrading to the Supabase Pro Plan gives you [access to our support team](/dashboard/support/new).
|
||||
|
||||
## Rate limiting, resource allocation, & abuse prevention
|
||||
|
||||
<Admonition type="caution" label="Shared Responsibility Model">
|
||||
|
||||
Running databases is a shared responsibility between you and Supabase. There are some things that we can take care of for you, and some things that you are responsible for.
|
||||
|
||||
Read more details in our [Shared Responsibility Model guide](/docs/guides/platform/shared-responsibility-model).
|
||||
|
||||
</Admonition>
|
||||
|
||||
- Supabase employs a number of safeguards against bursts of incoming traffic to prevent abuse and help maximize stability across the platform
|
||||
- If you're on a Team or Enterprise Plan and expect high load events, such as production launches, heavy load testing, or prolonged high resource usage, open a ticket via the [support form](https://supabase.help) for help. Provide at least 2 weeks notice.
|
||||
|
||||
### Auth rate limits
|
||||
|
||||
- The table below shows the rate limit quotas on the following authentication endpoints. You can configure the auth rate limits for your project [here](/dashboard/project/_/auth/rate-limits).
|
||||
- The table below shows the rate limit quotas on the following authentication endpoints. You can configure the authentication rate limits for your project in the [**Authentication > Rate Limits**](/dashboard/project/_/auth/rate-limits) section of the dashboard.
|
||||
|
||||
| Endpoint | Path | Limited By | Rate Limit |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -87,39 +106,13 @@ After developing your project and deciding it's production ready, you should run
|
||||
### Realtime limits
|
||||
|
||||
- Review the [Realtime limits](/docs/guides/realtime/limits).
|
||||
- If you need limits increased you can always [contact support](/dashboard/support/new).
|
||||
- If you need limits increased, [contact support](/dashboard/support/new).
|
||||
|
||||
### Abuse prevention
|
||||
|
||||
- Supabase provides CAPTCHA protection on the signup, sign-in and password reset endpoints. Refer to [our guide](/docs/guides/auth/auth-captcha) on how to protect against abuse using this method.
|
||||
- Supabase provides CAPTCHA protection on the signup, sign-in and password reset endpoints. Read [the Auth CAPTCHA guide](/docs/guides/auth/auth-captcha) for more details on how to protect against abuse using this method.
|
||||
|
||||
### Email link validity
|
||||
|
||||
- When working with enterprise systems, email scanners may scan and make a `GET` request to the reset password link or sign up link in your email. Since links in Supabase Auth are single use, a user who opens an email post-scan to click on a link will receive an error. To get around this problem,
|
||||
consider altering the email template to replace the original magic link with a link to a domain you control. The domain can present the user with a "Sign-in" button which redirect the user to the original magic link URL when clicked.
|
||||
|
||||
- When using a custom SMTP service, some services might have link tracking enabled which may overwrite or disform the email confirmation links sent by Supabase Auth. To prevent this from happening, we recommend that you disable link tracking when using a custom SMTP service.
|
||||
|
||||
## Subscribe to Supabase status page
|
||||
|
||||
Stay informed about Supabase service status by subscribing to the [Status Page](https://status.supabase.com/). We recommend setting up Slack notifications through an RSS feed to ensure your team receives timely updates about service status changes.
|
||||
|
||||
### Setting up Slack notifications
|
||||
|
||||
1. Install the RSS app in Slack:
|
||||
- Visit the [RSS app page](https://slack.com/marketplace/A0F81R7U7-rss) in the Slack marketplace
|
||||
- Click `Add to Slack` if not already installed
|
||||
- Otherwise you will get straight to next step, no need to reinstall the app
|
||||
|
||||
2. Configure the Supabase status feed:
|
||||
- Create a channel (e.g., `#supabase-status-alerts`) for status updates
|
||||
- On the [RSS app page](https://slack.com/marketplace/A0F81R7U7-rss) go to _Add a Feed_ section and set Feed URL to `https://status.supabase.com/history.rss`
|
||||
- Select your designated channel and click "Subscribe to this feed"
|
||||
|
||||
Once configured, your team will receive automatic notifications in Slack whenever the Supabase Status Page is updated.
|
||||
|
||||
For detailed setup instructions, see the [Add RSS feeds to Slack](https://slack.com/intl/en-nz/help/articles/218688467-Add-RSS-feeds-to-Slack).
|
||||
|
||||
## Next steps
|
||||
|
||||
This checklist is always growing so be sure to check back frequently, and also feel free to suggest additions and amendments by making a PR on [GitHub](https://github.com/supabase/supabase).
|
||||
- When working with enterprise systems, email scanners may scan and make a `GET` request to the reset password link or sign-up link in your email. Since links in Supabase Auth are single-use, a user who opens an email post-scan to click on a link will receive an error. To get around this problem, consider altering the email template to replace the original magic link with a link to a domain you control. The domain can present the user with a "Sign-in" button, which redirects the user to the original magic link URL when clicked.
|
||||
- When using a custom SMTP service, some services might have link tracking enabled which may overwrite or deform the email confirmation links sent by Supabase Auth. To prevent this from happening, we recommend that you disable link tracking when using a custom SMTP service.
|
||||
@@ -78,6 +78,12 @@ You can also install the CLI as dev dependency via [npm](https://www.npmjs.com/p
|
||||
npm install supabase --save-dev
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Global installation using `npm install -g supabase` is not supported. For global CLI usage, install via [Homebrew](/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=macos), [Scoop](/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=windows), or the [standalone binary](/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=linux).
|
||||
|
||||
</Admonition>
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -236,47 +236,325 @@ const OLD_PROJECT_SERVICE_KEY = 'old-project-service-key-xxx'
|
||||
const NEW_PROJECT_URL = 'https://yyy.supabase.co'
|
||||
const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy'
|
||||
|
||||
;(async () => {
|
||||
const oldSupabaseRestClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY, {
|
||||
db: {
|
||||
schema: 'storage',
|
||||
},
|
||||
})
|
||||
const oldSupabaseClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY)
|
||||
const newSupabaseClient = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY)
|
||||
const oldSupabase = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY)
|
||||
const newSupabase = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY)
|
||||
|
||||
// make sure you update max_rows in postgrest settings if you have a lot of objects
|
||||
// or paginate here
|
||||
const { data: oldObjects, error } = await oldSupabaseRestClient.from('objects').select()
|
||||
if (error) {
|
||||
console.log('error getting objects from old bucket')
|
||||
function createLoadingAnimation(message) {
|
||||
const readline = require('readline')
|
||||
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
let i = 0
|
||||
let timer
|
||||
let stopped = false
|
||||
|
||||
const animate = () => {
|
||||
if (stopped) return
|
||||
process.stdout.write(`\r${frames[i]} ${message}`)
|
||||
i = (i + 1) % frames.length
|
||||
timer = setTimeout(animate, 80)
|
||||
}
|
||||
|
||||
animate()
|
||||
|
||||
return {
|
||||
stop: (finalMessage = '') => {
|
||||
stopped = true
|
||||
clearTimeout(timer)
|
||||
readline.clearLine(process.stdout, 0)
|
||||
readline.cursorTo(process.stdout, 0)
|
||||
process.stdout.write(`✓ ${finalMessage || message}\n`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all files in a bucket, handling nested folders recursively.
|
||||
*/
|
||||
async function listAllFiles(bucket, path = '') {
|
||||
const loader = createLoadingAnimation(`Listing files in '${bucket}${path ? '/' + path : ''}'...`)
|
||||
|
||||
try {
|
||||
const { data, error } = await oldSupabase.storage.from(bucket).list(path, { limit: 1000 })
|
||||
if (error) {
|
||||
loader.stop(`Error listing files in '${bucket}${path ? '/' + path : ''}'`)
|
||||
throw new Error(`❌ Error listing files in bucket '${bucket}': ${error.message}`)
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
loader.stop(`No files found in '${bucket}${path ? '/' + path : ''}'`)
|
||||
return []
|
||||
}
|
||||
|
||||
let files = []
|
||||
for (const item of data) {
|
||||
if (!item.metadata) {
|
||||
loader.stop(`Found folder '${item.name}' in '${bucket}${path ? '/' + path : ''}'`)
|
||||
const subFiles = await listAllFiles(bucket, `${path}${item.name}/`)
|
||||
files = files.concat(subFiles)
|
||||
} else {
|
||||
files.push({ fullPath: `${path}${item.name}`, metadata: item.metadata })
|
||||
}
|
||||
}
|
||||
|
||||
loader.stop(`Found ${files.length} files in '${bucket}${path ? '/' + path : ''}'`)
|
||||
return files
|
||||
} catch (error) {
|
||||
loader.stop()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
for (const objectData of oldObjects) {
|
||||
console.log(`moving ${objectData.id}`)
|
||||
try {
|
||||
const { data, error: downloadObjectError } = await oldSupabaseClient.storage
|
||||
.from(objectData.bucket_id)
|
||||
.download(objectData.name)
|
||||
if (downloadObjectError) {
|
||||
throw downloadObjectError
|
||||
}
|
||||
/**
|
||||
* Creates a bucket in the new Supabase project if it doesn't exist.
|
||||
*/
|
||||
async function ensureBucketExists(bucketName, options = {}) {
|
||||
const { data: existingBucket, error: getBucketError } =
|
||||
await newSupabase.storage.getBucket(bucketName)
|
||||
|
||||
const { _, error: uploadObjectError } = await newSupabaseClient.storage
|
||||
.from(objectData.bucket_id)
|
||||
.upload(objectData.name, data, {
|
||||
upsert: true,
|
||||
contentType: objectData.metadata.mimetype,
|
||||
cacheControl: objectData.metadata.cacheControl,
|
||||
})
|
||||
if (uploadObjectError) {
|
||||
throw uploadObjectError
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('error moving ', objectData)
|
||||
console.log(err)
|
||||
if (getBucketError && !getBucketError.message.includes('not found')) {
|
||||
throw new Error(`❌ Error checking if bucket '${bucketName}' exists: ${getBucketError.message}`)
|
||||
}
|
||||
|
||||
if (!existingBucket) {
|
||||
console.log(`🪣 Creating bucket '${bucketName}' in new project...`)
|
||||
const { error } = await newSupabase.storage.createBucket(bucketName, options)
|
||||
if (error) throw new Error(`❌ Failed to create bucket '${bucketName}': ${error.message}`)
|
||||
console.log(`✅ Created bucket '${bucketName}'`)
|
||||
} else {
|
||||
console.log(`ℹ️ Bucket '${bucketName}' already exists in new project`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a single file from the old project to the new one.
|
||||
*/
|
||||
async function migrateFile(sourceBucketName, targetBucketName, file) {
|
||||
const loader = createLoadingAnimation(
|
||||
`Migrating ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'...`
|
||||
)
|
||||
|
||||
try {
|
||||
const { data, error: downloadError } = await oldSupabase.storage
|
||||
.from(sourceBucketName)
|
||||
.download(file.fullPath)
|
||||
if (downloadError) {
|
||||
loader.stop(`Failed to migrate ${file.fullPath}: Download error`)
|
||||
throw new Error(`Download failed: ${downloadError.message}`)
|
||||
}
|
||||
|
||||
// Preserve all available metadata from the original file
|
||||
const uploadOptions = {
|
||||
upsert: true,
|
||||
contentType: file.metadata?.mimetype,
|
||||
cacheControl: file.metadata?.cacheControl,
|
||||
}
|
||||
|
||||
const { error: uploadError } = await newSupabase.storage
|
||||
.from(targetBucketName)
|
||||
.upload(file.fullPath, data, uploadOptions)
|
||||
if (uploadError) {
|
||||
loader.stop(`Failed to migrate ${file.fullPath}: Upload error`)
|
||||
throw new Error(`Upload failed: ${uploadError.message}`)
|
||||
}
|
||||
|
||||
loader.stop(
|
||||
`Migrated ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'`
|
||||
)
|
||||
return { success: true, path: file.fullPath }
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`❌ Error migrating ${file.fullPath} in bucket '${targetBucketName}':`,
|
||||
err.message
|
||||
)
|
||||
return { success: false, path: file.fullPath, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
function chunkArray(array, size) {
|
||||
const chunks = []
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates all buckets and files from the old Supabase project to the new one.
|
||||
* Processes files in parallel within batches for efficiency.
|
||||
*/
|
||||
async function migrateBuckets() {
|
||||
console.log('🔄 Starting Supabase Storage migration...')
|
||||
console.log(`📦 Source project: ${OLD_PROJECT_URL}`)
|
||||
console.log(`📦 Target project: ${NEW_PROJECT_URL}`)
|
||||
|
||||
const readline = require('readline').createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
console.log(
|
||||
'\n⚠️ WARNING: This migration may overwrite files in the target project if they have the same paths.'
|
||||
)
|
||||
console.log('⚠️ It is recommended to back up your target project before proceeding.')
|
||||
|
||||
const answer = await new Promise((resolve) => {
|
||||
readline.question('Do you want to proceed with the migration? (yes/no): ', resolve)
|
||||
})
|
||||
|
||||
readline.close()
|
||||
|
||||
if (answer.toLowerCase() !== 'yes') {
|
||||
console.log('Migration canceled by user.')
|
||||
return { canceled: true }
|
||||
}
|
||||
|
||||
console.log('\n📦 Fetching all buckets from old project...')
|
||||
|
||||
const { data: oldBuckets, error: bucketListError } = await oldSupabase.storage.listBuckets()
|
||||
|
||||
if (bucketListError) throw new Error(`❌ Error fetching buckets: ${bucketListError.message}`)
|
||||
console.log(`✅ Found ${oldBuckets.length} buckets to migrate.`)
|
||||
|
||||
const { data: existingBuckets, error: existingBucketsError } =
|
||||
await newSupabase.storage.listBuckets()
|
||||
if (existingBucketsError)
|
||||
throw new Error(`❌ Error fetching existing buckets: ${existingBucketsError.message}`)
|
||||
|
||||
const existingBucketNames = existingBuckets.map((b) => b.name)
|
||||
const conflictingBuckets = oldBuckets.filter((b) => existingBucketNames.includes(b.name))
|
||||
|
||||
let conflictStrategy = 2
|
||||
|
||||
if (conflictingBuckets.length > 0) {
|
||||
console.log('\n⚠️ The following buckets already exist in the target project:')
|
||||
conflictingBuckets.forEach((b) => console.log(` - ${b.name}`))
|
||||
|
||||
const conflictAnswer = await new Promise((resolve) => {
|
||||
const rl = require('readline').createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
rl.question(
|
||||
'\nHow do you want to handle existing buckets?\n' +
|
||||
'1. Skip existing buckets\n' +
|
||||
'2. Merge files (may overwrite existing files)\n' +
|
||||
'3. Rename buckets in target (add suffix "_migrated")\n' +
|
||||
'4. Cancel migration\n' +
|
||||
'Enter your choice (1-4): ',
|
||||
(answer) => {
|
||||
rl.close()
|
||||
resolve(answer)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (conflictAnswer === '4') {
|
||||
console.log('Migration canceled by user.')
|
||||
return { canceled: true }
|
||||
}
|
||||
|
||||
conflictStrategy = parseInt(conflictAnswer)
|
||||
if (isNaN(conflictStrategy) || conflictStrategy < 1 || conflictStrategy > 3) {
|
||||
console.log('Invalid choice. Migration canceled.')
|
||||
return { canceled: true }
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
const migrationStats = {
|
||||
totalBuckets: oldBuckets.length,
|
||||
processedBuckets: 0,
|
||||
skippedBuckets: 0,
|
||||
totalFiles: 0,
|
||||
successfulFiles: 0,
|
||||
failedFiles: 0,
|
||||
failedFilesList: [],
|
||||
}
|
||||
|
||||
for (const bucket of oldBuckets) {
|
||||
const bucketName = bucket.name
|
||||
console.log(`\n📁 Processing bucket: ${bucketName}`)
|
||||
|
||||
let targetBucketName = bucketName
|
||||
|
||||
if (existingBucketNames.includes(bucketName)) {
|
||||
if (conflictStrategy === 1) {
|
||||
console.log(`⏩ Skipping bucket '${bucketName}' as it already exists in target project`)
|
||||
migrationStats.skippedBuckets++
|
||||
continue
|
||||
} else if (conflictStrategy === 3) {
|
||||
targetBucketName = `${bucketName}_migrated`
|
||||
console.log(`🔄 Renaming bucket to '${targetBucketName}' in target project`)
|
||||
} else {
|
||||
console.log(`🔄 Merging files into existing bucket '${bucketName}' in target project`)
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve bucket configuration when creating in the new project
|
||||
if (targetBucketName !== bucketName || !existingBucketNames.includes(bucketName)) {
|
||||
await ensureBucketExists(targetBucketName, {
|
||||
public: bucket.public,
|
||||
fileSizeLimit: bucket.file_size_limit,
|
||||
allowedMimeTypes: bucket.allowed_mime_types,
|
||||
})
|
||||
}
|
||||
|
||||
const files = await listAllFiles(bucketName)
|
||||
console.log(`✅ Found ${files.length} files in bucket '${bucketName}'.`)
|
||||
migrationStats.totalFiles += files.length
|
||||
|
||||
const batches = chunkArray(files, 10)
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
console.log(`\n🚀 Processing batch ${i + 1}/${batches.length} (${batches[i].length} files)`)
|
||||
|
||||
const results = await Promise.all(
|
||||
batches[i].map((file) => migrateFile(bucketName, targetBucketName, file))
|
||||
)
|
||||
|
||||
const batchSuccesses = results.filter((r) => r.success).length
|
||||
const batchFailures = results.filter((r) => !r.success)
|
||||
|
||||
migrationStats.successfulFiles += batchSuccesses
|
||||
migrationStats.failedFiles += batchFailures.length
|
||||
migrationStats.failedFilesList.push(...batchFailures.map((f) => f.path))
|
||||
|
||||
console.log(
|
||||
`✅ Completed batch ${i + 1}/${batches.length}: ${batchSuccesses} succeeded, ${batchFailures.length} failed`
|
||||
)
|
||||
}
|
||||
|
||||
migrationStats.processedBuckets++
|
||||
console.log(`✅ Completed bucket '${bucketName}' migration`)
|
||||
}
|
||||
|
||||
console.log('\n📊 Migration Summary:')
|
||||
console.log(
|
||||
`Buckets: ${migrationStats.processedBuckets}/${migrationStats.totalBuckets} processed, ${migrationStats.skippedBuckets} skipped`
|
||||
)
|
||||
console.log(
|
||||
`Files: ${migrationStats.successfulFiles} succeeded, ${migrationStats.failedFiles} failed (${migrationStats.totalFiles} total)`
|
||||
)
|
||||
|
||||
if (migrationStats.failedFiles > 0) {
|
||||
console.log('\n⚠️ Failed files:')
|
||||
migrationStats.failedFilesList.forEach((path) => console.log(` - ${path}`))
|
||||
return migrationStats
|
||||
}
|
||||
|
||||
return migrationStats
|
||||
}
|
||||
|
||||
migrateBuckets()
|
||||
.then((stats) => {
|
||||
if (stats.failedFiles > 0) {
|
||||
console.log(`\n⚠️ Migration completed with ${stats.failedFiles} failed files.`)
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log('\n🎉 Migration completed successfully!')
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('❌ Fatal error during migration:', err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
```
|
||||
@@ -1,4 +1,4 @@
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { isMsSqlForeignTable } from 'data/table-editor/table-editor-types'
|
||||
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
@@ -15,10 +15,7 @@ import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { QueuedOperation } from 'state/table-editor-operation-queue.types'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
|
||||
import {
|
||||
useIsQueueOperationsEnabled,
|
||||
useIsTableFilterBarEnabled,
|
||||
} from '../interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useIsTableFilterBarEnabled } from '../interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { Shortcuts } from './components/common/Shortcuts'
|
||||
import { Footer } from './components/footer/Footer'
|
||||
import { Grid } from './components/grid/Grid'
|
||||
@@ -29,7 +26,7 @@ import { useTableFilter } from './hooks/useTableFilter'
|
||||
import { useTableSort } from './hooks/useTableSort'
|
||||
import { validateMsSqlSorting } from './MsSqlValidation'
|
||||
import { GridProps } from './types'
|
||||
import { reapplyOptimisticUpdates } from './utils/queueOperationUtils'
|
||||
import { formatGridDataWithOperationValues } from './utils/queueOperationUtils'
|
||||
|
||||
export const SupabaseGrid = ({
|
||||
customHeader,
|
||||
@@ -43,9 +40,6 @@ export const SupabaseGrid = ({
|
||||
const { id: _id } = useParams()
|
||||
const tableId = _id ? Number(_id) : undefined
|
||||
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
@@ -73,7 +67,6 @@ export const SupabaseGrid = ({
|
||||
isError,
|
||||
isPending: isLoading,
|
||||
isRefetching,
|
||||
dataUpdatedAt,
|
||||
} = useTableRowsQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
@@ -100,39 +93,9 @@ export const SupabaseGrid = ({
|
||||
if (!mounted) setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Re-apply optimistic updates when table data is loaded/refetched
|
||||
// This ensures pending changes remain visible when switching tabs or after data refresh
|
||||
// Skip re-applying during save to avoid race condition where refetch completes before queue clears
|
||||
const isSaving = tableEditorSnap.operationQueue.status === 'saving'
|
||||
useEffect(() => {
|
||||
if (
|
||||
isSuccess &&
|
||||
project?.ref &&
|
||||
tableId &&
|
||||
isQueueOperationsEnabled &&
|
||||
tableEditorSnap.hasPendingOperations &&
|
||||
!isSaving
|
||||
) {
|
||||
reapplyOptimisticUpdates({
|
||||
queryClient,
|
||||
projectRef: project.ref,
|
||||
tableId,
|
||||
operations: tableEditorSnap.operationQueue.operations as readonly QueuedOperation[],
|
||||
})
|
||||
}
|
||||
}, [
|
||||
isSuccess,
|
||||
dataUpdatedAt,
|
||||
project?.ref,
|
||||
tableId,
|
||||
isQueueOperationsEnabled,
|
||||
tableEditorSnap.hasPendingOperations,
|
||||
tableEditorSnap.operationQueue.operations,
|
||||
queryClient,
|
||||
isSaving,
|
||||
])
|
||||
|
||||
const rows = data?.rows ?? EMPTY_ARR
|
||||
const operations = tableEditorSnap.operationQueue.operations as QueuedOperation[]
|
||||
const baseRows = data?.rows ?? EMPTY_ARR
|
||||
const rows = formatGridDataWithOperationValues({ operations, rows: baseRows })
|
||||
|
||||
const HeaderComponent = newFilterBarEnabled ? HeaderNew : Header
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
||||
import { compact } from 'lodash'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { CalculatedColumn, CellKeyboardEvent } from 'react-data-grid'
|
||||
|
||||
import type { Filter, SavedState } from 'components/grid/types'
|
||||
import type { Filter, SavedState, SupaRow } from 'components/grid/types'
|
||||
import { Entity, isTableLike } from 'data/table-editor/table-editor-types'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { compact } from 'lodash'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { parseAsNativeArrayOf, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { CalculatedColumn, CellKeyboardEvent } from 'react-data-grid'
|
||||
import { copyToClipboard } from 'ui'
|
||||
|
||||
import { FilterOperatorOptions } from './components/header/filter/Filter.constants'
|
||||
import { STORAGE_KEY_PREFIX } from './constants'
|
||||
import type { Sort, SupaColumn, SupaTable } from './types'
|
||||
import { formatClipboardValue } from './utils/common'
|
||||
import { QueuedOperation, QueuedOperationType } from '@/state/table-editor-operation-queue.types'
|
||||
|
||||
export function formatSortURLParams(tableName: string, sort?: string[]): Sort[] {
|
||||
if (Array.isArray(sort)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueu
|
||||
import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { Button } from 'ui'
|
||||
|
||||
@@ -131,9 +131,7 @@ export function useOnRowsChange(rows: SupaRow[]) {
|
||||
|
||||
if (isQueueOperationsEnabled) {
|
||||
queueCellEditWithOptimisticUpdate({
|
||||
queryClient,
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
projectRef: project.ref,
|
||||
tableId: snap.table.id,
|
||||
table: snap.originalTable,
|
||||
row: previousRow,
|
||||
@@ -167,7 +165,6 @@ export function useOnRowsChange(rows: SupaRow[]) {
|
||||
snap.originalTable,
|
||||
snap.table.id,
|
||||
tableEditorSnap,
|
||||
queryClient,
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -283,7 +283,6 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
queueRowDeletesWithOptimisticUpdate({
|
||||
rows,
|
||||
table: snap.originalTable,
|
||||
queryClient,
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
|
||||
@@ -65,17 +65,19 @@ export const HeaderNew = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap min-h-10 items-center bg-dash-sidebar dark:bg-surface-100 px-1.5 py-1.5 gap-2">
|
||||
<div className="flex flex-wrap min-h-10 items-center bg-dash-sidebar dark:bg-surface-100 py-1.5 gap-2">
|
||||
{customHeader ? (
|
||||
customHeader
|
||||
<div className="flex-1 px-1.5">{customHeader}</div>
|
||||
) : snap.selectedRows.size > 0 ? (
|
||||
<RowHeader tableQueriesEnabled={tableQueriesEnabled} />
|
||||
<div className="flex-1 px-1.5">
|
||||
<RowHeader tableQueriesEnabled={tableQueriesEnabled} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-w-[300px] flex items-center gap-2">
|
||||
<div className="w-full flex items-center gap-2 px-1.5 pb-1.5 border-b border-border">
|
||||
<FilterPopoverNew isRefetching={isRefetching} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
<div className="flex items-center gap-2 overflow-x-auto px-1.5">
|
||||
{!customHeader && snap.selectedRows.size === 0 && (
|
||||
<SortPopover tableQueriesEnabled={tableQueriesEnabled} />
|
||||
)}
|
||||
@@ -275,7 +277,6 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
queueRowDeletesWithOptimisticUpdate({
|
||||
rows,
|
||||
table: snap.originalTable,
|
||||
queryClient,
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
|
||||
@@ -207,7 +207,7 @@ export const FilterPopoverNew = ({ isRefetching = false }: FilterPopoverProps) =
|
||||
actions={actions}
|
||||
isLoading={isGenerating}
|
||||
variant="pill"
|
||||
className="bg-transparent border-0 overflow-visible"
|
||||
className="bg-transparent border-0 overflow-visible px-1.5"
|
||||
icon={icon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,6 @@ type RowContextMenuProps = {
|
||||
type RowContextMenuItemProps = ItemParams<{ rowIdx: number }, string>
|
||||
|
||||
export const RowContextMenu = ({ rows }: RowContextMenuProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
@@ -46,7 +45,6 @@ export const RowContextMenu = ({ rows }: RowContextMenuProps) => {
|
||||
queueRowDeletesWithOptimisticUpdate({
|
||||
rows: [row],
|
||||
table: snap.originalTable,
|
||||
queryClient,
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { useOperationQueueActions } from './useOperationQueueActions'
|
||||
import { useIsQueueOperationsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { tableRowKeys } from '@/data/table-rows/keys'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useHotKey } from '@/hooks/ui/useHotKey'
|
||||
import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
||||
|
||||
@@ -14,6 +18,8 @@ import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
||||
* before the data grid handles the keyboard event.
|
||||
*/
|
||||
export function useOperationQueueShortcuts() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const { handleSave } = useOperationQueueActions()
|
||||
@@ -43,4 +49,23 @@ export function useOperationQueueShortcuts() {
|
||||
'.',
|
||||
{ enabled: isEnabled }
|
||||
)
|
||||
|
||||
useHotKey(
|
||||
(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const tableIdLatestOperation = snap.operationQueue.operations.at(-1)?.tableId
|
||||
snap.undoLatestOperation()
|
||||
|
||||
// Invalidate the query to revert the optimistic update
|
||||
if (project && tableIdLatestOperation) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableRowKeys.tableRowsAndCount(project.ref, tableIdLatestOperation),
|
||||
})
|
||||
}
|
||||
},
|
||||
'z',
|
||||
{ enabled: isEnabled }
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { generateTableChangeKey, rowMatchesIdentifiers, applyCellEdit } from './queueOperationUtils'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { SupaRow } from '../types'
|
||||
import {
|
||||
type NewEditCellContentOperation,
|
||||
formatGridDataWithOperationValues,
|
||||
generateTableChangeKey,
|
||||
rowMatchesIdentifiers,
|
||||
} from './queueOperationUtils'
|
||||
import {
|
||||
QueuedOperationType,
|
||||
type NewAddRowOperation,
|
||||
type NewDeleteRowOperation,
|
||||
QueuedOperationType,
|
||||
type NewEditCellContentOperation,
|
||||
type QueuedOperation,
|
||||
} from '@/state/table-editor-operation-queue.types'
|
||||
|
||||
describe('generateTableChangeKey', () => {
|
||||
@@ -145,101 +152,257 @@ describe('rowMatchesIdentifiers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyCellEdit', () => {
|
||||
test('should apply cell edit to matching row', () => {
|
||||
describe('formatGridDataWithOperationValues', () => {
|
||||
const makeRow = (idx: number, data: Record<string, unknown> = {}): SupaRow => ({
|
||||
idx,
|
||||
...data,
|
||||
})
|
||||
|
||||
const makeEditOp = (
|
||||
overrides: Partial<QueuedOperation & { payload: any }> = {}
|
||||
): QueuedOperation => ({
|
||||
id: 'op-1',
|
||||
tableId: 1,
|
||||
timestamp: Date.now(),
|
||||
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'name',
|
||||
oldValue: 'old',
|
||||
newValue: 'new',
|
||||
table: {} as any,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeDeleteOp = (
|
||||
rowIdentifiers: Record<string, unknown>,
|
||||
originalRow: SupaRow
|
||||
): QueuedOperation => ({
|
||||
id: 'op-del',
|
||||
tableId: 1,
|
||||
timestamp: Date.now(),
|
||||
type: QueuedOperationType.DELETE_ROW,
|
||||
payload: { rowIdentifiers, originalRow, table: {} as any },
|
||||
})
|
||||
|
||||
const makeAddOp = (tempId: string, rowData: Record<string, unknown> = {}): QueuedOperation => ({
|
||||
id: 'op-add',
|
||||
tableId: 1,
|
||||
timestamp: Date.now(),
|
||||
type: QueuedOperationType.ADD_ROW,
|
||||
payload: {
|
||||
tempId,
|
||||
rowData: { idx: Number(tempId), __tempId: tempId, ...rowData },
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
|
||||
test('should return rows unchanged when there are no operations', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })]
|
||||
const result = formatGridDataWithOperationValues({ operations: [], rows })
|
||||
expect(result).toEqual(rows)
|
||||
})
|
||||
|
||||
test('should apply EDIT_CELL_CONTENT to matching row', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })]
|
||||
const op = makeEditOp({
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'name',
|
||||
oldValue: 'Alice',
|
||||
newValue: 'Updated',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op], rows })
|
||||
expect(result[0]).toEqual({ idx: 0, id: 1, name: 'Updated' })
|
||||
expect(result[1]).toEqual(rows[1])
|
||||
})
|
||||
|
||||
test('should not modify rows when EDIT_CELL_CONTENT does not match any row', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
|
||||
const op = makeEditOp({
|
||||
payload: {
|
||||
rowIdentifiers: { id: 999 },
|
||||
columnName: 'name',
|
||||
oldValue: 'old',
|
||||
newValue: 'new',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op], rows })
|
||||
expect(result).toEqual(rows)
|
||||
})
|
||||
|
||||
test('should apply multiple EDIT_CELL_CONTENT operations to different rows', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })]
|
||||
const op1 = makeEditOp({
|
||||
id: 'op-1',
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'name',
|
||||
oldValue: 'Alice',
|
||||
newValue: 'Updated Alice',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
const op2 = makeEditOp({
|
||||
id: 'op-2',
|
||||
payload: {
|
||||
rowIdentifiers: { id: 2 },
|
||||
columnName: 'name',
|
||||
oldValue: 'Bob',
|
||||
newValue: 'Updated Bob',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows })
|
||||
expect(result[0].name).toBe('Updated Alice')
|
||||
expect(result[1].name).toBe('Updated Bob')
|
||||
})
|
||||
|
||||
test('last edit wins when multiple operations target the same row', () => {
|
||||
// Each operation spreads from the original row, so only the last op's column change is preserved
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice', email: 'alice@test.com' })]
|
||||
const op1 = makeEditOp({
|
||||
id: 'op-1',
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'name',
|
||||
oldValue: 'Alice',
|
||||
newValue: 'Updated',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
const op2 = makeEditOp({
|
||||
id: 'op-2',
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'email',
|
||||
oldValue: 'alice@test.com',
|
||||
newValue: 'updated@test.com',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows })
|
||||
// The second op overwrites the first since both spread from the original row
|
||||
expect(result[0].name).toBe('Alice')
|
||||
expect(result[0].email).toBe('updated@test.com')
|
||||
})
|
||||
|
||||
test('should mark matching row as deleted for DELETE_ROW operation', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })]
|
||||
const op = makeDeleteOp({ id: 1 }, rows[0])
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op], rows })
|
||||
expect(result[0].__isDeleted).toBe(true)
|
||||
expect(result[1].__isDeleted).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should not modify rows when DELETE_ROW does not match any row', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
|
||||
const op = makeDeleteOp({ id: 999 }, makeRow(0, { id: 999 }))
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op], rows })
|
||||
expect(result[0].__isDeleted).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should not mutate the original rows array', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
|
||||
const op = makeEditOp({
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'name',
|
||||
oldValue: 'Alice',
|
||||
newValue: 'Updated',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op], rows })
|
||||
expect(rows[0].name).toBe('Alice')
|
||||
expect(result[0].name).toBe('Updated')
|
||||
})
|
||||
|
||||
test('should handle mixed operation types', () => {
|
||||
const rows = [
|
||||
{ idx: 0, id: 1, name: 'old' },
|
||||
{ idx: 1, id: 2, name: 'test' },
|
||||
makeRow(0, { id: 1, name: 'Alice' }),
|
||||
makeRow(1, { id: 2, name: 'Bob' }),
|
||||
makeRow(2, { id: 3, name: 'Charlie' }),
|
||||
]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
|
||||
expect(result).toEqual([
|
||||
{ idx: 0, id: 1, name: 'new' },
|
||||
{ idx: 1, id: 2, name: 'test' },
|
||||
])
|
||||
|
||||
const editOp = makeEditOp({
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'name',
|
||||
oldValue: 'Alice',
|
||||
newValue: 'Updated Alice',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
const deleteOp = makeDeleteOp({ id: 2 }, rows[1])
|
||||
|
||||
const result = formatGridDataWithOperationValues({
|
||||
operations: [editOp, deleteOp],
|
||||
rows,
|
||||
})
|
||||
|
||||
expect(result[0].name).toBe('Updated Alice')
|
||||
expect(result[1].__isDeleted).toBe(true)
|
||||
expect(result[2]).toEqual(rows[2])
|
||||
})
|
||||
|
||||
test('should not affect non-matching rows', () => {
|
||||
const rows = [
|
||||
{ idx: 0, id: 1, name: 'old' },
|
||||
{ idx: 1, id: 2, name: 'test' },
|
||||
test('should prepend new row for ADD_ROW operation', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
|
||||
const op = makeAddOp('-100', { name: 'New Row' })
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op], rows })
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatchObject({ __tempId: '-100', name: 'New Row' })
|
||||
expect(result[1]).toEqual(rows[0])
|
||||
})
|
||||
|
||||
test('should update existing pending row for ADD_ROW with same tempId', () => {
|
||||
const rows: SupaRow[] = [
|
||||
{ idx: -100, __tempId: '-100', name: 'Original' } as any,
|
||||
makeRow(1, { id: 1, name: 'Alice' }),
|
||||
]
|
||||
const result = applyCellEdit(rows, 'name', { id: 3 }, 'new')
|
||||
expect(result).toEqual([
|
||||
{ idx: 0, id: 1, name: 'old' },
|
||||
{ idx: 1, id: 2, name: 'test' },
|
||||
])
|
||||
const op = makeAddOp('-100', { name: 'Updated' })
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op], rows })
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatchObject({ __tempId: '-100', name: 'Updated' })
|
||||
})
|
||||
|
||||
test('should create new row instances for matching row', () => {
|
||||
const rows = [{ idx: 0, id: 1, name: 'old' }]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
|
||||
expect(result[0]).not.toBe(rows[0])
|
||||
expect(result[0]).toEqual({ idx: 0, id: 1, name: 'new' })
|
||||
test('should handle multiple ADD_ROW operations', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
|
||||
const op1 = makeAddOp('-100', { name: 'Row 1' })
|
||||
const op2 = makeAddOp('-200', { name: 'Row 2' })
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows })
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toMatchObject({ __tempId: '-200', name: 'Row 2' })
|
||||
expect(result[1]).toMatchObject({ __tempId: '-100', name: 'Row 1' })
|
||||
})
|
||||
|
||||
test('should not modify original array', () => {
|
||||
const rows = [{ idx: 0, id: 1, name: 'old' }]
|
||||
const originalRows = [...rows]
|
||||
applyCellEdit(rows, 'name', { id: 1 }, 'new')
|
||||
expect(rows).toEqual(originalRows)
|
||||
})
|
||||
test('should handle EDIT_CELL_CONTENT with composite primary keys', () => {
|
||||
const rows = [makeRow(0, { tenant_id: 'a', user_id: 1, name: 'Alice' })]
|
||||
const op = makeEditOp({
|
||||
payload: {
|
||||
rowIdentifiers: { tenant_id: 'a', user_id: 1 },
|
||||
columnName: 'name',
|
||||
oldValue: 'Alice',
|
||||
newValue: 'Updated',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
|
||||
test('should handle multiple matching rows with composite keys', () => {
|
||||
const rows = [
|
||||
{ idx: 0, id: 1, org_id: 10, name: 'old1' },
|
||||
{ idx: 1, id: 1, org_id: 20, name: 'old2' },
|
||||
{ idx: 2, id: 2, org_id: 10, name: 'old3' },
|
||||
]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1, org_id: 10 }, 'new')
|
||||
expect(result).toEqual([
|
||||
{ idx: 0, id: 1, org_id: 10, name: 'new' },
|
||||
{ idx: 1, id: 1, org_id: 20, name: 'old2' },
|
||||
{ idx: 2, id: 2, org_id: 10, name: 'old3' },
|
||||
])
|
||||
})
|
||||
|
||||
test('should handle setting value to null', () => {
|
||||
const rows = [{ idx: 0, id: 1, name: 'test' }]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, null)
|
||||
expect(result).toEqual([{ idx: 0, id: 1, name: null }])
|
||||
})
|
||||
|
||||
test('should handle setting value to undefined', () => {
|
||||
const rows = [{ idx: 0, id: 1, name: 'test' }]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, undefined)
|
||||
expect(result).toEqual([{ idx: 0, id: 1, name: undefined }])
|
||||
})
|
||||
|
||||
test('should handle numeric values', () => {
|
||||
const rows = [{ idx: 0, id: 1, count: 0 }]
|
||||
const result = applyCellEdit(rows, 'count', { id: 1 }, 42)
|
||||
expect(result).toEqual([{ idx: 0, id: 1, count: 42 }])
|
||||
})
|
||||
|
||||
test('should handle object values', () => {
|
||||
const rows = [{ idx: 0, id: 1, data: null }]
|
||||
const newValue = { nested: { value: 123 } }
|
||||
const result = applyCellEdit(rows, 'data', { id: 1 }, newValue)
|
||||
expect(result).toEqual([{ idx: 0, id: 1, data: newValue }])
|
||||
})
|
||||
|
||||
test('should handle empty rows array', () => {
|
||||
const rows: any[] = []
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('should update all matching rows with same identifier', () => {
|
||||
const rows = [
|
||||
{ idx: 0, id: 1, name: 'row1' },
|
||||
{ idx: 1, id: 1, name: 'row2' },
|
||||
{ idx: 2, id: 2, name: 'row3' },
|
||||
]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, 'updated')
|
||||
expect(result).toEqual([
|
||||
{ idx: 0, id: 1, name: 'updated' },
|
||||
{ idx: 1, id: 1, name: 'updated' },
|
||||
{ idx: 2, id: 2, name: 'row3' },
|
||||
])
|
||||
const result = formatGridDataWithOperationValues({ operations: [op], rows })
|
||||
expect(result[0].name).toBe('Updated')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { isTableLike, type Entity } from 'data/table-editor/table-editor-types'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import type { TableRowsData } from 'data/table-rows/table-rows-query'
|
||||
import type { Dictionary } from 'types'
|
||||
|
||||
import { isPendingAddRow, PendingAddRow, PendingDeleteRow, SupaRow } from '../types'
|
||||
import { isPendingAddRow, PendingAddRow, SupaRow } from '../types'
|
||||
import {
|
||||
EditCellContentOperation,
|
||||
NewQueuedOperation,
|
||||
@@ -63,68 +60,12 @@ export function rowMatchesIdentifiers(
|
||||
return identifierEntries.every(([key, value]) => row[key] === value)
|
||||
}
|
||||
|
||||
export function applyCellEdit(
|
||||
rows: SupaRow[],
|
||||
columnName: string,
|
||||
rowIdentifiers: Dictionary<unknown>,
|
||||
newValue: unknown
|
||||
): SupaRow[] {
|
||||
return rows.map((row) => {
|
||||
const rowMatches = rowMatchesIdentifiers(row, rowIdentifiers)
|
||||
if (rowMatches) {
|
||||
return { ...row, [columnName]: newValue }
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
export function applyRowAdd(
|
||||
rows: SupaRow[],
|
||||
tempId: string,
|
||||
idx: number,
|
||||
rowData: Dictionary<unknown>
|
||||
): (PendingAddRow | SupaRow)[] {
|
||||
// Check if row with this tempId already exists
|
||||
const existingIndex = rows.findIndex((row) => isPendingAddRow(row) && row.__tempId === tempId)
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing row in place
|
||||
return rows.map((row, index) => {
|
||||
if (index === existingIndex) {
|
||||
return { ...row, ...rowData, __tempId: tempId }
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
const newRow: PendingAddRow = {
|
||||
idx,
|
||||
...rowData,
|
||||
__tempId: tempId,
|
||||
}
|
||||
return [newRow, ...rows]
|
||||
}
|
||||
|
||||
export function markRowAsDeleted(
|
||||
rows: SupaRow[],
|
||||
rowIdentifiers: Dictionary<unknown>
|
||||
): (PendingDeleteRow | SupaRow)[] {
|
||||
return rows.map((row): PendingDeleteRow | SupaRow => {
|
||||
const rowMatches = rowMatchesIdentifiers(row, rowIdentifiers)
|
||||
if (rowMatches) {
|
||||
return { ...row, __isDeleted: true }
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
export function removeRow(rows: SupaRow[], rowIdentifiers: Dictionary<unknown>): SupaRow[] {
|
||||
return rows.filter((row) => !rowMatchesIdentifiers(row, rowIdentifiers))
|
||||
}
|
||||
|
||||
interface QueueCellEditParams {
|
||||
queryClient: QueryClient
|
||||
queueOperation: (operation: NewQueuedOperation) => void
|
||||
projectRef: string
|
||||
tableId: number
|
||||
table: Entity
|
||||
row: SupaRow
|
||||
@@ -136,9 +77,7 @@ interface QueueCellEditParams {
|
||||
}
|
||||
|
||||
export function queueCellEditWithOptimisticUpdate({
|
||||
queryClient,
|
||||
queueOperation,
|
||||
projectRef,
|
||||
tableId,
|
||||
table,
|
||||
row,
|
||||
@@ -167,22 +106,10 @@ export function queueCellEditWithOptimisticUpdate({
|
||||
enumArrayColumns,
|
||||
},
|
||||
})
|
||||
|
||||
// Apply optimistic update to the UI
|
||||
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } })
|
||||
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
rows: applyCellEdit(old.rows, columnName, rowIdentifiers, newValue),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface QueueRowAddParams {
|
||||
queryClient: QueryClient
|
||||
queueOperation: (operation: NewQueuedOperation) => void
|
||||
projectRef: string
|
||||
tableId: number
|
||||
table: Entity
|
||||
rowData: PendingAddRow
|
||||
@@ -190,9 +117,7 @@ interface QueueRowAddParams {
|
||||
}
|
||||
|
||||
export function queueRowAddWithOptimisticUpdate({
|
||||
queryClient,
|
||||
queueOperation,
|
||||
projectRef,
|
||||
tableId,
|
||||
table,
|
||||
rowData,
|
||||
@@ -213,73 +138,58 @@ export function queueRowAddWithOptimisticUpdate({
|
||||
enumArrayColumns,
|
||||
},
|
||||
})
|
||||
|
||||
// Apply optimistic update to the UI
|
||||
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } })
|
||||
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
rows: applyRowAdd(old.rows, tempId, idx, rowData),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface ReapplyOptimisticUpdatesParams {
|
||||
queryClient: QueryClient
|
||||
projectRef: string
|
||||
tableId: number
|
||||
operations: readonly QueuedOperation[]
|
||||
}
|
||||
|
||||
export function reapplyOptimisticUpdates({
|
||||
queryClient,
|
||||
projectRef,
|
||||
tableId,
|
||||
export const formatGridDataWithOperationValues = ({
|
||||
operations,
|
||||
}: ReapplyOptimisticUpdatesParams) {
|
||||
const tableOperations = operations.filter((op) => op.tableId === tableId)
|
||||
if (tableOperations.length === 0) return
|
||||
rows,
|
||||
}: {
|
||||
operations: QueuedOperation[]
|
||||
rows: SupaRow[]
|
||||
}) => {
|
||||
const formattedRows = rows.slice()
|
||||
|
||||
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } })
|
||||
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
|
||||
if (!old) return old
|
||||
operations.forEach((op) => {
|
||||
if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) {
|
||||
const { rowIdentifiers, columnName, newValue } = op.payload
|
||||
const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers))
|
||||
if (rowMatches) {
|
||||
formattedRows[rowMatches.idx] = { ...rowMatches, [columnName]: newValue }
|
||||
}
|
||||
} else if (op.type === QueuedOperationType.ADD_ROW) {
|
||||
const { tempId, rowData } = op.payload
|
||||
const idx = Number(tempId)
|
||||
|
||||
let rows = [...old.rows]
|
||||
for (const operation of tableOperations) {
|
||||
switch (operation.type) {
|
||||
case QueuedOperationType.EDIT_CELL_CONTENT: {
|
||||
const { rowIdentifiers, columnName, newValue } = operation.payload
|
||||
rows = applyCellEdit(rows, columnName, rowIdentifiers, newValue)
|
||||
break
|
||||
}
|
||||
case QueuedOperationType.ADD_ROW: {
|
||||
const { tempId, rowData } = operation.payload
|
||||
// Derive idx from tempId (tempId is stringified negative timestamp)
|
||||
const idx = Number(tempId)
|
||||
rows = applyRowAdd(rows, tempId, idx, rowData)
|
||||
break
|
||||
}
|
||||
case QueuedOperationType.DELETE_ROW: {
|
||||
const { rowIdentifiers } = operation.payload
|
||||
rows = markRowAsDeleted(rows, rowIdentifiers)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// Need to explicitly handle other operations
|
||||
throw new Error(`Unknown operation type: ${(operation as never)['type']}`)
|
||||
// Check if row with this tempId already exists
|
||||
const existingIndex = formattedRows.findIndex(
|
||||
(row) => isPendingAddRow(row) && row.__tempId === tempId
|
||||
)
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing row in place
|
||||
formattedRows[existingIndex] = {
|
||||
...formattedRows[existingIndex],
|
||||
...rowData,
|
||||
__tempId: tempId,
|
||||
}
|
||||
} else {
|
||||
const newRow: PendingAddRow = { ...rowData, idx, __tempId: tempId }
|
||||
formattedRows.unshift(newRow)
|
||||
}
|
||||
} else if (op.type === QueuedOperationType.DELETE_ROW) {
|
||||
const { rowIdentifiers } = op.payload
|
||||
const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers))
|
||||
if (rowMatches) {
|
||||
formattedRows[rowMatches.idx] = { ...rowMatches, __isDeleted: true }
|
||||
}
|
||||
}
|
||||
|
||||
return { ...old, rows }
|
||||
})
|
||||
|
||||
return formattedRows
|
||||
}
|
||||
|
||||
interface QueueRowDeletesParams {
|
||||
rows: SupaRow[]
|
||||
table: Entity
|
||||
queryClient: QueryClient
|
||||
queueOperation: (operation: NewQueuedOperation) => void
|
||||
projectRef: string | undefined
|
||||
}
|
||||
@@ -291,7 +201,6 @@ interface QueueRowDeletesParams {
|
||||
export function queueRowDeletesWithOptimisticUpdate({
|
||||
rows,
|
||||
table,
|
||||
queryClient,
|
||||
queueOperation,
|
||||
projectRef,
|
||||
}: QueueRowDeletesParams): void {
|
||||
@@ -327,24 +236,5 @@ export function queueRowDeletesWithOptimisticUpdate({
|
||||
table,
|
||||
},
|
||||
})
|
||||
|
||||
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } })
|
||||
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
|
||||
if (!old) return old
|
||||
|
||||
// For pending add rows, remove completely
|
||||
if (isPendingAddRow(row)) {
|
||||
return {
|
||||
...old,
|
||||
rows: removeRow(old.rows, rowIdentifiers),
|
||||
}
|
||||
}
|
||||
|
||||
// For existing rows, mark as deleted
|
||||
return {
|
||||
...old,
|
||||
rows: markRowAsDeleted(old.rows, rowIdentifiers),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useConsentState } from 'common'
|
||||
import { LOCAL_STORAGE_KEYS } from 'common/constants/local-storage'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { useSendResetMutation } from 'data/telemetry/send-reset-mutation'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { X } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { useConsentState } from 'common'
|
||||
import { LOCAL_STORAGE_KEYS } from 'common/constants/local-storage'
|
||||
import { useSendResetMutation } from 'data/telemetry/send-reset-mutation'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Badge } from 'ui'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { Card, CardContent, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Switch } from 'ui'
|
||||
import { Card, CardContent, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Switch } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import {
|
||||
PageSection,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
import * as z from 'zod'
|
||||
|
||||
export const PrivacyUpdateBanner = () => {
|
||||
const [privacyUpdateAcknowledged, setPrivacyUpdateAcknowledged] = useLocalStorageQuery(
|
||||
@@ -30,25 +30,20 @@ export const PrivacyUpdateBanner = () => {
|
||||
if (privacyUpdateAcknowledged) return null
|
||||
|
||||
return (
|
||||
<Alert_Shadcn_ className="mb-4 relative">
|
||||
<AlertTitle_Shadcn_>
|
||||
<Badge variant="default" className="mr-2">
|
||||
NOTICE
|
||||
</Badge>
|
||||
Privacy Policy Update – Effective March 16, 2026
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
We've updated our{' '}
|
||||
<a href="https://supabase.com/privacy" target="_blank" className="text hover:text-brand">
|
||||
Privacy Policy
|
||||
</a>{' '}
|
||||
to clarify our use of analytics, cookies, and advertising audience management tools. By
|
||||
continuing to use Supabase after March 16, you agree to the new terms. Questions? Contact{' '}
|
||||
<a href="mailto:privacy@supabase.com" target="_blank" className="text hover:text-brand">
|
||||
our team
|
||||
</a>
|
||||
.
|
||||
</AlertDescription_Shadcn_>
|
||||
<Admonition
|
||||
type="note"
|
||||
title="Updates to our privacy policy"
|
||||
description={
|
||||
<>
|
||||
We’ve clarified how we use analytics, cookies, and advertising tools in our{' '}
|
||||
<InlineLink href="https://supabase.com/privacy">privacy policy</InlineLink>, effective
|
||||
March 16, 2026. By continuing to use Supabase, you agree to the updated terms.{' '}
|
||||
<InlineLink href="mailto:privacy@supabase.com">Contact us</InlineLink> with any
|
||||
questions.{' '}
|
||||
</>
|
||||
}
|
||||
className="mb-10 relative"
|
||||
>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
icon={<X />}
|
||||
@@ -56,7 +51,7 @@ export const PrivacyUpdateBanner = () => {
|
||||
onClick={() => setPrivacyUpdateAcknowledged(true)}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Dismiss' } }}
|
||||
/>
|
||||
</Alert_Shadcn_>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
KeyboardShortcut,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Card, Form_Shadcn_ } from 'ui'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
@@ -23,11 +12,17 @@ import {
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { HotkeyToggle } from './HotkeyToggle'
|
||||
|
||||
const HotkeySchema = z.object({
|
||||
commandMenuEnabled: z.boolean(),
|
||||
aiAssistantEnabled: z.boolean(),
|
||||
inlineEditorEnabled: z.boolean(),
|
||||
copyMarkdownEnabled: z.boolean(),
|
||||
copyJsonEnabled: z.boolean(),
|
||||
downloadCsvEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
export const HotkeySettings = () => {
|
||||
@@ -43,6 +38,18 @@ export const HotkeySettings = () => {
|
||||
LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT),
|
||||
true
|
||||
)
|
||||
const [copyMarkdownEnabled, setCopyMarkdownEnabled] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.HOTKEY_COPY_MARKDOWN,
|
||||
true
|
||||
)
|
||||
const [copyJsonEnabled, setCopyJsonEnabled] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.HOTKEY_COPY_JSON,
|
||||
true
|
||||
)
|
||||
const [downloadCsvEnabled, setDownloadCsvEnabled] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.HOTKEY_DOWNLOAD_CSV,
|
||||
true
|
||||
)
|
||||
|
||||
const form = useForm<z.infer<typeof HotkeySchema>>({
|
||||
resolver: zodResolver(HotkeySchema),
|
||||
@@ -50,6 +57,9 @@ export const HotkeySettings = () => {
|
||||
commandMenuEnabled: commandMenuEnabled ?? true,
|
||||
aiAssistantEnabled: aiAssistantEnabled ?? true,
|
||||
inlineEditorEnabled: inlineEditorEnabled ?? true,
|
||||
copyMarkdownEnabled: copyMarkdownEnabled ?? true,
|
||||
copyJsonEnabled: copyJsonEnabled ?? true,
|
||||
downloadCsvEnabled: downloadCsvEnabled ?? true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -66,87 +76,49 @@ export const HotkeySettings = () => {
|
||||
<PageSectionContent>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<Card>
|
||||
<CardContent className="border-b">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="commandMenuEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label={
|
||||
<div className="flex items-center gap-x-3">
|
||||
<KeyboardShortcut keys={['Meta', 'k']} />
|
||||
<span>Command menu</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value)
|
||||
setCommandMenuEnabled(value)
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardContent className="border-b">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="aiAssistantEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label={
|
||||
<div className="flex items-center gap-x-3">
|
||||
<KeyboardShortcut keys={['Meta', 'i']} />
|
||||
<span>AI Assistant Panel</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value)
|
||||
setAiAssistantEnabled(value)
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="inlineEditorEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label={
|
||||
<div className="flex items-center gap-x-3">
|
||||
<KeyboardShortcut keys={['Meta', 'e']} />
|
||||
<span>Inline SQL Editor Panel</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value)
|
||||
setInlineEditorEnabled(value)
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<HotkeyToggle
|
||||
form={form}
|
||||
name="commandMenuEnabled"
|
||||
keys={['Meta', 'k']}
|
||||
label="Command menu"
|
||||
onToggle={setCommandMenuEnabled}
|
||||
/>
|
||||
<HotkeyToggle
|
||||
form={form}
|
||||
name="aiAssistantEnabled"
|
||||
keys={['Meta', 'i']}
|
||||
label="AI Assistant Panel"
|
||||
onToggle={setAiAssistantEnabled}
|
||||
/>
|
||||
<HotkeyToggle
|
||||
form={form}
|
||||
name="inlineEditorEnabled"
|
||||
keys={['Meta', 'e']}
|
||||
label="Inline SQL Editor Panel"
|
||||
onToggle={setInlineEditorEnabled}
|
||||
/>
|
||||
<HotkeyToggle
|
||||
form={form}
|
||||
name="copyMarkdownEnabled"
|
||||
keys={['Shift', 'Meta', 'm']}
|
||||
label="Copy results as Markdown"
|
||||
onToggle={setCopyMarkdownEnabled}
|
||||
/>
|
||||
<HotkeyToggle
|
||||
form={form}
|
||||
name="copyJsonEnabled"
|
||||
keys={['Shift', 'Meta', 'j']}
|
||||
label="Copy results as JSON"
|
||||
onToggle={setCopyJsonEnabled}
|
||||
/>
|
||||
<HotkeyToggle
|
||||
form={form}
|
||||
name="downloadCsvEnabled"
|
||||
keys={['Shift', 'Meta', 'd']}
|
||||
label="Download results as CSV"
|
||||
onToggle={setDownloadCsvEnabled}
|
||||
isLast
|
||||
/>
|
||||
</Card>
|
||||
</Form_Shadcn_>
|
||||
</PageSectionContent>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { FieldValues, Path, UseFormReturn } from 'react-hook-form'
|
||||
import { CardContent, FormControl_Shadcn_, FormField_Shadcn_, KeyboardShortcut, Switch } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
interface HotkeyToggleProps<T extends FieldValues> {
|
||||
form: UseFormReturn<T>
|
||||
name: Path<T>
|
||||
keys: string[]
|
||||
label: string
|
||||
onToggle: (value: boolean) => void
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function HotkeyToggle<T extends FieldValues>({
|
||||
form,
|
||||
name,
|
||||
keys,
|
||||
label,
|
||||
onToggle,
|
||||
isLast,
|
||||
}: HotkeyToggleProps<T>) {
|
||||
return (
|
||||
<CardContent className={isLast ? undefined : 'border-b'}>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label={
|
||||
<div className="flex items-center gap-x-3">
|
||||
<KeyboardShortcut keys={keys} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value)
|
||||
onToggle(value)
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export type FeaturePreview = {
|
||||
|
||||
export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs')
|
||||
const tableEditorNewFilterBar = useFlag('tableEditorNewFilterBar')
|
||||
|
||||
const pgDeltaDiffEnabled = useFlag('pgdeltaDiff')
|
||||
const showFloatingMobileToolbar = useFlag('enableFloatingMobileToolbar')
|
||||
const platformWebhooksEnabled = useFlag('platformWebhooks')
|
||||
@@ -101,7 +101,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
enabled: true,
|
||||
isNew: true,
|
||||
isPlatformOnly: false,
|
||||
isDefaultOptIn: tableEditorNewFilterBar,
|
||||
isDefaultOptIn: true,
|
||||
},
|
||||
{
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_FLOATING_MOBILE_TOOLBAR,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
BillingCustomerDataForm,
|
||||
type BillingCustomerDataFormValues,
|
||||
} from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm'
|
||||
import { TAX_IDS } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants'
|
||||
import { resolveStoredTaxId } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils'
|
||||
import { useBillingCustomerDataForm } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
|
||||
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
|
||||
@@ -82,9 +82,8 @@ export function UpdateBillingAddressModal() {
|
||||
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 || ''
|
||||
? (resolveStoredTaxId(taxId.type, taxId.country, customerProfile?.address?.country)?.name ??
|
||||
'')
|
||||
: '',
|
||||
}),
|
||||
[customerProfile, taxId]
|
||||
|
||||
+70
-1
@@ -2,6 +2,7 @@ import { useParams } from 'common'
|
||||
import { Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react'
|
||||
import { parseAsBoolean, parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -36,8 +37,11 @@ import AlertError from '@/components/ui/AlertError'
|
||||
import { FilterPopover } from '@/components/ui/FilterPopover'
|
||||
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
|
||||
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
|
||||
import { useOAuthCustomProvidersQuery } from '@/data/oauth-custom-providers/oauth-custom-providers-query'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { toast } from 'sonner'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
const CUSTOM_PROVIDERS_SORT_VALUES = [
|
||||
'name:asc',
|
||||
@@ -87,6 +91,23 @@ export const CustomAuthProvidersList = () => {
|
||||
const isCustomProvidersEnabled = !!authConfig?.CUSTOM_OAUTH_ENABLED
|
||||
const providerLimit = authConfig?.CUSTOM_OAUTH_MAX_PROVIDERS || 0
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { mutate: updateAuthConfig, isPending: isEnabling } = useAuthConfigUpdateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Custom providers have been enabled')
|
||||
// Invalidate and refetch custom providers query so it retries after enabling
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['projects', projectRef, 'oauth-custom-providers'],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleEnableCustomProviders = () => {
|
||||
if (projectRef) {
|
||||
updateAuthConfig({ projectRef, config: { CUSTOM_OAUTH_ENABLED: true } })
|
||||
}
|
||||
}
|
||||
|
||||
const [selectedProviderToEdit, setSelectedProviderToEdit] = useState<string | null>(null)
|
||||
const [selectedProviderToDelete, setSelectedProviderToDelete] = useState<string | null>(null)
|
||||
const [filteredProviderTypes, setFilteredProviderTypes] = useState<string[]>([])
|
||||
@@ -201,7 +222,44 @@ export const CustomAuthProvidersList = () => {
|
||||
return <GenericSkeletonLoader />
|
||||
}
|
||||
|
||||
if (!isCustomProvidersEnabled) {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Custom providers are not enabled"
|
||||
description="Enable custom OAuth/OIDC providers to configure your own identity providers for authentication."
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isEnabling}
|
||||
disabled={isEnabling}
|
||||
onClick={handleEnableCustomProviders}
|
||||
>
|
||||
Enable Custom Providers
|
||||
</Button>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
if (error?.message?.includes('Custom providers are not enabled')) {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Custom providers are not enabled"
|
||||
description="Enable custom OAuth/OIDC providers to configure your own identity providers for authentication."
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isEnabling}
|
||||
disabled={isEnabling}
|
||||
onClick={handleEnableCustomProviders}
|
||||
>
|
||||
Enable Custom Providers
|
||||
</Button>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
return <AlertError error={error} subject="Failed to retrieve Custom Auth Providers" />
|
||||
}
|
||||
|
||||
@@ -267,7 +325,18 @@ export const CustomAuthProvidersList = () => {
|
||||
className="text-xs flex flex-col gap-y-2 bg-alternative items-start"
|
||||
>
|
||||
{!isCustomProvidersEnabled ? (
|
||||
'Custom providers are not enabled for this project. Please contact support to enable this feature.'
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<p>Custom providers are not enabled for this project.</p>
|
||||
<Button
|
||||
type="primary"
|
||||
size="tiny"
|
||||
loading={isEnabling}
|
||||
disabled={isEnabling}
|
||||
onClick={handleEnableCustomProviders}
|
||||
>
|
||||
Enable Custom Providers
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p>You've reached the limit of {providerLimit} providers for your plan.</p>
|
||||
|
||||
+7
-5
@@ -13,6 +13,10 @@ import {
|
||||
} from '@stripe/stripe-js'
|
||||
import { Form } from '@ui/components/shadcn/ui/form'
|
||||
import { TAX_IDS } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants'
|
||||
import {
|
||||
resolveStoredTaxId,
|
||||
getEffectiveTaxCountry,
|
||||
} from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils'
|
||||
import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types'
|
||||
import { getURL } from 'lib/helpers'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
@@ -96,10 +100,8 @@ export const NewPaymentMethodElement = forwardRef(
|
||||
resolver: zodResolver(BillingCustomerDataSchema),
|
||||
defaultValues: {
|
||||
tax_id_name: currentTaxId
|
||||
? TAX_IDS.find(
|
||||
(option) =>
|
||||
option.type === currentTaxId.type && option.countryIso2 === currentTaxId.country
|
||||
)?.name || ''
|
||||
? (resolveStoredTaxId(currentTaxId.type, currentTaxId.country, currentAddress?.country)
|
||||
?.name ?? '')
|
||||
: '',
|
||||
tax_id_type: currentTaxId ? currentTaxId.type : '',
|
||||
tax_id_value: currentTaxId ? currentTaxId.value : '',
|
||||
@@ -172,7 +174,7 @@ export const NewPaymentMethodElement = forwardRef(
|
||||
function getConfiguredTaxId(): CustomerTaxId | null {
|
||||
return purchasingAsBusiness && selectedTaxId
|
||||
? {
|
||||
country: selectedTaxId.countryIso2,
|
||||
country: getEffectiveTaxCountry(selectedTaxId),
|
||||
type: selectedTaxId.type,
|
||||
value: form.getValues('tax_id_value'),
|
||||
}
|
||||
|
||||
@@ -44,8 +44,6 @@ export function ConnectConfigSection({
|
||||
}: ConnectConfigSectionProps) {
|
||||
if (activeFields.length === 0) return null
|
||||
|
||||
const formLayoutClassName = 'md:[&>div:first-child]:!w-1/3 xl:[&>div:first-child]:!w-2/5'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{activeFields.map((field) => {
|
||||
|
||||
@@ -471,7 +471,7 @@ describe('connect.resolver:getDefaultState', () => {
|
||||
steps: [],
|
||||
}
|
||||
|
||||
const state = getDefaultState(schema)
|
||||
const state = getDefaultState({ schema })
|
||||
expect(state.mode).toBe('framework')
|
||||
})
|
||||
|
||||
@@ -501,7 +501,7 @@ describe('connect.resolver:getDefaultState', () => {
|
||||
steps: [],
|
||||
}
|
||||
|
||||
const state = getDefaultState(schema)
|
||||
const state = getDefaultState({ schema })
|
||||
expect(state.framework).toBe('nextjs')
|
||||
expect(state.library).toBe('supabasejs')
|
||||
expect(state.mcpReadonly).toBe(false)
|
||||
@@ -514,7 +514,7 @@ describe('connect.resolver:getDefaultState', () => {
|
||||
steps: [],
|
||||
}
|
||||
|
||||
const state = getDefaultState(schema)
|
||||
const state = getDefaultState({ schema })
|
||||
expect(state.mode).toBe('direct')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -196,7 +196,7 @@ function resolveFieldOptions(field: { options?: unknown }, state: ConnectState):
|
||||
/**
|
||||
* Gets default state for the schema, using first mode and default field values.
|
||||
*/
|
||||
export function getDefaultState(schema: ConnectSchema): ConnectState {
|
||||
export function getDefaultState({ schema }: { schema: ConnectSchema }): ConnectState {
|
||||
const defaultMode = schema.modes[0]?.id ?? 'direct'
|
||||
|
||||
const state: ConnectState = { mode: defaultMode }
|
||||
|
||||
@@ -170,6 +170,7 @@ describe('connect.schema:steps resolution', () => {
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'shadcn-add')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'shadcn-env')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'shadcn-explore')).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -186,6 +187,7 @@ describe('connect.schema:steps resolution', () => {
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'shadcn-add')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'shadcn-env')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve default steps for other frameworks', () => {
|
||||
@@ -337,6 +339,14 @@ describe('connect.schema:step content paths', () => {
|
||||
expect(exploreStep?.content).toBe('steps/shadcn/explore')
|
||||
})
|
||||
|
||||
test('shadcn env step should have valid content path', () => {
|
||||
const state: ConnectState = { mode: 'framework', framework: 'nextjs', frameworkUi: true }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
const envStep = steps.find((s) => s.id === 'shadcn-env')
|
||||
|
||||
expect(envStep?.content).toBe('steps/shadcn/env')
|
||||
})
|
||||
|
||||
test('direct connection step should have valid content path', () => {
|
||||
const state: ConnectState = { mode: 'direct' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
@@ -53,6 +53,13 @@ const frameworkShadcnStep: StepDefinition = {
|
||||
content: 'steps/shadcn/command',
|
||||
}
|
||||
|
||||
const frameworkShadcnEnvStep: StepDefinition = {
|
||||
id: 'shadcn-env',
|
||||
title: 'Set env variables',
|
||||
description: 'Add the following values to your env file.',
|
||||
content: 'steps/shadcn/env',
|
||||
}
|
||||
|
||||
const frameworkShadcnExploreStep: StepDefinition = {
|
||||
id: 'shadcn-explore',
|
||||
title: 'Check out more UI components',
|
||||
@@ -173,7 +180,7 @@ export const connectSchema: ConnectSchema = {
|
||||
id: 'direct',
|
||||
label: 'Direct',
|
||||
description: 'Connection string',
|
||||
fields: ['connectionMethod', 'useSharedPooler', 'connectionType'],
|
||||
fields: ['connectionSource', 'connectionMethod', 'useSharedPooler', 'connectionType'],
|
||||
},
|
||||
{
|
||||
id: 'orm',
|
||||
@@ -226,6 +233,13 @@ export const connectSchema: ConnectSchema = {
|
||||
},
|
||||
|
||||
// Direct connection fields
|
||||
connectionSource: {
|
||||
id: 'connectionSource',
|
||||
type: 'select',
|
||||
label: 'Source',
|
||||
options: { source: 'connectionSources' },
|
||||
defaultValue: undefined,
|
||||
},
|
||||
connectionMethod: {
|
||||
id: 'connectionMethod',
|
||||
type: 'radio-list',
|
||||
@@ -297,6 +311,7 @@ export const connectSchema: ConnectSchema = {
|
||||
true: [
|
||||
frameworkInstallStep,
|
||||
frameworkShadcnStep,
|
||||
frameworkShadcnEnvStep,
|
||||
frameworkShadcnExploreStep,
|
||||
skillsInstallStep,
|
||||
],
|
||||
@@ -308,6 +323,7 @@ export const connectSchema: ConnectSchema = {
|
||||
true: [
|
||||
frameworkInstallStep,
|
||||
frameworkShadcnStep,
|
||||
frameworkShadcnEnvStep,
|
||||
frameworkShadcnExploreStep,
|
||||
skillsInstallStep,
|
||||
],
|
||||
|
||||
+88
-1
@@ -1,7 +1,10 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
import { CodeBlock } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { getConnectionStrings } from '../../../DatabaseSettings.utils'
|
||||
import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import {
|
||||
type ConnectionStringMethod,
|
||||
type DatabaseConnectionType,
|
||||
@@ -15,6 +18,12 @@ import {
|
||||
PASSWORD_PLACEHOLDER,
|
||||
resolveConnectionString,
|
||||
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
|
||||
import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
|
||||
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { pluckObjectFields } from '@/lib/helpers'
|
||||
|
||||
const buildPsqlCommand = (params: { host: string; port: string; database: string; user: string }) =>
|
||||
`psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}`
|
||||
@@ -22,15 +31,93 @@ const buildPsqlCommand = (params: { host: string; port: string; database: string
|
||||
const buildJdbcString = (params: { host: string; port: string; database: string; user: string }) =>
|
||||
`jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}`
|
||||
|
||||
/**
|
||||
* [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings
|
||||
* from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here
|
||||
*
|
||||
* We can however, consider to shift this logic into ConnectStepsSection, such that we can consider read replicas for
|
||||
* the other tabs like "Framework" and "ORM" too. However, leaving them out for now and only updating "Direct"
|
||||
*/
|
||||
const useConnectionStringDatabases = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler')
|
||||
|
||||
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
||||
const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef })
|
||||
const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef })
|
||||
const { data: addons } = useProjectAddonsQuery({ projectRef })
|
||||
const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? [])
|
||||
|
||||
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
|
||||
const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' }
|
||||
|
||||
return Object.fromEntries(
|
||||
databases.map((db) => {
|
||||
const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS)
|
||||
const poolingConfigurationShared = supavisorConfig?.find(
|
||||
(x) => x.identifier === db.identifier
|
||||
)
|
||||
const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined
|
||||
|
||||
const connectionStringsShared = getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationShared?.connection_string ?? '',
|
||||
db_host: poolingConfigurationShared?.db_host ?? '',
|
||||
db_name: poolingConfigurationShared?.db_name ?? '',
|
||||
db_port: poolingConfigurationShared?.db_port ?? 0,
|
||||
db_user: poolingConfigurationShared?.db_user ?? '',
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
|
||||
const connectionStringsDedicated =
|
||||
poolingConfigurationDedicated !== undefined
|
||||
? getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationDedicated.connection_string.replace(
|
||||
projectRef ?? '_',
|
||||
db.identifier
|
||||
),
|
||||
db_host: poolingConfigurationDedicated.db_host,
|
||||
db_name: poolingConfigurationDedicated.db_name,
|
||||
db_port: poolingConfigurationDedicated.db_port,
|
||||
db_user: poolingConfigurationDedicated.db_user,
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
: undefined
|
||||
|
||||
return [
|
||||
db.identifier,
|
||||
{
|
||||
transactionShared: connectionStringsShared.pooler.uri,
|
||||
sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'),
|
||||
transactionDedicated: connectionStringsDedicated?.pooler.uri,
|
||||
sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'),
|
||||
ipv4SupportedForDedicatedPooler: !!ipv4Addon,
|
||||
direct: connectionStringsShared.direct.uri,
|
||||
},
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Step component for direct database connections.
|
||||
* Uses state to determine which connection string to show.
|
||||
*/
|
||||
function DirectConnectionContent({ state, connectionStringPooler }: StepContentProps) {
|
||||
function DirectConnectionContent({ state }: StepContentProps) {
|
||||
const connectionSource = state.connectionSource
|
||||
const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
|
||||
const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
|
||||
const useSharedPooler = Boolean(state.useSharedPooler)
|
||||
|
||||
const connectionStrings = useConnectionStringDatabases()
|
||||
const connectionStringPooler =
|
||||
connectionStrings[connectionSource as keyof typeof connectionStrings]
|
||||
|
||||
// Determine which connection string to use
|
||||
const resolvedConnectionString = useMemo(
|
||||
() =>
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock'
|
||||
|
||||
import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
function getEnvFile(
|
||||
state: StepContentProps['state'],
|
||||
projectKeys: StepContentProps['projectKeys']
|
||||
) {
|
||||
if (state.framework === 'nextjs') {
|
||||
return {
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: [
|
||||
`NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
|
||||
projectKeys.publishableKey
|
||||
? `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
|
||||
: `NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
if (state.framework === 'react') {
|
||||
const isCreateReactApp = state.frameworkVariant === 'create-react-app'
|
||||
const envFileName = isCreateReactApp ? '.env.local' : '.env'
|
||||
const keyPrefix = isCreateReactApp ? 'REACT_APP' : 'VITE'
|
||||
|
||||
return {
|
||||
name: envFileName,
|
||||
language: 'bash',
|
||||
code: [
|
||||
`${keyPrefix}_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
|
||||
projectKeys.publishableKey
|
||||
? `${keyPrefix}_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
|
||||
: `${keyPrefix}_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function ShadcnEnvContent({ state, projectKeys }: StepContentProps) {
|
||||
const envFile = getEnvFile(state, projectKeys)
|
||||
|
||||
if (!envFile) return null
|
||||
|
||||
return <MultipleCodeBlock files={[envFile]} />
|
||||
}
|
||||
|
||||
export default ShadcnEnvContent
|
||||
@@ -1,8 +1,16 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { useConnectState } from './useConnectState'
|
||||
|
||||
vi.mock('common', () => ({
|
||||
useParams: () => ({ ref: 'test-ref' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/data/read-replicas/replicas-query', () => ({
|
||||
useReadReplicasQuery: () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
describe('useConnectState', () => {
|
||||
// ============================================================================
|
||||
// Initial State Tests
|
||||
@@ -371,6 +379,7 @@ describe('useConnectState', () => {
|
||||
|
||||
const stepIds = result.current.resolvedSteps.map((s) => s.id)
|
||||
expect(stepIds).toContain('shadcn-add')
|
||||
expect(stepIds).toContain('shadcn-env')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useParams } from 'common'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder'
|
||||
|
||||
@@ -24,6 +25,8 @@ import type {
|
||||
ResolvedStep,
|
||||
} from './Connect.types'
|
||||
import { resolveFrameworkLibraryKey } from './Connect.utils'
|
||||
import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
|
||||
|
||||
// ============================================================================
|
||||
// Data Source Helpers
|
||||
@@ -33,7 +36,15 @@ import { resolveFrameworkLibraryKey } from './Connect.utils'
|
||||
* Get field options from a data source reference.
|
||||
* This maps source names to actual data.
|
||||
*/
|
||||
function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOption[] {
|
||||
function getFieldOptionsFromSource({
|
||||
source,
|
||||
state,
|
||||
databases,
|
||||
}: {
|
||||
source: string
|
||||
state: ConnectState
|
||||
databases: Database[]
|
||||
}): FieldOption[] {
|
||||
switch (source) {
|
||||
case 'frameworks':
|
||||
return [...FRAMEWORKS, ...MOBILES].map((f) => ({
|
||||
@@ -98,6 +109,16 @@ function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOp
|
||||
description: m.description,
|
||||
}))
|
||||
|
||||
case 'connectionSources':
|
||||
return databases.map((db) => {
|
||||
const region = formatDatabaseRegion(db?.region ?? '')
|
||||
const id = formatDatabaseID(db.identifier ?? '')
|
||||
const label = db.identifier.includes('-rr-')
|
||||
? `Read Replica (${region} - ${id}}`
|
||||
: 'Primary Database'
|
||||
return { value: db.identifier, label }
|
||||
})
|
||||
|
||||
case 'connectionTypes':
|
||||
return DATABASE_CONNECTION_TYPES.map((t) => ({
|
||||
value: t.id,
|
||||
@@ -133,7 +154,15 @@ function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOp
|
||||
/**
|
||||
* Resolve field options, handling both static options and data source references.
|
||||
*/
|
||||
function resolveFieldOptionsWithSource(field: ResolvedField, state: ConnectState): FieldOption[] {
|
||||
function resolveFieldOptionsWithSource({
|
||||
field,
|
||||
state,
|
||||
databases,
|
||||
}: {
|
||||
field: ResolvedField
|
||||
state: ConnectState
|
||||
databases: Database[]
|
||||
}): FieldOption[] {
|
||||
// If already resolved (from conditional resolution)
|
||||
if (field.resolvedOptions.length > 0) {
|
||||
return field.resolvedOptions
|
||||
@@ -142,7 +171,7 @@ function resolveFieldOptionsWithSource(field: ResolvedField, state: ConnectState
|
||||
// Check if it's a source reference
|
||||
const options = connectSchema.fields[field.id]?.options
|
||||
if (options && typeof options === 'object' && 'source' in options) {
|
||||
return getFieldOptionsFromSource(options.source as string, state)
|
||||
return getFieldOptionsFromSource({ source: options.source as string, state, databases })
|
||||
}
|
||||
|
||||
return []
|
||||
@@ -163,8 +192,11 @@ export interface UseConnectStateReturn {
|
||||
}
|
||||
|
||||
export function useConnectState(initialState?: Partial<ConnectState>): UseConnectStateReturn {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
||||
|
||||
const [state, setState] = useState<ConnectState>(() => {
|
||||
const defaults = getDefaultState(connectSchema)
|
||||
const defaults = getDefaultState({ schema: connectSchema })
|
||||
|
||||
// Set initial framework if mode is framework
|
||||
if (defaults.mode === 'framework' && !defaults.framework) {
|
||||
@@ -246,41 +278,45 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((mode: ConnectMode) => {
|
||||
setState((prev) => {
|
||||
const next: ConnectState = { ...prev, mode }
|
||||
const setMode = useCallback(
|
||||
(mode: ConnectMode) => {
|
||||
setState((prev) => {
|
||||
const next: ConnectState = { ...prev, mode }
|
||||
|
||||
// Initialize mode-specific defaults
|
||||
if (mode === 'framework' && !next.framework) {
|
||||
const firstFramework = FRAMEWORKS[0]
|
||||
next.framework = firstFramework?.key ?? ''
|
||||
if (firstFramework?.children?.length > 1) {
|
||||
next.frameworkVariant = firstFramework.children[0]?.key ?? ''
|
||||
// Initialize mode-specific defaults
|
||||
if (mode === 'framework' && !next.framework) {
|
||||
const firstFramework = FRAMEWORKS[0]
|
||||
next.framework = firstFramework?.key ?? ''
|
||||
if (firstFramework?.children?.length > 1) {
|
||||
next.frameworkVariant = firstFramework.children[0]?.key ?? ''
|
||||
}
|
||||
const libraryKey = resolveFrameworkLibraryKey({
|
||||
framework: next.framework,
|
||||
frameworkVariant: next.frameworkVariant,
|
||||
library: next.library,
|
||||
})
|
||||
if (libraryKey) next.library = libraryKey
|
||||
}
|
||||
const libraryKey = resolveFrameworkLibraryKey({
|
||||
framework: next.framework,
|
||||
frameworkVariant: next.frameworkVariant,
|
||||
library: next.library,
|
||||
})
|
||||
if (libraryKey) next.library = libraryKey
|
||||
}
|
||||
|
||||
if (mode === 'direct') {
|
||||
next.connectionMethod = next.connectionMethod ?? 'direct'
|
||||
next.connectionType = next.connectionType ?? 'uri'
|
||||
}
|
||||
if (mode === 'direct') {
|
||||
next.connectionMethod = next.connectionMethod ?? 'direct'
|
||||
next.connectionType = next.connectionType ?? 'uri'
|
||||
next.connectionSource = projectRef ?? '_'
|
||||
}
|
||||
|
||||
if (mode === 'orm' && !next.orm) {
|
||||
next.orm = ORMS[0]?.key ?? ''
|
||||
}
|
||||
if (mode === 'orm' && !next.orm) {
|
||||
next.orm = ORMS[0]?.key ?? ''
|
||||
}
|
||||
|
||||
if (mode === 'mcp' && !next.mcpClient) {
|
||||
next.mcpClient = MCP_CLIENTS[0]?.key ?? ''
|
||||
}
|
||||
if (mode === 'mcp' && !next.mcpClient) {
|
||||
next.mcpClient = MCP_CLIENTS[0]?.key ?? ''
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
return next
|
||||
})
|
||||
},
|
||||
[projectRef]
|
||||
)
|
||||
|
||||
const activeFields = useMemo(() => getActiveFields(connectSchema, state), [state])
|
||||
|
||||
@@ -290,9 +326,9 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
|
||||
(fieldId: string): FieldOption[] => {
|
||||
const field = activeFields.find((f) => f.id === fieldId)
|
||||
if (!field) return []
|
||||
return resolveFieldOptionsWithSource(field, state)
|
||||
return resolveFieldOptionsWithSource({ field, state, databases })
|
||||
},
|
||||
[activeFields, state]
|
||||
[activeFields, state, databases]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -73,9 +73,11 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden 2xl:table-cell whitespace-nowrap">
|
||||
<p className="text-foreground-light">
|
||||
{dayjs(item.created_at).format('DD MMM, YYYY HH:mm')}
|
||||
</p>
|
||||
<TimestampInfo
|
||||
className="text-sm text-foreground-light whitespace-nowrap"
|
||||
utcTimestamp={item.created_at}
|
||||
label={dayjs(item.created_at).fromNow()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="lg:table-cell">
|
||||
<TimestampInfo
|
||||
|
||||
+3
-4
@@ -25,7 +25,7 @@ import {
|
||||
BillingCustomerDataForm,
|
||||
type BillingCustomerDataFormValues,
|
||||
} from './BillingCustomerDataForm'
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
import { resolveStoredTaxId } from './TaxID.utils'
|
||||
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
|
||||
|
||||
export const BillingCustomerData = () => {
|
||||
@@ -66,9 +66,8 @@ export const BillingCustomerData = () => {
|
||||
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 || ''
|
||||
? (resolveStoredTaxId(taxId.type, taxId.country, customerProfile?.address?.country)?.name ??
|
||||
'')
|
||||
: '',
|
||||
}),
|
||||
[customerProfile, taxId]
|
||||
|
||||
+12
@@ -5,6 +5,8 @@ export interface TaxId {
|
||||
countryIso2: string
|
||||
placeholder: string
|
||||
vatPrefix?: string
|
||||
/** Override the country code sent to Orb when it differs from countryIso2 */
|
||||
taxCountryIso2?: string
|
||||
}
|
||||
|
||||
// Commented out countries are not currently supported by Orb API
|
||||
@@ -430,6 +432,16 @@ export const TAX_IDS: TaxId[] = [
|
||||
vatPrefix: 'GB',
|
||||
countryIso2: 'GB',
|
||||
},
|
||||
{
|
||||
// Isle of Man uses GB VAT numbers, taxCountryIso2 must be GB for Orb validation
|
||||
name: 'IM VAT',
|
||||
type: 'gb_vat',
|
||||
country: 'Isle of Man',
|
||||
placeholder: 'GB123456789',
|
||||
vatPrefix: 'GB',
|
||||
countryIso2: 'IM',
|
||||
taxCountryIso2: 'GB',
|
||||
},
|
||||
{
|
||||
name: 'GE VAT',
|
||||
type: 'ge_vat',
|
||||
|
||||
+32
-1
@@ -1,4 +1,35 @@
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
import { TAX_IDS, TaxId } from './TaxID.constants'
|
||||
|
||||
/**
|
||||
* Returns the country code to send to Orb for tax validation.
|
||||
*
|
||||
* Most tax IDs use their display country (countryIso2), but some territories
|
||||
* share another country's tax system — e.g., Isle of Man uses GB VAT.
|
||||
* In these cases, taxCountryIso2 overrides the country sent to Orb.
|
||||
*/
|
||||
export const getEffectiveTaxCountry = (taxId: TaxId): string =>
|
||||
taxId.taxCountryIso2 ?? taxId.countryIso2
|
||||
|
||||
/**
|
||||
* Resolves a stored tax ID (from Orb) back to its TAX_IDS UI entry.
|
||||
*
|
||||
* Uses billingCountry (from the customer's address) to disambiguate when
|
||||
* multiple entries share the same Orb type+country — e.g., both "UK VAT"
|
||||
* and "IM VAT" map to {type: 'gb_vat', country: 'GB'} in Orb, but differ
|
||||
* by billing address country (GB vs IM).
|
||||
*
|
||||
* When billingCountry is unavailable, falls back to the stored country.
|
||||
*/
|
||||
export const resolveStoredTaxId = (
|
||||
type: string,
|
||||
taxCountry: string,
|
||||
billingCountry?: string
|
||||
): TaxId | undefined => {
|
||||
const candidates = TAX_IDS.filter((option) => option.type === type)
|
||||
const preferredCountry = billingCountry ?? taxCountry
|
||||
|
||||
return candidates.find((o) => o.countryIso2 === preferredCountry)
|
||||
}
|
||||
|
||||
export const sanitizeTaxIdValue = (taxId: { name: string; value: string }) => {
|
||||
const selectedTaxId = TAX_IDS.find((option) => option.name === taxId.name)
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form'
|
||||
|
||||
import { BillingCustomerDataFormValues, BillingCustomerDataSchema } from './BillingCustomerDataForm'
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
import { sanitizeTaxIdValue } from './TaxID.utils'
|
||||
import { getEffectiveTaxCountry, sanitizeTaxIdValue } from './TaxID.utils'
|
||||
import { components } from 'api-types'
|
||||
import type { CustomerAddress } from 'data/organizations/types'
|
||||
|
||||
@@ -88,7 +88,7 @@ export function useBillingCustomerDataForm({
|
||||
value: values.tax_id_value,
|
||||
name: form.getValues().tax_id_name,
|
||||
}),
|
||||
country: selectedTaxId?.countryIso2,
|
||||
country: getEffectiveTaxCountry(selectedTaxId),
|
||||
}
|
||||
: null,
|
||||
})
|
||||
|
||||
@@ -95,7 +95,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
|
||||
</div>
|
||||
) : errorLints.length > 0 ? (
|
||||
<>
|
||||
<Row columns={[3, 2, 1]}>
|
||||
<Row maxColumns={4} minWidth={280}>
|
||||
{errorLints.map((lint) => {
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -405,7 +405,8 @@ export function CustomReportSection() {
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<Row
|
||||
columns={[3, 2, 1]}
|
||||
maxColumns={4}
|
||||
minWidth={280}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
||||
@@ -219,7 +219,7 @@ export const ProjectUsageSection = () => {
|
||||
tooltipSide="left"
|
||||
/>
|
||||
</div>
|
||||
<Row columns={[3, 2, 1]}>
|
||||
<Row maxColumns={4} minWidth={280}>
|
||||
{enabledServices.map((s) => (
|
||||
<Card key={s.key} className="mb-0 md:mb-0 h-full flex flex-col h-64">
|
||||
<CardHeader className="flex flex-row items-end justify-between gap-2 space-y-0 pb-0 border-b-0">
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
convertResultsToCSV,
|
||||
convertResultsToJSON,
|
||||
convertResultsToMarkdown,
|
||||
formatResults,
|
||||
getResultsHeaders,
|
||||
} from './Results.utils'
|
||||
|
||||
describe('Results.utils', () => {
|
||||
describe('formatResults', () => {
|
||||
it('should stringify object values', () => {
|
||||
const results = [{ id: 1, data: { nested: true } }]
|
||||
const formatted = formatResults(results)
|
||||
expect(formatted).toEqual([{ id: 1, data: '{"nested":true}' }])
|
||||
})
|
||||
|
||||
it('should stringify array values', () => {
|
||||
const results = [{ id: 1, tags: ['a', 'b'] }]
|
||||
const formatted = formatResults(results)
|
||||
expect(formatted).toEqual([{ id: 1, tags: '["a","b"]' }])
|
||||
})
|
||||
|
||||
it('should stringify null values', () => {
|
||||
const results = [{ id: 1, value: null }]
|
||||
const formatted = formatResults(results)
|
||||
expect(formatted).toEqual([{ id: 1, value: 'null' }])
|
||||
})
|
||||
|
||||
it('should leave primitive values unchanged', () => {
|
||||
const results = [{ name: 'test', count: 42, active: true }]
|
||||
const formatted = formatResults(results)
|
||||
expect(formatted).toEqual([{ name: 'test', count: 42, active: true }])
|
||||
})
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(formatResults([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertResultsToMarkdown', () => {
|
||||
it('should return undefined for empty results', () => {
|
||||
expect(convertResultsToMarkdown([])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should convert results to a markdown table', () => {
|
||||
const results = [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
]
|
||||
const md = convertResultsToMarkdown(results)
|
||||
expect(md).toContain('| id | name |')
|
||||
expect(md).toContain('| 1 | Alice |')
|
||||
expect(md).toContain('| 2 | Bob |')
|
||||
})
|
||||
|
||||
it('should stringify nested objects in markdown output', () => {
|
||||
const results = [{ id: 1, meta: { role: 'admin' } }]
|
||||
const md = convertResultsToMarkdown(results)
|
||||
expect(md).toContain('{"role":"admin"}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertResultsToJSON', () => {
|
||||
it('should return undefined for empty results', () => {
|
||||
expect(convertResultsToJSON([])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return formatted JSON string', () => {
|
||||
const results = [{ id: 1, name: 'Alice' }]
|
||||
const json = convertResultsToJSON(results)
|
||||
expect(json).toBe(JSON.stringify(results, null, 2))
|
||||
})
|
||||
|
||||
it('should preserve nested object structure', () => {
|
||||
const results = [{ id: 1, meta: { role: 'admin' } }]
|
||||
const json = convertResultsToJSON(results)
|
||||
const parsed = JSON.parse(json!)
|
||||
expect(parsed[0].meta.role).toBe('admin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getResultsHeaders', () => {
|
||||
it('should return undefined for empty results', () => {
|
||||
expect(getResultsHeaders([])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return keys from the first row', () => {
|
||||
const results = [{ id: 1, name: 'Alice' }]
|
||||
expect(getResultsHeaders(results)).toEqual(['id', 'name'])
|
||||
})
|
||||
|
||||
it('should preserve key order from the first row', () => {
|
||||
const results = [{ z: 1, a: 2, m: 3 }]
|
||||
expect(getResultsHeaders(results)).toEqual(['z', 'a', 'm'])
|
||||
})
|
||||
|
||||
it('should use only the first row for headers', () => {
|
||||
const results = [{ id: 1 }, { id: 2, extra: 'bonus' }]
|
||||
expect(getResultsHeaders(results)).toEqual(['id'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertResultsToCSV', () => {
|
||||
it('should return undefined for empty results', () => {
|
||||
expect(convertResultsToCSV([])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should include header row from first result keys', () => {
|
||||
const results = [{ id: 1, name: 'Alice' }]
|
||||
const csv = convertResultsToCSV(results)
|
||||
expect(csv).toContain('id,name')
|
||||
})
|
||||
|
||||
it('should include data rows', () => {
|
||||
const results = [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
]
|
||||
const csv = convertResultsToCSV(results)
|
||||
expect(csv).toContain('Alice')
|
||||
expect(csv).toContain('Bob')
|
||||
})
|
||||
|
||||
it('should stringify object values', () => {
|
||||
const results = [{ id: 1, meta: { role: 'admin' } }]
|
||||
const csv = convertResultsToCSV(results)
|
||||
// CSV wraps values containing special chars in quotes and escapes inner quotes
|
||||
expect(csv).toContain('"{""role"":""admin""}"')
|
||||
})
|
||||
|
||||
it('should stringify array values', () => {
|
||||
const results = [{ id: 1, tags: ['a', 'b'] }]
|
||||
const csv = convertResultsToCSV(results)
|
||||
// CSV wraps values containing special chars in quotes and escapes inner quotes
|
||||
expect(csv).toContain('"[""a"",""b""]"')
|
||||
})
|
||||
|
||||
it('should stringify null values', () => {
|
||||
const results = [{ id: 1, value: null }]
|
||||
const csv = convertResultsToCSV(results)
|
||||
expect(csv).toContain('null')
|
||||
})
|
||||
|
||||
it('should preserve column order from first row', () => {
|
||||
const results = [{ z: 1, a: 2, m: 3 }]
|
||||
const csv = convertResultsToCSV(results)
|
||||
// split on CRLF line endings produced by papaparse
|
||||
const headerRow = csv!.split('\r\n')[0]
|
||||
expect(headerRow).toBe('z,a,m')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { markdownTable } from 'markdown-table'
|
||||
import Papa from 'papaparse'
|
||||
|
||||
type ResultRow = Record<string, unknown>
|
||||
|
||||
export function formatResults(
|
||||
results: ResultRow[]
|
||||
): Record<string, string | number | boolean | null | undefined>[] {
|
||||
return results.map((row) => {
|
||||
const formatted: Record<string, string | number | boolean> = {}
|
||||
for (const key of Object.keys(row)) {
|
||||
const value = row[key]
|
||||
formatted[key] =
|
||||
typeof value === 'object' ? JSON.stringify(value) : (value as string | number | boolean)
|
||||
}
|
||||
return formatted
|
||||
})
|
||||
}
|
||||
|
||||
export function convertResultsToMarkdown(results: ResultRow[]): string | undefined {
|
||||
const formatted = formatResults(results)
|
||||
if (formatted.length === 0) return undefined
|
||||
|
||||
const columns = Object.keys(formatted[0])
|
||||
const rows = formatted.map((row) => columns.map((col) => String(row[col] ?? '')))
|
||||
const table = [columns, ...rows]
|
||||
return markdownTable(table)
|
||||
}
|
||||
|
||||
export function convertResultsToJSON(results: ResultRow[]): string | undefined {
|
||||
if (results.length === 0) return undefined
|
||||
return JSON.stringify(results, null, 2)
|
||||
}
|
||||
|
||||
export function getResultsHeaders(results: ResultRow[]): string[] | undefined {
|
||||
const firstRow = Array.from(results)[0]
|
||||
if (firstRow) return Object.keys(firstRow)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function convertResultsToCSV(results: ResultRow[]): string | undefined {
|
||||
if (results.length === 0) return undefined
|
||||
|
||||
const headers = getResultsHeaders(results)
|
||||
const formatted = formatResults(results)
|
||||
|
||||
return Papa.unparse(formatted, { columns: headers })
|
||||
}
|
||||
+5
-1
@@ -1,8 +1,10 @@
|
||||
import { Column } from 'react-data-grid'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
import type { LogData } from '../Logs.types'
|
||||
import { extractEdgeFunctionName } from '../Logs.utils'
|
||||
import { ResponseCodeFormatter, RowLayout, TextFormatter } from '../LogsFormatters'
|
||||
import { defaultRenderCell } from './DefaultPreviewColumnRenderer'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
const columns: Column<LogData>[] = [
|
||||
{
|
||||
@@ -13,11 +15,13 @@ const columns: Column<LogData>[] = [
|
||||
if (!props.row.status_code && !props.row.method) {
|
||||
return defaultRenderCell(props)
|
||||
}
|
||||
const functionName = extractEdgeFunctionName(props.row.pathname)
|
||||
return (
|
||||
<RowLayout>
|
||||
<TimestampInfo utcTimestamp={props.row.timestamp!} />
|
||||
<ResponseCodeFormatter value={String(props.row.status_code)} />
|
||||
<TextFormatter value={String(props.row.method)} />
|
||||
{functionName && <TextFormatter value={functionName} />}
|
||||
<TextFormatter value={String(props.row.id)} />
|
||||
</RowLayout>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { LogData } from './Logs.types'
|
||||
import { buildLogsPrompt, formatLogsAsJson, formatLogsAsMarkdown } from './Logs.utils'
|
||||
import {
|
||||
buildLogsPrompt,
|
||||
extractEdgeFunctionName,
|
||||
formatLogsAsJson,
|
||||
formatLogsAsMarkdown,
|
||||
} from './Logs.utils'
|
||||
|
||||
const createLog = (overrides: Partial<LogData> = {}): LogData => ({
|
||||
id: 'test-id',
|
||||
@@ -83,4 +88,31 @@ describe('Logs.utils', () => {
|
||||
expect(result).toContain('1 Supabase log entry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractEdgeFunctionName', () => {
|
||||
test('extracts function name from full pathname', () => {
|
||||
expect(extractEdgeFunctionName('/functions/v1/hello-world-1')).toBe('hello-world-1')
|
||||
})
|
||||
|
||||
test('returns empty string for null', () => {
|
||||
expect(extractEdgeFunctionName(null)).toBe('')
|
||||
})
|
||||
|
||||
test('returns empty string for undefined', () => {
|
||||
expect(extractEdgeFunctionName(undefined)).toBe('')
|
||||
})
|
||||
|
||||
test('returns empty string for non-string values', () => {
|
||||
expect(extractEdgeFunctionName(42)).toBe('')
|
||||
expect(extractEdgeFunctionName({})).toBe('')
|
||||
})
|
||||
|
||||
test('handles pathname with no slashes', () => {
|
||||
expect(extractEdgeFunctionName('my-function')).toBe('my-function')
|
||||
})
|
||||
|
||||
test('handles trailing slash', () => {
|
||||
expect(extractEdgeFunctionName('/functions/v1/hello-world-1/')).toBe('hello-world-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -205,7 +205,7 @@ ${orderBy}
|
||||
limit ${limit}
|
||||
`
|
||||
}
|
||||
return `select id, ${table}.timestamp, event_message, response.status_code, request.method, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table}
|
||||
return `select id, ${table}.timestamp, event_message, response.status_code, request.method, request.pathname, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table}
|
||||
${joins}
|
||||
${where}
|
||||
${orderBy}
|
||||
@@ -808,6 +808,12 @@ const LOG_TABLE_TO_SERVICE_LABEL: Record<string, string> = {
|
||||
etl_replication_logs: 'ETL',
|
||||
}
|
||||
|
||||
export function extractEdgeFunctionName(pathname: unknown): string {
|
||||
if (typeof pathname !== 'string' || !pathname) return ''
|
||||
const parts = pathname.split('/').filter(Boolean)
|
||||
return parts[parts.length - 1] ?? ''
|
||||
}
|
||||
|
||||
function extractServiceLabelFromSql(sql: string): string | null {
|
||||
const match = sql.match(/\bfrom\s+(\w+)/i)
|
||||
const tableName = match?.[1]
|
||||
|
||||
+52
-14
@@ -19,7 +19,16 @@ import { ExternalLink, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Dictionary } from 'types'
|
||||
import { Button, Checkbox, Input, SidePanel, Toggle } from 'ui'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
SidePanel,
|
||||
Toggle,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { ActionBar } from '../ActionBar'
|
||||
import type { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types'
|
||||
@@ -378,20 +387,49 @@ export const ColumnEditor = ({
|
||||
label="Is Primary Key"
|
||||
descriptionText="A primary key indicates that a column or group of columns can be used as a unique identifier for rows in the table"
|
||||
checked={columnFields?.isPrimaryKey ?? false}
|
||||
onChange={() => onUpdateField({ isPrimaryKey: !columnFields?.isPrimaryKey })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Allow Nullable"
|
||||
descriptionText="Allow the column to assume a NULL value if no value is provided"
|
||||
checked={columnFields.isNullable}
|
||||
onChange={() => onUpdateField({ isNullable: !columnFields.isNullable })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Is Unique"
|
||||
descriptionText="Enforce values in the column to be unique across rows"
|
||||
checked={columnFields.isUnique}
|
||||
onChange={() => onUpdateField({ isUnique: !columnFields.isUnique })}
|
||||
onChange={() =>
|
||||
onUpdateField({
|
||||
isPrimaryKey: !columnFields?.isPrimaryKey,
|
||||
isUnique: false,
|
||||
isNullable: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Toggle
|
||||
label="Allow Nullable"
|
||||
disabled={columnFields.isPrimaryKey}
|
||||
descriptionText="Allow the column to assume a NULL value if no value is provided"
|
||||
checked={columnFields.isNullable}
|
||||
onChange={() => onUpdateField({ isNullable: !columnFields.isNullable })}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" align="start">
|
||||
Column is a primary key and hence cannot be NULL
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Toggle
|
||||
label="Is Unique"
|
||||
disabled={columnFields.isPrimaryKey}
|
||||
descriptionText="Enforce values in the column to be unique across rows"
|
||||
checked={columnFields.isUnique}
|
||||
onChange={() => onUpdateField({ isUnique: !columnFields.isUnique })}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" align="start">
|
||||
Column is a primary key and hence already unique
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Input
|
||||
label="CHECK Constraint"
|
||||
labelOptional="Optional"
|
||||
|
||||
+11
-7
@@ -1,13 +1,13 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Undo2 } from 'lucide-react'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { Card, CardContent, CardHeader } from 'ui'
|
||||
|
||||
import { formatOperationItemValue } from './OperationQueueSidePanel.utils'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { AddRowPayload } from '@/state/table-editor-operation-queue.types'
|
||||
import { Card, CardContent, CardHeader } from 'ui'
|
||||
|
||||
interface AddRowOperationItemProps {
|
||||
operationId: string
|
||||
@@ -49,7 +49,7 @@ export const AddRowOperationItem = ({
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pt-2.5 flex flex-row gap-2">
|
||||
<CardHeader className="py-2 px-3 flex flex-row gap-2 space-y-0 items-center">
|
||||
<div className="min-w-0 flex-1 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="text-code-inline dark:bg-surface-300 dark:border-foreground-muted/50">
|
||||
@@ -62,14 +62,13 @@ export const AddRowOperationItem = ({
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
aria-label="Revert change"
|
||||
className="px-1.5"
|
||||
className="w-7"
|
||||
icon={<Undo2 />}
|
||||
onClick={handleDelete}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
side: 'left',
|
||||
align: 'end',
|
||||
text: 'Revert change',
|
||||
},
|
||||
@@ -77,7 +76,12 @@ export const AddRowOperationItem = ({
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="font-mono text-xs space-y-1 text-brand-link">
|
||||
<CardContent className="py-2 px-3 font-mono text-xs text-brand-link">
|
||||
{previewColumns.length === 0 && (
|
||||
<span className="text-foreground-light">
|
||||
No data provided, default values will be used
|
||||
</span>
|
||||
)}
|
||||
{previewColumns.map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2 py-0.5">
|
||||
<span className="text-brand-link select-none font-medium">+</span>
|
||||
|
||||
+6
-7
@@ -1,13 +1,13 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { Card, CardContent, CardHeader } from 'ui'
|
||||
|
||||
import { formatOperationItemValue } from './OperationQueueSidePanel.utils'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { DeleteRowPayload } from '@/state/table-editor-operation-queue.types'
|
||||
import { Card, CardContent, CardHeader } from 'ui'
|
||||
|
||||
interface DeleteRowOperationItemProps {
|
||||
operationId: string
|
||||
@@ -47,7 +47,7 @@ export const DeleteRowOperationItem = ({
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-destructive-500 bg-destructive-500/5">
|
||||
<CardHeader className="pt-2.5 flex flex-row gap-2 border-b border-destructive-500">
|
||||
<CardHeader className="py-2 px-3 flex flex-row gap-2 border-b border-destructive-500 space-y-0 items-center">
|
||||
<div className="min-w-0 flex-1 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="text-code-inline dark:bg-surface-300 dark:border-foreground-muted/50">
|
||||
@@ -62,14 +62,13 @@ export const DeleteRowOperationItem = ({
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
aria-label="Revert change"
|
||||
className="px-1.5"
|
||||
className="w-7"
|
||||
icon={<Undo2 />}
|
||||
onClick={handleDelete}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
side: 'left',
|
||||
align: 'end',
|
||||
text: 'Revert change',
|
||||
},
|
||||
@@ -77,7 +76,7 @@ export const DeleteRowOperationItem = ({
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="font-mono text-xs bg-destructive-100/30">
|
||||
<CardContent className="py-2 px-3 font-mono text-xs bg-destructive-100/30">
|
||||
<div className="text-destructive py-0.5">Row will be deleted</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+5
-6
@@ -1,13 +1,13 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { Card, CardContent, CardHeader } from 'ui'
|
||||
|
||||
import { formatOperationItemValue } from './OperationQueueSidePanel.utils'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { EditCellContentPayload } from '@/state/table-editor-operation-queue.types'
|
||||
import { Card, CardContent, CardHeader } from 'ui'
|
||||
|
||||
interface OperationItemProps {
|
||||
operationId: string
|
||||
@@ -46,7 +46,7 @@ export const OperationItem = ({ operationId, tableId, content }: OperationItemPr
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pt-2.5 flex flex-row gap-2">
|
||||
<CardHeader className="py-2 px-3 flex flex-row gap-2 space-y-0 items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="text-code-inline dark:bg-surface-300 dark:border-foreground-muted/50">
|
||||
{fullTableName}
|
||||
@@ -59,14 +59,13 @@ export const OperationItem = ({ operationId, tableId, content }: OperationItemPr
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
aria-label="Revert change"
|
||||
className="px-1.5"
|
||||
icon={<Undo2 />}
|
||||
onClick={handleDelete}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
side: 'left',
|
||||
align: 'end',
|
||||
text: 'Revert change',
|
||||
},
|
||||
@@ -74,7 +73,7 @@ export const OperationItem = ({ operationId, tableId, content }: OperationItemPr
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="font-mono text-xs">
|
||||
<CardContent className="py-2 px-3 font-mono text-xs">
|
||||
<div className="flex gap-2 py-0.5">
|
||||
<span className="text-destructive select-none font-medium">-</span>
|
||||
<span className="text-destructive truncate max-w-full" title={formattedOldValue}>
|
||||
|
||||
+8
-8
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
QueuedOperation,
|
||||
isAddRowOperation,
|
||||
isDeleteRowOperation,
|
||||
isEditCellContentOperation,
|
||||
QueuedOperation,
|
||||
} from 'state/table-editor-operation-queue.types'
|
||||
|
||||
import { AddRowOperationItem } from './AddRowOperationItem'
|
||||
@@ -27,10 +27,10 @@ export const OperationList = ({ operations }: OperationListProps) => {
|
||||
const editOperations = operations.filter(isEditCellContentOperation)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="divide-y">
|
||||
{deleteOperations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs text-foreground-lighter">
|
||||
<div className="space-y-3 p-4">
|
||||
<h3 className="text-xs text-foreground-lighter font-mono uppercase">
|
||||
{deleteOperations.length} row deletion{deleteOperations.length !== 1 ? 's' : ''}
|
||||
</h3>
|
||||
|
||||
@@ -48,8 +48,8 @@ export const OperationList = ({ operations }: OperationListProps) => {
|
||||
)}
|
||||
|
||||
{addOperations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs text-foreground-lighter">
|
||||
<div className="space-y-3 p-4">
|
||||
<h3 className="text-xs text-foreground-lighter font-mono uppercase">
|
||||
{addOperations.length} row addition{addOperations.length !== 1 ? 's' : ''}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
@@ -66,8 +66,8 @@ export const OperationList = ({ operations }: OperationListProps) => {
|
||||
)}
|
||||
|
||||
{editOperations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs text-foreground-lighter">
|
||||
<div className="space-y-3 p-4">
|
||||
<h3 className="text-xs text-foreground-lighter font-mono uppercase">
|
||||
{editOperations.length} cell edit{editOperations.length !== 1 ? 's' : ''}
|
||||
</h3>
|
||||
|
||||
|
||||
+64
-52
@@ -1,72 +1,84 @@
|
||||
import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { Button, SidePanel } from 'ui'
|
||||
import {
|
||||
Button,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
} from 'ui'
|
||||
|
||||
import { OperationList } from './OperationList'
|
||||
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
|
||||
import { getModKeyLabel } from '@/lib/helpers'
|
||||
import { QueuedOperation } from '@/state/table-editor-operation-queue.types'
|
||||
|
||||
interface OperationQueueSidePanelProps {
|
||||
visible: boolean
|
||||
closePanel: () => void
|
||||
}
|
||||
|
||||
export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueSidePanelProps) => {
|
||||
export const OperationQueueSidePanel = () => {
|
||||
const modKey = getModKeyLabel()
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
|
||||
const visible = snap.sidePanel?.type === 'operation-queue'
|
||||
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
|
||||
|
||||
const { handleSave, handleCancel, isSaving } = useOperationQueueActions({
|
||||
onSaveSuccess: closePanel,
|
||||
onCancelSuccess: closePanel,
|
||||
onSaveSuccess: snap.closeSidePanel,
|
||||
onCancelSuccess: snap.closeSidePanel,
|
||||
})
|
||||
|
||||
const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({
|
||||
checkIsDirty: () => true,
|
||||
onClose: () => handleCancel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
size="large"
|
||||
visible={visible}
|
||||
onCancel={closePanel}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()} // Prevent focus on first focussable element since it is the revert changes ButtonTooltip
|
||||
header={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>Pending changes</span>
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
<>
|
||||
<Sheet open={visible} onOpenChange={(open) => !open && snap.closeSidePanel()}>
|
||||
<SheetContent
|
||||
className="flex flex-col gap-y-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Pending changes</SheetTitle>
|
||||
<SheetDescription>
|
||||
{operations.length} operation{operations.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
customFooter={
|
||||
<div className="flex w-full justify-between border-t border-default px-3 py-4">
|
||||
<Button type="default" onClick={closePanel}>
|
||||
Close
|
||||
<span className="text-foreground/40 text-[10px] ml-1.5">{modKey}.</span>
|
||||
</Button>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving || operations.length === 0}
|
||||
>
|
||||
Revert{operations.length > 1 && ' all'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetSection className="overflow-auto flex-grow p-0">
|
||||
<OperationList operations={operations} />
|
||||
</SheetSection>
|
||||
|
||||
<SheetFooter className="!justify-between">
|
||||
<Button type="default" onClick={snap.closeSidePanel}>
|
||||
Close
|
||||
<span className="text-foreground/40 text-[10px] ml-1.5">{modKey}.</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || operations.length === 0}
|
||||
loading={isSaving}
|
||||
>
|
||||
Save{operations.length > 1 && ' all'}
|
||||
<span className="text-foreground/40 text-[10px] ml-1.5">{modKey}S</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content className="py-4 h-full">
|
||||
<OperationList operations={operations} />
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={confirmOnClose}
|
||||
disabled={isSaving || operations.length === 0}
|
||||
>
|
||||
Revert{operations.length > 1 && ' all'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || operations.length === 0}
|
||||
loading={isSaving}
|
||||
>
|
||||
Save{operations.length > 1 && ' all'}
|
||||
<span className="text-foreground/40 text-[10px] ml-1.5">{modKey}S</span>
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+2
-9
@@ -1,5 +1,5 @@
|
||||
import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
@@ -276,9 +276,7 @@ export const SidePanelEditor = ({
|
||||
// Queue the ADD_ROW operation if queue operations feature is enabled
|
||||
if (isQueueOperationsEnabled && selectedTable.primary_keys.length > 0) {
|
||||
queueRowAddWithOptimisticUpdate({
|
||||
queryClient,
|
||||
queueOperation: snap.queueOperation,
|
||||
projectRef: project.ref,
|
||||
tableId: selectedTable.id,
|
||||
table: selectedTable as unknown as Entity,
|
||||
rowData: payload,
|
||||
@@ -330,9 +328,7 @@ export const SidePanelEditor = ({
|
||||
const oldValue = row[changedColumn]
|
||||
|
||||
queueCellEditWithOptimisticUpdate({
|
||||
queryClient,
|
||||
queueOperation: snap.queueOperation,
|
||||
projectRef: project.ref,
|
||||
tableId: selectedTable.id,
|
||||
// Cast to Entity - the queue save mutation only uses id, name, schema
|
||||
table: selectedTable as unknown as Entity,
|
||||
@@ -1068,10 +1064,7 @@ export const SidePanelEditor = ({
|
||||
closePanel={onClosePanel}
|
||||
updateEditorDirty={setIsEdited}
|
||||
/>
|
||||
<OperationQueueSidePanel
|
||||
visible={snap.sidePanel?.type === 'operation-queue'}
|
||||
closePanel={snap.closeSidePanel}
|
||||
/>
|
||||
<OperationQueueSidePanel />
|
||||
<DiscardChangesConfirmationDialog {...modalProps} />
|
||||
</>
|
||||
)
|
||||
|
||||
+67
-41
@@ -4,10 +4,12 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { EMPTY_ARR, EMPTY_OBJ } from 'lib/void'
|
||||
import { Link, Menu, Plus, Settings, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Checkbox_Shadcn_,
|
||||
cn,
|
||||
Command_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { typeExpressionSuggestions } from '../ColumnEditor/ColumnEditor.constants'
|
||||
import type { Suggestion } from '../ColumnEditor/ColumnEditor.types'
|
||||
@@ -53,20 +56,20 @@ interface ColumnProps {
|
||||
isNewRecord: boolean
|
||||
hasForeignKeys: boolean
|
||||
hasImportContent: boolean
|
||||
dragHandleProps?: any
|
||||
dragHandleProps?: DraggableProvidedDragHandleProps | null
|
||||
onUpdateColumn: (changes: Partial<ColumnField>) => void
|
||||
onRemoveColumn: () => void
|
||||
onEditForeignKey: (relation?: ForeignKey) => void
|
||||
}
|
||||
|
||||
const Column = ({
|
||||
export const Column = ({
|
||||
column = EMPTY_OBJ as ColumnField,
|
||||
relations = EMPTY_ARR as ForeignKey[],
|
||||
enumTypes = EMPTY_ARR as EnumeratedType[],
|
||||
isNewRecord = false,
|
||||
hasForeignKeys = false,
|
||||
hasImportContent = false,
|
||||
dragHandleProps = EMPTY_OBJ,
|
||||
dragHandleProps,
|
||||
onUpdateColumn,
|
||||
onRemoveColumn,
|
||||
onEditForeignKey,
|
||||
@@ -125,7 +128,7 @@ const Column = ({
|
||||
'[&>div>div>div>input]:py-1.5 [&>div>div>div>input]:border-r-transparent [&>div>div>div>input]:rounded-r-none',
|
||||
hasImportContent ? 'opacity-50' : ''
|
||||
)}
|
||||
onChange={(event: any) => onUpdateColumn({ name: event.target.value })}
|
||||
onChange={(event) => onUpdateColumn({ name: event.target.value })}
|
||||
/>
|
||||
{relations.filter((r) => !r.toRemove).length === 0 ? (
|
||||
<Button
|
||||
@@ -258,7 +261,7 @@ const Column = ({
|
||||
suggestions={suggestions}
|
||||
suggestionsHeader="Suggested expressions"
|
||||
suggestionsTooltip="Suggested expressions"
|
||||
onChange={(event: any) => onUpdateColumn({ defaultValue: event.target.value })}
|
||||
onChange={(event) => onUpdateColumn({ defaultValue: event.target.value })}
|
||||
onSelectSuggestion={(suggestion: Suggestion) =>
|
||||
onUpdateColumn({ defaultValue: suggestion.value })
|
||||
}
|
||||
@@ -295,54 +298,79 @@ const Column = ({
|
||||
<Settings size={16} strokeWidth={1} />
|
||||
</div>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ align="end" className="w-96 p-0">
|
||||
<div className="flex items-center justify-center bg-surface-200 space-y-1 py-1.5 px-3 border-b border-overlay">
|
||||
<PopoverContent_Shadcn_ align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-center bg-surface-200 gap-y-1 py-1.5 px-3 border-b border-overlay">
|
||||
<h5 className="text-foreground">Extra options</h5>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1" key={`${column.id}_configuration`}>
|
||||
<div className="flex flex-col gap-y-4 p-4" key={`${column.id}_configuration`}>
|
||||
{!column.isPrimaryKey && (
|
||||
<Checkbox
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id="isNullable"
|
||||
label="Is Nullable"
|
||||
description="Specify if the column can assume a NULL value if no value is provided"
|
||||
checked={column.isNullable}
|
||||
className="p-4"
|
||||
onChange={() => onUpdateColumn({ isNullable: !column.isNullable })}
|
||||
/>
|
||||
>
|
||||
<Checkbox_Shadcn_
|
||||
id="isNullable"
|
||||
checked={column.isNullable}
|
||||
onCheckedChange={() => onUpdateColumn({ isNullable: !column.isNullable })}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
{!column.isPrimaryKey && (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id="isUnique"
|
||||
label="Is Unique"
|
||||
description="Enforce if values in the column should be unique across rows"
|
||||
>
|
||||
<Checkbox_Shadcn_
|
||||
id="isUnique"
|
||||
checked={column.isUnique}
|
||||
onCheckedChange={() => onUpdateColumn({ isUnique: !column.isUnique })}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
<Checkbox
|
||||
label="Is Unique"
|
||||
description="Enforce if values in the column should be unique across rows"
|
||||
checked={column.isUnique}
|
||||
className="p-4"
|
||||
onChange={() => onUpdateColumn({ isUnique: !column.isUnique })}
|
||||
/>
|
||||
{column.format.includes('int') && (
|
||||
<Checkbox
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id="isIdentity"
|
||||
label="Is Identity"
|
||||
description="Automatically assign a sequential unique number to the column"
|
||||
checked={column.isIdentity}
|
||||
className="p-4"
|
||||
onChange={() => {
|
||||
const isIdentity = !column.isIdentity
|
||||
const isArray = isIdentity ? false : column.isArray
|
||||
onUpdateColumn({ isIdentity, isArray })
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Checkbox_Shadcn_
|
||||
id="isIdentity"
|
||||
checked={column.isIdentity}
|
||||
onCheckedChange={() => {
|
||||
const isIdentity = !column.isIdentity
|
||||
const isArray = isIdentity ? false : column.isArray
|
||||
onUpdateColumn({ isIdentity, isArray })
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
|
||||
{!column.isPrimaryKey && (
|
||||
<Checkbox
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex"
|
||||
id="defineAsArray"
|
||||
label="Define as Array"
|
||||
description="Define your column as a variable-length multidimensional array"
|
||||
checked={column.isArray}
|
||||
className="p-4"
|
||||
onChange={() => {
|
||||
const isArray = !column.isArray
|
||||
const isIdentity = isArray ? false : column.isIdentity
|
||||
onUpdateColumn({ isArray, isIdentity })
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Checkbox_Shadcn_
|
||||
id="defineAsArray"
|
||||
checked={column.isArray}
|
||||
onCheckedChange={() => {
|
||||
const isArray = !column.isArray
|
||||
const isIdentity = isArray ? false : column.isIdentity
|
||||
onUpdateColumn({ isArray, isIdentity })
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent_Shadcn_>
|
||||
@@ -359,5 +387,3 @@ const Column = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Column
|
||||
+18
-19
@@ -1,36 +1,37 @@
|
||||
import { isEmpty, noop, partition } from 'lodash'
|
||||
import { Edit, ExternalLink, HelpCircle, Key, Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
DraggableProvided,
|
||||
Droppable,
|
||||
DroppableProvided,
|
||||
} from 'react-beautiful-dnd'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import type { EnumeratedType } from 'data/enumerated-types/enumerated-types-query'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { isEmpty, noop, partition } from 'lodash'
|
||||
import { Edit, ExternalLink, HelpCircle, Key, Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
Droppable,
|
||||
type DraggableProvided,
|
||||
type DroppableProvided,
|
||||
type DropResult,
|
||||
} from 'react-beautiful-dnd'
|
||||
import {
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
|
||||
import { generateColumnField } from '../ColumnEditor/ColumnEditor.utils'
|
||||
import { ForeignKeySelector } from '../ForeignKeySelector/ForeignKeySelector'
|
||||
import type { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types'
|
||||
import { TEXT_TYPES } from '../SidePanelEditor.constants'
|
||||
import type { ColumnField, ExtendedPostgresRelationship } from '../SidePanelEditor.types'
|
||||
import Column from './Column'
|
||||
import { Column } from './Column'
|
||||
import type { ImportContent, TableField } from './TableEditor.types'
|
||||
|
||||
interface ColumnManagementProps {
|
||||
@@ -46,7 +47,7 @@ interface ColumnManagementProps {
|
||||
onUpdateFkRelations: (relations: ForeignKey[]) => void
|
||||
}
|
||||
|
||||
const ColumnManagement = ({
|
||||
export const ColumnManagement = ({
|
||||
table,
|
||||
columns = [],
|
||||
relations,
|
||||
@@ -114,7 +115,7 @@ const ColumnManagement = ({
|
||||
onColumnsUpdated(updatedColumns)
|
||||
}
|
||||
|
||||
const onSortColumns = (result: any, type: 'pks' | 'others') => {
|
||||
const onSortColumns = (result: DropResult, type: 'pks' | 'others') => {
|
||||
// Dropped outside of the list
|
||||
if (!result.destination) {
|
||||
return
|
||||
@@ -258,7 +259,7 @@ const ColumnManagement = ({
|
||||
</div>
|
||||
|
||||
{primaryKeyColumns.length > 0 && (
|
||||
<DragDropContext onDragEnd={(result: any) => onSortColumns(result, 'pks')}>
|
||||
<DragDropContext onDragEnd={(result) => onSortColumns(result, 'pks')}>
|
||||
<Droppable droppableId="pk_columns_droppable">
|
||||
{(droppableProvided: DroppableProvided) => (
|
||||
<div
|
||||
@@ -303,7 +304,7 @@ const ColumnManagement = ({
|
||||
</DragDropContext>
|
||||
)}
|
||||
|
||||
<DragDropContext onDragEnd={(result: any) => onSortColumns(result, 'others')}>
|
||||
<DragDropContext onDragEnd={(result) => onSortColumns(result, 'others')}>
|
||||
<Droppable droppableId="other_columns_droppable">
|
||||
{(droppableProvided: DroppableProvided) => (
|
||||
<div
|
||||
@@ -379,5 +380,3 @@ const ColumnManagement = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColumnManagement
|
||||
+3
-4
@@ -29,7 +29,7 @@ import type { SaveTableParams } from '../SidePanelEditor'
|
||||
import type { ColumnField } from '../SidePanelEditor.types'
|
||||
import { SpreadsheetImport } from '../SpreadsheetImport/SpreadsheetImport'
|
||||
import { ApiAccessToggle, type TableApiAccessHandlerWithHistoryReturn } from './ApiAccessToggle'
|
||||
import ColumnManagement from './ColumnManagement'
|
||||
import { ColumnManagement } from './ColumnManagement'
|
||||
import { ForeignKeysManagement } from './ForeignKeysManagement/ForeignKeysManagement'
|
||||
import { HeaderTitle } from './HeaderTitle'
|
||||
import { RLSDisableModalContent } from './RLSDisableModal'
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
validateFields,
|
||||
} from './TableEditor.utils'
|
||||
import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled'
|
||||
import { checkDataApiPrivilegesNonEmpty } from '@/lib/data-api-types'
|
||||
|
||||
type SaveTableParamsFor<Action extends SaveTableParams['action']> = Extract<
|
||||
SaveTableParams,
|
||||
@@ -357,7 +356,7 @@ export const TableEditor = ({
|
||||
type="text"
|
||||
error={errors.name ? String(errors.name) : undefined}
|
||||
value={tableFields?.name}
|
||||
onChange={(event: any) => onUpdateField({ name: event.target.value })}
|
||||
onChange={(event) => onUpdateField({ name: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Description"
|
||||
@@ -366,7 +365,7 @@ export const TableEditor = ({
|
||||
layout="horizontal"
|
||||
type="text"
|
||||
value={tableFields?.comment ?? ''}
|
||||
onChange={(event: any) => onUpdateField({ comment: event.target.value })}
|
||||
onChange={(event) => onUpdateField({ comment: event.target.value })}
|
||||
/>
|
||||
</SidePanel.Content>
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
|
||||
import { ProjectLayout } from '../ProjectLayout'
|
||||
import EdgeFunctionsLayout from './EdgeFunctionsLayout'
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
@@ -273,7 +274,11 @@ const EdgeFunctionDetailsLayout = ({
|
||||
<PageHeaderSummary>
|
||||
<PageHeaderTitle>{functionSlug ? name : 'Edge Functions'}</PageHeaderTitle>
|
||||
<PageHeaderDescription className="flex flex-row flex-wrap items-center gap-x-4 gap-y-1 !text-sm">
|
||||
<span className="flex items-center gap-2">{functionUrl}</span>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="flex items-center gap-2">{functionUrl}</span>
|
||||
<CopyButton iconOnly type="text" text={functionUrl} />
|
||||
</div>
|
||||
|
||||
<HoverCard
|
||||
openDelay={250}
|
||||
closeDelay={100}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
import {
|
||||
DiscardChangesConfirmationDialog,
|
||||
type DiscardChangesConfirmationDialogProps,
|
||||
} from './DiscardChangesConfirmationDialog'
|
||||
import { usePreventNavigationOnUnsavedChanges } from '@/hooks/ui/usePreventNavigationOnUnsavedChanges'
|
||||
|
||||
export const PreventNavigationOnUnsavedChanges = ({
|
||||
hasChanges,
|
||||
@@ -14,56 +11,15 @@ export const PreventNavigationOnUnsavedChanges = ({
|
||||
DiscardChangesConfirmationDialogProps,
|
||||
'visible' | 'onClose' | 'onCancel'
|
||||
>) => {
|
||||
const router = useRouter()
|
||||
const [navigateUrl, setNavigateUrl] = useState<string>()
|
||||
const [confirmNavigate, setConfirmNavigate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // deprecated, but older browsers still require this
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowseAway = (url: string) => {
|
||||
if (hasChanges && !confirmNavigate) {
|
||||
setNavigateUrl(url)
|
||||
throw 'Route change declined' // Just to prevent the route change
|
||||
return
|
||||
}
|
||||
setNavigateUrl(undefined)
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.on('routeChangeStart', handleBrowseAway)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.off('routeChangeStart', handleBrowseAway)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [confirmNavigate, hasChanges])
|
||||
|
||||
const handleCancel = () => {
|
||||
setNavigateUrl(undefined)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setConfirmNavigate(true)
|
||||
let urlToNavigate = navigateUrl ?? '/'
|
||||
if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) {
|
||||
urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/'
|
||||
}
|
||||
if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}`
|
||||
setNavigateUrl(undefined)
|
||||
router.push(urlToNavigate)
|
||||
}
|
||||
const { handleCancel, handleConfirm, shouldConfirm } = usePreventNavigationOnUnsavedChanges({
|
||||
hasChanges,
|
||||
})
|
||||
|
||||
return (
|
||||
<DiscardChangesConfirmationDialog
|
||||
visible={!!navigateUrl}
|
||||
visible={shouldConfirm}
|
||||
onCancel={handleCancel}
|
||||
onClose={handleClose}
|
||||
onClose={handleConfirm}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/Lay
|
||||
import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query'
|
||||
import { useRateMessageMutation } from 'data/ai/rate-message-mutation'
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
@@ -142,7 +142,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
})
|
||||
}, [project?.ref, project?.connectionString, selectedOrganizationRef.current?.slug, state])
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const track = useTrack()
|
||||
|
||||
const {
|
||||
messages: chatMessages,
|
||||
@@ -234,17 +234,11 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
spanId: state.messageSpanIds[messageId],
|
||||
})
|
||||
|
||||
sendEvent({
|
||||
action: 'assistant_message_rating_submitted',
|
||||
properties: {
|
||||
rating,
|
||||
category: result.category,
|
||||
...(reason && { reason }),
|
||||
},
|
||||
groups: {
|
||||
project: project.ref,
|
||||
organization: selectedOrganization.slug,
|
||||
},
|
||||
track('assistant_message_rating_submitted', {
|
||||
rating,
|
||||
category: result.category,
|
||||
...(reason && { reason }),
|
||||
chatId: state.activeChatId,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to rate message:', error)
|
||||
@@ -255,7 +249,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
})
|
||||
}
|
||||
},
|
||||
[chatMessages, project?.ref, selectedOrganization?.slug, rateMessage, sendEvent, state]
|
||||
[chatMessages, project?.ref, selectedOrganization?.slug, rateMessage, track, state]
|
||||
)
|
||||
|
||||
const isContextExceededError =
|
||||
@@ -333,21 +327,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
setValue('')
|
||||
|
||||
if (finalContent.includes('Help me to debug')) {
|
||||
sendEvent({
|
||||
action: 'assistant_debug_submitted',
|
||||
groups: {
|
||||
project: ref ?? 'Unknown',
|
||||
organization: selectedOrganization?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
track('assistant_debug_submitted', { chatId: snap.activeChatId })
|
||||
} else {
|
||||
sendEvent({
|
||||
action: 'assistant_prompt_submitted',
|
||||
groups: {
|
||||
project: ref ?? 'Unknown',
|
||||
organization: selectedOrganization?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
track('assistant_prompt_submitted', { chatId: snap.activeChatId })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import {
|
||||
convertResultsToCSV,
|
||||
convertResultsToJSON,
|
||||
convertResultsToMarkdown,
|
||||
} from 'components/interfaces/SQLEditor/UtilityPanel/Results.utils'
|
||||
import saveAs from 'file-saver'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useHotKey } from 'hooks/ui/useHotKey'
|
||||
import { ChevronDown, Copy, Download, Settings } from 'lucide-react'
|
||||
import { markdownTable } from 'markdown-table'
|
||||
import Papa from 'papaparse'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'common'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
|
||||
import {
|
||||
Button,
|
||||
copyToClipboard,
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
KeyboardShortcut,
|
||||
} from 'ui'
|
||||
|
||||
interface DownloadResultsButtonProps {
|
||||
@@ -44,25 +48,19 @@ export const DownloadResultsButton = ({
|
||||
const { ref } = useParams()
|
||||
const pathname = usePathname()
|
||||
const isLogs = pathname?.includes?.('/logs') ?? false
|
||||
// [Joshen] Ensure JSON values are stringified for CSV and Markdown
|
||||
const formattedResults = results.map((row) => {
|
||||
const r = { ...row }
|
||||
Object.keys(row).forEach((x) => {
|
||||
if (typeof row[x] === 'object') r[x] = JSON.stringify(row[x])
|
||||
})
|
||||
return r
|
||||
})
|
||||
const [copyMarkdownEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COPY_MARKDOWN, true)
|
||||
const [copyJsonEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COPY_JSON, true)
|
||||
const [downloadCsvEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_DOWNLOAD_CSV, true)
|
||||
|
||||
const headers = useMemo(() => {
|
||||
if (results) {
|
||||
const firstRow = Array.from(results)[0]
|
||||
if (firstRow) return Object.keys(firstRow)
|
||||
}
|
||||
return undefined
|
||||
}, [results])
|
||||
const isEmpty = useMemo(() => results.length === 0, [results])
|
||||
|
||||
const downloadAsCSV = () => {
|
||||
const csv = Papa.unparse(formattedResults, { columns: headers })
|
||||
const csv = convertResultsToCSV(results)
|
||||
if (!csv) {
|
||||
toast('Results are empty')
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
saveAs(blob, `${fileName}.csv`)
|
||||
toast.success('Downloading results as CSV')
|
||||
@@ -70,35 +68,56 @@ export const DownloadResultsButton = ({
|
||||
}
|
||||
|
||||
const copyAsMarkdown = () => {
|
||||
if (navigator) {
|
||||
if (formattedResults.length == 0) toast('Results are empty')
|
||||
|
||||
const columns = Object.keys(formattedResults[0])
|
||||
const rows = formattedResults.map((x) => {
|
||||
let temp: any[] = []
|
||||
columns.forEach((col) => temp.push(x[col]))
|
||||
return temp
|
||||
})
|
||||
const table = [columns].concat(rows)
|
||||
const markdownData = markdownTable(table)
|
||||
|
||||
copyToClipboard(markdownData, () => {
|
||||
toast.success('Copied results to clipboard')
|
||||
onCopyAsMarkdown?.()
|
||||
})
|
||||
const markdownData = convertResultsToMarkdown(results)
|
||||
if (!markdownData) {
|
||||
toast('Results are empty')
|
||||
return
|
||||
}
|
||||
copyToClipboard(markdownData, () => {
|
||||
toast.success('Copied markdown to clipboard')
|
||||
onCopyAsMarkdown?.()
|
||||
})
|
||||
}
|
||||
|
||||
const copyAsJSON = () => {
|
||||
if (navigator) {
|
||||
if (results.length === 0) return toast('Results are empty')
|
||||
copyToClipboard(JSON.stringify(results, null, 2), () => {
|
||||
toast.success('Copied results to clipboard')
|
||||
onCopyAsJSON?.()
|
||||
})
|
||||
const jsonData = convertResultsToJSON(results)
|
||||
if (!jsonData) {
|
||||
toast('Results are empty')
|
||||
return
|
||||
}
|
||||
copyToClipboard(jsonData, () => {
|
||||
toast.success('Copied JSON to clipboard')
|
||||
onCopyAsJSON?.()
|
||||
})
|
||||
}
|
||||
|
||||
useHotKey(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
copyAsMarkdown()
|
||||
},
|
||||
'm',
|
||||
{ enabled: copyMarkdownEnabled ?? isEmpty, shift: true }
|
||||
)
|
||||
|
||||
useHotKey(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
copyAsJSON()
|
||||
},
|
||||
'j',
|
||||
{ enabled: copyJsonEnabled ?? isEmpty, shift: true }
|
||||
)
|
||||
|
||||
useHotKey(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
downloadAsCSV()
|
||||
},
|
||||
'd',
|
||||
{ enabled: downloadCsvEnabled ?? isEmpty, shift: true }
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -112,7 +131,7 @@ export const DownloadResultsButton = ({
|
||||
{!iconOnly && text}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} className="w-44">
|
||||
<DropdownMenuContent align={align} className="w-60">
|
||||
{isLogs && IS_PLATFORM && (
|
||||
<DropdownMenuItem asChild className="gap-x-2">
|
||||
<Link href={`/project/${ref}/settings/log-drains`}>
|
||||
@@ -124,14 +143,23 @@ export const DownloadResultsButton = ({
|
||||
<DropdownMenuItem onClick={copyAsMarkdown} className="gap-x-2">
|
||||
<Copy size={14} />
|
||||
<p>Copy as markdown</p>
|
||||
<span className="ml-auto">
|
||||
<KeyboardShortcut keys={['Shift', 'Meta', 'm']} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyAsJSON} className="gap-x-2">
|
||||
<Copy size={14} />
|
||||
<p>Copy as JSON</p>
|
||||
<span className="ml-auto">
|
||||
<KeyboardShortcut keys={['Shift', 'Meta', 'j']} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => downloadAsCSV()}>
|
||||
<Download size={14} />
|
||||
<p>Download CSV</p>
|
||||
<span className="ml-auto">
|
||||
<KeyboardShortcut keys={['Shift', 'Meta', 'd']} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -27,12 +27,10 @@ export async function getOAuthCustomProviders({
|
||||
|
||||
if (error) {
|
||||
let newError = error
|
||||
console.log(newError)
|
||||
if (
|
||||
newError.message.startsWith(
|
||||
'AuthUnknownError: JSON.parse: unexpected non-whitespace character'
|
||||
)
|
||||
) {
|
||||
// Non-JSON responses from the API indicate custom providers aren't enabled.
|
||||
// Different browsers/SDK versions produce different JSON parse error messages,
|
||||
// so we check broadly for JSON parse indicators.
|
||||
if (/JSON\.parse|Unexpected token|unexpected.*character/.test(newError.message)) {
|
||||
newError = new AuthError('Custom providers are not enabled for this project')
|
||||
}
|
||||
handleError(newError)
|
||||
|
||||
@@ -37,8 +37,5 @@ export const incidentBannerQueryOptions = () =>
|
||||
// long retry delays.
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 4 ** attemptIndex, 1000 * 60 * 5),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
// temporarily disable incident status queries until we have the capacity to handle potential increased load
|
||||
// from retries.
|
||||
enabled: false,
|
||||
// enabled: IS_PLATFORM,
|
||||
enabled: IS_PLATFORM,
|
||||
})
|
||||
@@ -12,6 +12,17 @@ import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { createParser, useQueryState } from 'nuqs'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
export const getIntervalGranularity = (from: string, to: string): AnalyticsInterval => {
|
||||
const diffInDays = dayjs(to).diff(from, 'day', true)
|
||||
const diffInHours = dayjs(to).diff(from, 'hour', true)
|
||||
|
||||
if (diffInHours <= 1) return '1m'
|
||||
if (diffInHours <= 12) return '2m'
|
||||
if (diffInHours <= 24) return '10m'
|
||||
if (diffInDays <= 7) return '30m'
|
||||
return '1d'
|
||||
}
|
||||
|
||||
export const DATERANGE_LIMITS: { [key: string]: number } = {
|
||||
free: 1,
|
||||
pro: 7,
|
||||
@@ -186,23 +197,12 @@ export const useReportDateRange = (
|
||||
return getDefaultHelper().helper.text
|
||||
}, [timestampStartValue, timestampEndValue, helperTextValue, getDefaultHelper])
|
||||
|
||||
const handleIntervalGranularity = (from: string, to: string): AnalyticsInterval => {
|
||||
const diffInDays = dayjs(to).diff(from, 'day', true)
|
||||
const diffInHours = dayjs(to).diff(from, 'hour', true)
|
||||
|
||||
if (diffInHours <= 1) return '1m'
|
||||
if (diffInHours <= 12) return '2m'
|
||||
if (diffInHours <= 24) return '10m'
|
||||
if (diffInDays <= 7) return '1h'
|
||||
return '1d'
|
||||
}
|
||||
|
||||
// Derive selectedDateRange from current values
|
||||
const selectedDateRange: ReportDateRange = useMemo(
|
||||
() => ({
|
||||
period_start: { date: timestampStart, time_period: '1d' },
|
||||
period_end: { date: timestampEnd, time_period: 'today' },
|
||||
interval: handleIntervalGranularity(timestampStart, timestampEnd),
|
||||
interval: getIntervalGranularity(timestampStart, timestampEnd),
|
||||
}),
|
||||
[timestampStart, timestampEnd]
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLatest } from 'react-use'
|
||||
function useHotKey(
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
key: string,
|
||||
options?: { enabled?: boolean }
|
||||
options?: { enabled?: boolean; shift?: boolean }
|
||||
): void
|
||||
/**
|
||||
* @deprecated The `dependencies` parameter is deprecated. Use the overload without dependencies instead.
|
||||
@@ -14,27 +14,34 @@ function useHotKey(
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
key: string,
|
||||
dependencies: unknown[],
|
||||
options?: { enabled?: boolean }
|
||||
options?: { enabled?: boolean; shift?: boolean }
|
||||
): void
|
||||
function useHotKey(
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
key: string,
|
||||
dependenciesOrOptions?: unknown[] | { enabled?: boolean },
|
||||
options?: { enabled?: boolean }
|
||||
dependenciesOrOptions?: unknown[] | { enabled?: boolean; shift?: boolean },
|
||||
options?: { enabled?: boolean; shift?: boolean }
|
||||
): void {
|
||||
// Determine which overload was called
|
||||
const isDepsArray = Array.isArray(dependenciesOrOptions)
|
||||
const resolvedOptions = isDepsArray ? options : dependenciesOrOptions
|
||||
const enabled = resolvedOptions?.enabled ?? true
|
||||
const shift = resolvedOptions?.shift ?? false
|
||||
|
||||
const enabledRef = useLatest(enabled)
|
||||
const callbackRef = useLatest(callback)
|
||||
const keyRef = useLatest(key)
|
||||
const shiftRef = useLatest(shift)
|
||||
|
||||
useEffect(() => {
|
||||
function handler(e: KeyboardEvent) {
|
||||
if (!enabledRef.current) return
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === keyRef.current && !e.altKey && !e.shiftKey) {
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.key.toLowerCase() === keyRef.current.toLowerCase() &&
|
||||
!e.altKey &&
|
||||
(shiftRef.current ? e.shiftKey : !e.shiftKey)
|
||||
) {
|
||||
callbackRef.current(e)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +50,7 @@ function useHotKey(
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler, true)
|
||||
}
|
||||
}, [callbackRef, enabledRef, keyRef])
|
||||
}, [callbackRef, enabledRef, keyRef, shiftRef])
|
||||
}
|
||||
|
||||
export { useHotKey }
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useStaticEffectEvent } from '../useStaticEffectEvent'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
|
||||
interface UsePreventNavigationOnUnsavedChangesOptions {
|
||||
/*
|
||||
* Boolean indicating whether there are changes that would be lost if users navigate to another
|
||||
* page or close the browser tab
|
||||
*/
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
interface UsePreventNavigationOnUnsavedChangesReturn {
|
||||
/*
|
||||
* Cancel the navigation and keep the changes
|
||||
*/
|
||||
handleCancel: () => void
|
||||
/*
|
||||
* Confirm the navigation and lose the changes
|
||||
*/
|
||||
handleConfirm: () => void
|
||||
/*
|
||||
* Boolean indicating whether UI to request users confirmation for the navigation should be
|
||||
* displayed
|
||||
*/
|
||||
shouldConfirm: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
* Hook that prevents navigation when users could lose their changes.
|
||||
* It prevents both NextJS and browser navigation (such as when closing the tab)
|
||||
*/
|
||||
export const usePreventNavigationOnUnsavedChanges = ({
|
||||
hasChanges,
|
||||
}: UsePreventNavigationOnUnsavedChangesOptions): UsePreventNavigationOnUnsavedChangesReturn => {
|
||||
const router = useRouter()
|
||||
const [navigateUrl, setNavigateUrl] = useState<string>()
|
||||
const [confirmNavigate, setConfirmNavigate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // deprecated, but older browsers still require this
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowseAway = (url: string) => {
|
||||
if (hasChanges && !confirmNavigate) {
|
||||
setNavigateUrl(url)
|
||||
throw 'Route change declined' // Just to prevent the route change
|
||||
return
|
||||
}
|
||||
setNavigateUrl(undefined)
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.on('routeChangeStart', handleBrowseAway)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.off('routeChangeStart', handleBrowseAway)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [confirmNavigate, hasChanges])
|
||||
|
||||
const handleCancel = useStaticEffectEvent(() => {
|
||||
setNavigateUrl(undefined)
|
||||
})
|
||||
|
||||
const handleConfirm = useStaticEffectEvent(() => {
|
||||
setConfirmNavigate(true)
|
||||
let urlToNavigate = navigateUrl ?? '/'
|
||||
if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) {
|
||||
urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/'
|
||||
}
|
||||
if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}`
|
||||
setNavigateUrl(undefined)
|
||||
router.push(urlToNavigate)
|
||||
})
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
handleCancel,
|
||||
handleConfirm,
|
||||
shouldConfirm: !!navigateUrl,
|
||||
}),
|
||||
[navigateUrl, handleCancel, handleConfirm]
|
||||
)
|
||||
}
|
||||
@@ -102,13 +102,15 @@ Sentry.init({
|
||||
const thirdPartyErrorFilterIntegration = (Sentry as any).thirdPartyErrorFilterIntegration
|
||||
if (!thirdPartyErrorFilterIntegration) return []
|
||||
|
||||
// Drop errors whose stack trace only contains third-party frames (browser extensions,
|
||||
// Tag errors whose stack trace only contains third-party frames (browser extensions,
|
||||
// injected scripts, etc.). This uses build-time code annotation via the applicationKey
|
||||
// in next.config.js to reliably distinguish our code from third-party code.
|
||||
// We use 'apply-tag' instead of 'drop' so that beforeSend can exempt error boundary
|
||||
// crashes — these may originate in third-party code but are caused by first-party bugs.
|
||||
return [
|
||||
thirdPartyErrorFilterIntegration({
|
||||
filterKeys: ['supabase-studio'],
|
||||
behaviour: 'drop-error-if-exclusively-contains-third-party-frames',
|
||||
behaviour: 'apply-tag-if-exclusively-contains-third-party-frames',
|
||||
}),
|
||||
]
|
||||
})(),
|
||||
@@ -146,6 +148,19 @@ Sentry.init({
|
||||
return null
|
||||
}
|
||||
|
||||
const isErrorBoundaryCrash =
|
||||
event.tags?.globalErrorBoundary === true || event.tags?.globalErrorBoundary === 'true'
|
||||
const isThirdPartyOnly =
|
||||
event.tags?.third_party_code === true || event.tags?.third_party_code === 'true'
|
||||
|
||||
// Drop third-party-only errors UNLESS they crashed the page via the global error boundary.
|
||||
// This preserves noise reduction for browser extensions and injected scripts,
|
||||
// while ensuring page-crashing errors from third-party libs (caused by first-party bugs)
|
||||
// are always reported.
|
||||
if (isThirdPartyOnly && !isErrorBoundaryCrash) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Downsample only known high-noise classes; keep all other errors at full rate.
|
||||
const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes(
|
||||
`Failed to construct 'URL': Invalid URL`
|
||||
@@ -173,9 +188,14 @@ Sentry.init({
|
||||
return null
|
||||
}
|
||||
|
||||
// Drop events where every exception has no stack trace — these are not debuggable
|
||||
// Drop events where every exception has no stack trace — these are not debuggable.
|
||||
// Exempt error boundary crashes: even without stack frames, a page crash is always worth reporting.
|
||||
const exceptions = event.exception?.values ?? []
|
||||
if (exceptions.length > 0 && exceptions.every((ex) => !ex.stacktrace?.frames?.length)) {
|
||||
if (
|
||||
!isErrorBoundaryCrash &&
|
||||
exceptions.length > 0 &&
|
||||
exceptions.every((ex) => !ex.stacktrace?.frames?.length)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ export async function generateAssistantResponse({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...convertToModelMessages(messages),
|
||||
...(await convertToModelMessages(messages)),
|
||||
]
|
||||
|
||||
const streamTextFn = shouldTrace ? tracedStreamText : ai.streamText
|
||||
|
||||
@@ -55,6 +55,7 @@ export const PROVIDERS: ProviderRegistry = {
|
||||
providerOptions: {
|
||||
openai: {
|
||||
reasoningEffort: 'minimal',
|
||||
store: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSupabaseMcpServer } from '@supabase/mcp-server-supabase'
|
||||
import { createSupabaseApiPlatform } from '@supabase/mcp-server-supabase/platform/api'
|
||||
import { StreamTransport } from '@supabase/mcp-utils'
|
||||
import { experimental_createMCPClient as createMCPClient } from 'ai'
|
||||
import { createMCPClient } from '@ai-sdk/mcp'
|
||||
|
||||
import { API_URL } from 'lib/constants'
|
||||
|
||||
|
||||
@@ -57,11 +57,12 @@ test('rate calls the tool sanitizer', async () => {
|
||||
}))
|
||||
|
||||
vi.mock('ai', () => ({
|
||||
generateObject: vi.fn().mockResolvedValue({
|
||||
object: {
|
||||
generateText: vi.fn().mockResolvedValue({
|
||||
output: {
|
||||
category: 'sql_generation',
|
||||
},
|
||||
}),
|
||||
Output: { object: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('components/ui/AIAssistantPanel/Message.utils', () => ({
|
||||
|
||||
@@ -33,6 +33,9 @@ const nextConfig = {
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH,
|
||||
assetPrefix: getAssetPrefix(),
|
||||
output: 'standalone',
|
||||
experimental: {
|
||||
clientRouterFilter: false,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -28,11 +28,12 @@
|
||||
"scorers:deploy": "IS_BRAINTRUST_PUSH=true braintrust push evals/scorer-online.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.0",
|
||||
"@ai-sdk/openai": "2.0.32",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.0",
|
||||
"@ai-sdk/react": "2.0.52",
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.81",
|
||||
"@ai-sdk/mcp": "^1.0.25",
|
||||
"@ai-sdk/openai": "^3.0.41",
|
||||
"@ai-sdk/provider": "^3.0.8",
|
||||
"@ai-sdk/provider-utils": "^4.0.19",
|
||||
"@ai-sdk/react": "^3.0.118",
|
||||
"@aws-sdk/credential-providers": "^3.830.0",
|
||||
"@dagrejs/dagre": "^1.0.4",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
@@ -74,7 +75,7 @@
|
||||
"@vercel/functions": "^2.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@zip.js/zip.js": "^2.7.29",
|
||||
"ai": "5.0.52",
|
||||
"ai": "^6.0.116",
|
||||
"ai-commands": "workspace:*",
|
||||
"awesome-debounce-promise": "^2.1.0",
|
||||
"common": "workspace:*",
|
||||
@@ -106,7 +107,7 @@
|
||||
"next": "~16.1.7",
|
||||
"next-themes": "^0.3.0",
|
||||
"nuqs": "2.7.1",
|
||||
"openai": "^4.75.1",
|
||||
"openai": "^4.104.0",
|
||||
"openapi-fetch": "0.12.4",
|
||||
"papaparse": "^5.3.1",
|
||||
"path-to-regexp": "^8.0.0",
|
||||
@@ -185,7 +186,7 @@
|
||||
"@vitest/ui": "^3.2.0",
|
||||
"api-types": "workspace:*",
|
||||
"autoevals": "^0.0.132",
|
||||
"braintrust": "^3.0.0",
|
||||
"braintrust": "^3.4.0",
|
||||
"common": "workspace:*",
|
||||
"config": "workspace:*",
|
||||
"date-fns": "^2.30.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateObject } from 'ai'
|
||||
import { generateText, Output } from 'ai'
|
||||
import { getModel } from 'lib/ai/model'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
@@ -41,11 +41,13 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(500).json({ error: modelError.message })
|
||||
}
|
||||
|
||||
const { object } = await generateObject({
|
||||
const { output } = await generateText({
|
||||
model,
|
||||
providerOptions,
|
||||
schema: z.object({
|
||||
feedback_category: z.enum(['support', 'feedback', 'unknown']),
|
||||
output: Output.object({
|
||||
schema: z.object({
|
||||
feedback_category: z.enum(['support', 'feedback', 'unknown']),
|
||||
}),
|
||||
}),
|
||||
temperature: 0,
|
||||
prompt: `
|
||||
@@ -97,7 +99,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
`,
|
||||
})
|
||||
|
||||
return res.json({ feedback_category: object.feedback_category })
|
||||
return res.json({ feedback_category: output.feedback_category })
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(`Classifying this feedback failed`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateObject } from 'ai'
|
||||
import { generateText, Output } from 'ai'
|
||||
import { currentLogger } from 'braintrust'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { rateMessageResponseSchema } from 'components/ui/AIAssistantPanel/Message.utils'
|
||||
@@ -110,10 +110,10 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(500).json({ error: modelError.message })
|
||||
}
|
||||
|
||||
const { object } = await generateObject({
|
||||
const { output } = await generateText({
|
||||
model,
|
||||
providerOptions,
|
||||
schema: rateMessageResponseSchema,
|
||||
output: Output.object({ schema: rateMessageResponseSchema }),
|
||||
prompt: `
|
||||
Your job is to look at a Supabase Assistant conversation, which the user has given feedback on, and classify it.
|
||||
|
||||
@@ -148,7 +148,7 @@ Instructions:
|
||||
})
|
||||
logger?.updateSpan({
|
||||
id: spanId,
|
||||
metadata: { feedbackCategory: object.category },
|
||||
metadata: { feedbackCategory: output.category },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to log feedback to Braintrust:', error)
|
||||
@@ -156,7 +156,7 @@ Instructions:
|
||||
}
|
||||
|
||||
return res.json({
|
||||
category: object.category,
|
||||
category: output.category,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -102,5 +102,5 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
tools: getTools(),
|
||||
})
|
||||
|
||||
result.pipeUIMessageStreamToResponse(res)
|
||||
result.pipeUIMessageStreamToResponse(res, { headers: { 'Content-Encoding': 'none' } })
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateObject } from 'ai'
|
||||
import { generateText, Output } from 'ai'
|
||||
import { source } from 'common-tags'
|
||||
import { getModel } from 'lib/ai/model'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
@@ -46,10 +46,10 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(500).json({ error: modelError.message })
|
||||
}
|
||||
|
||||
const result = await generateObject({
|
||||
const result = await generateText({
|
||||
model,
|
||||
providerOptions,
|
||||
schema: cronSchema,
|
||||
output: Output.object({ schema: cronSchema }),
|
||||
prompt: source`
|
||||
You are a cron syntax expert. Your purpose is to convert natural language time descriptions into valid cron expressions for pg_cron.
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
`,
|
||||
})
|
||||
|
||||
return res.json(result.object.cron_expression)
|
||||
return res.json(result.output.cron_expression)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(`AI cron generation failed: ${error.message}`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateObject } from 'ai'
|
||||
import { generateText, Output } from 'ai'
|
||||
import { source } from 'common-tags'
|
||||
import { getModel } from 'lib/ai/model'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
@@ -62,10 +62,10 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
options: property.options,
|
||||
}))
|
||||
|
||||
const result = await generateObject({
|
||||
const result = await generateText({
|
||||
model,
|
||||
providerOptions,
|
||||
schema: filterGroupSchema,
|
||||
output: Output.object({ schema: filterGroupSchema }),
|
||||
prompt: source`
|
||||
You are an expert Postgres filter builder. Convert the user's request into structured filters.
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
`,
|
||||
})
|
||||
|
||||
const generatedFilters = result.object
|
||||
const generatedFilters = result.output
|
||||
|
||||
if (!validateFilterGroup(generatedFilters, normalizedFilterProperties)) {
|
||||
return res.status(400).json({
|
||||
|
||||
@@ -206,6 +206,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
|
||||
|
||||
result.pipeUIMessageStreamToResponse(res, {
|
||||
sendReasoning: true,
|
||||
headers: { 'Content-Encoding': 'none' },
|
||||
onError: (error) => {
|
||||
if (error == null) {
|
||||
return 'unknown error'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateObject } from 'ai'
|
||||
import { generateText, Output } from 'ai'
|
||||
import { source } from 'common-tags'
|
||||
import { getModel } from 'lib/ai/model'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
@@ -51,10 +51,10 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(500).json({ error: modelError.message })
|
||||
}
|
||||
|
||||
const result = await generateObject({
|
||||
const result = await generateText({
|
||||
model,
|
||||
providerOptions,
|
||||
schema: titleSchema,
|
||||
output: Output.object({ schema: titleSchema }),
|
||||
prompt: source`
|
||||
Generate a short title and summarized description for this Postgres SQL snippet:
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
`,
|
||||
})
|
||||
|
||||
return res.json(result.object)
|
||||
return res.json(result.output)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(`AI title generation failed: ${error.message}`)
|
||||
|
||||
@@ -46,10 +46,10 @@ import { useCustomContent } from 'hooks/custom-content/useCustomContent'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useDataApiGrantTogglesEnabled } from 'hooks/misc/useDataApiGrantTogglesEnabled'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { usePHFlag } from 'hooks/ui/useFlag'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import { usePHFlag } from 'hooks/ui/useFlag'
|
||||
import { DOCS_URL, PROJECT_STATUS, PROVIDERS, useDefaultProvider } from 'lib/constants'
|
||||
import { buildStudioPageTitle } from 'lib/page-title'
|
||||
import { useProfile } from 'lib/profile'
|
||||
@@ -339,7 +339,9 @@ const Wizard: NextPageWithLayout = () => {
|
||||
dbSql:
|
||||
[
|
||||
enableRlsEventTrigger && AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL,
|
||||
isDataApiGrantTogglesEnabled && buildDefaultPrivilegesSql('revoke'),
|
||||
// [Alaister]: temporarily disable the default secure sql
|
||||
// To re-enable, remove the false &&
|
||||
false && isDataApiGrantTogglesEnabled && buildDefaultPrivilegesSql('revoke'),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n') || undefined,
|
||||
|
||||
@@ -128,6 +128,11 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<EdgeFunctionsSortDropdown value={sort} onChange={setSortQueryParam} />
|
||||
</div>
|
||||
<span className="border-l border-default pl-2 text-xs text-foreground-light">
|
||||
{search && filteredFunctions.length !== functions.length
|
||||
? `Viewing ${filteredFunctions.length} of ${functions.length} functions in total`
|
||||
: `Viewing ${functions.length} ${functions.length === 1 ? 'function' : 'functions'} in total`}
|
||||
</span>
|
||||
</div>
|
||||
<Card>
|
||||
<Table>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PostgresColumn } from '@supabase/postgres-meta'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import type { PostgresColumn } from '@supabase/postgres-meta'
|
||||
import { useConstant } from 'common'
|
||||
import type { SupaRow } from 'components/grid/types'
|
||||
import {
|
||||
@@ -308,6 +308,16 @@ export const createTableEditorState = () => {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Undo the latest operation from the queue
|
||||
*/
|
||||
undoLatestOperation: () => {
|
||||
state.operationQueue.operations = state.operationQueue.operations.slice(0, -1)
|
||||
if (state.operationQueue.operations.length === 0) {
|
||||
state.operationQueue.status = 'idle'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the queue status
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { sanitizeTaxIdValue } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils'
|
||||
import { TAX_IDS } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants'
|
||||
import {
|
||||
getEffectiveTaxCountry,
|
||||
resolveStoredTaxId,
|
||||
sanitizeTaxIdValue,
|
||||
} from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
|
||||
/**
|
||||
@@ -48,3 +53,37 @@ describe('TaxID utils: sanitizeTaxID', () => {
|
||||
expect(sanitizedID).toBe('12-3456789')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaxID utils: getEffectiveTaxCountry', () => {
|
||||
test('returns countryIso2 when no override is set', () => {
|
||||
const ukVat = TAX_IDS.find((t) => t.name === 'UK VAT')!
|
||||
expect(getEffectiveTaxCountry(ukVat)).toBe('GB')
|
||||
})
|
||||
|
||||
test('returns taxCountryIso2 when override is set', () => {
|
||||
const imVat = TAX_IDS.find((t) => t.name === 'IM VAT')!
|
||||
expect(getEffectiveTaxCountry(imVat)).toBe('GB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaxID utils: resolveStoredTaxId', () => {
|
||||
test('resolves GB customer to UK VAT with billingCountry', () => {
|
||||
const ukVat = TAX_IDS.find((t) => t.name === 'UK VAT')!
|
||||
expect(resolveStoredTaxId('gb_vat', 'GB', 'GB')).toBe(ukVat)
|
||||
})
|
||||
|
||||
test('resolves IM customer to IM VAT with billingCountry', () => {
|
||||
const imVat = TAX_IDS.find((t) => t.name === 'IM VAT')!
|
||||
expect(resolveStoredTaxId('gb_vat', 'GB', 'IM')).toBe(imVat)
|
||||
})
|
||||
|
||||
test('resolves GB customer to UK VAT without billingCountry', () => {
|
||||
// Without billingCountry, falls back to countryIso2 match — UK VAT has countryIso2 'GB'
|
||||
const ukVat = TAX_IDS.find((t) => t.name === 'UK VAT')!
|
||||
expect(resolveStoredTaxId('gb_vat', 'GB')).toBe(ukVat)
|
||||
})
|
||||
|
||||
test('returns undefined for unknown type', () => {
|
||||
expect(resolveStoredTaxId('unknown_type', 'XX')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: 'Supabase joins the Stripe Projects developer preview'
|
||||
description: 'Supabase is a co-design partner in the Stripe Projects developer preview.'
|
||||
author: gregor_vand, ana_mogul
|
||||
date: '2026-03-19'
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
- stripe
|
||||
- integrations
|
||||
- developer-tools
|
||||
imgSocial: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=partnerships&layout=icon-text©=Stripe+Projects%0A%5BDeveloper+Preview%5D&icon=supabase.svg&icon2=stripe.svg'
|
||||
imgThumb: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=partnerships&layout=icon-only©=Stripe+Projects%0A%5BDeveloper+Preview%5D&icon=supabase.svg&icon2=stripe.svg'
|
||||
toc_depth: 2
|
||||
---
|
||||
|
||||
Supabase is part of the [Stripe Projects](https://projects.dev/) developer preview. Stripe Projects is a new workflow in the Stripe CLI that lets developers and AI agents provision real services and get working credentials from a single command. With Supabase in the catalog, one command provisions a Postgres database along with the rest of Supabase (Auth, Storage, Edge Functions, and Realtime) without ever opening a dashboard.
|
||||
|
||||
AI Agents need a fast way to provision infrastructure, with deterministic steps and real credentials. They cannot click through a browser or guess at setup docs.
|
||||
|
||||
Stripe Projects offers a standard provisioning workflow for AI Agents and humans. One command handles account setup, resource provisioning, and credential delivery. Credentials land in your environment in a format that both humans and agents can use. Your Supabase account and dashboard stay yours and are always accessible.
|
||||
|
||||
We worked closely with Stripe as a co-design partner on the integration spec. That means provisioning a Supabase project through Stripe Projects is deterministic and repeatable. Whether you run it yourself or an agent runs it for you, the steps are the same.
|
||||
|
||||
## How it works
|
||||
|
||||
Install the Stripe CLI, then run:
|
||||
|
||||
```bash
|
||||
stripe plugin install projects
|
||||
stripe projects init my-app
|
||||
stripe projects add supabase/supabase:free
|
||||
stripe projects env --sync
|
||||
```
|
||||
|
||||
That's it. A new Supabase project is created in your account with a full Postgres database ready to connect. To open the Supabase dashboard directly:
|
||||
|
||||
```bash
|
||||
stripe projects open supabase
|
||||
```
|
||||
|
||||
If you already have a Supabase account, the flow will prompt you to link it instead of creating a new one.
|
||||
|
||||
## What you get
|
||||
|
||||
**A complete Supabase project**
|
||||
|
||||
Every project includes a Postgres database, Auth, Storage, Edge Functions, and Realtime. [Learn more in the docs](https://supabase.com/docs/guides/database/overview).
|
||||
|
||||
**Credentials in your environment**
|
||||
|
||||
After provisioning, `stripe projects env --sync` writes your project credentials to a local `.env` file. They are also stored in your Stripe Project so agents can access them directly.
|
||||
|
||||
**Your account, your data**
|
||||
|
||||
The database lives in your own Supabase account. You keep full access to the Supabase dashboard, your connection strings, and your data. Nothing is proxied or white-labeled.
|
||||
|
||||
**Credential rotation**
|
||||
|
||||
You can rotate your database credentials at any time:
|
||||
|
||||
```bash
|
||||
stripe projects rotate supabase-supabase:free
|
||||
```
|
||||
|
||||
This updates the stored secrets in your Stripe Project automatically.
|
||||
|
||||
## Get started
|
||||
|
||||
This is a developer preview. Install the Stripe CLI, then run:
|
||||
|
||||
```bash
|
||||
stripe projects init my-app
|
||||
stripe projects add supabase/supabase:free
|
||||
```
|
||||
|
||||
[Install the Stripe CLI](https://docs.stripe.com/stripe-cli/install)
|
||||
|
||||
[Stripe Projects docs](https://docs.stripe.com/stripe-projects)
|
||||
|
||||
We want to hear from you. If you run into issues or have feedback, [reach out on Discord](https://discord.supabase.com/) or open an issue on [GitHub](https://github.com/supabase/supabase/issues).
|
||||
|
||||
<div className="video-container mb-8">
|
||||
<iframe
|
||||
className="w-full"
|
||||
src="https://www.youtube-nocookie.com/embed/KQzDyyXn72M"
|
||||
title="Supabase joins the Stripe Projects developer preview"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
@@ -13,11 +13,9 @@ duration: 45 mins
|
||||
categories:
|
||||
- webinar
|
||||
main_cta:
|
||||
{
|
||||
url: 'https://attendee.gotowebinar.com/register/5606981989518150747',
|
||||
target: '_blank',
|
||||
label: 'Register now',
|
||||
}
|
||||
url: '#recording'
|
||||
target: _self
|
||||
label: Watch the recording
|
||||
speakers: 'seth_kramer,dave_wilson,zimo_holdijk,harry_roper,victor_orozco'
|
||||
---
|
||||
|
||||
@@ -25,6 +23,16 @@ AI coding tools have made prototyping almost instant. Agencies now send working
|
||||
|
||||
Join agency leaders and Supabase as they share how top agencies balance velocity with control. Learn when to let AI move fast, and where experienced developers still need to step in.
|
||||
|
||||
<div className="video-container mb-8" id="recording">
|
||||
<iframe
|
||||
className="w-full"
|
||||
src="https://www.youtube-nocookie.com/embed/D_Qhiyyp6_M"
|
||||
title="Ship Fast, Stay Safe: AI Prototyping That Survives Production"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
|
||||
## Key takeaways
|
||||
|
||||
- Why leading agencies design database schemas and RLS policies before touching the UI
|
||||
@@ -37,4 +45,4 @@ Join agency leaders and Supabase as they share how top agencies balance velocity
|
||||
|
||||
- How to hand projects off to clients without handing over production risk
|
||||
|
||||
Join us live to participate in the Q&A. Can't make it? We'll send you a link to the recording.
|
||||
We hope you enjoy the recording!
|
||||
@@ -40,8 +40,7 @@ const page: GoPageInput = {
|
||||
title: 'Watch the recording',
|
||||
description:
|
||||
'Join agency leaders and Supabase as they share how top agencies balance velocity with control when using AI coding tools to build production applications.',
|
||||
// TODO: Add YouTube URL when recording is available
|
||||
children: <MediaBlock />,
|
||||
children: <MediaBlock youtubeUrl="https://www.youtube.com/watch?v=D_Qhiyyp6_M" />,
|
||||
},
|
||||
{
|
||||
type: 'feature-grid',
|
||||
|
||||
@@ -889,5 +889,21 @@
|
||||
"company": "Performanze",
|
||||
"author_url": "#",
|
||||
"author_image_url": "/images/blog/avatars/victor-orozco.jpeg"
|
||||
},
|
||||
{
|
||||
"author_id": "ana_mogul",
|
||||
"author": "Ana Mogul",
|
||||
"username": "ana1337x",
|
||||
"position": "Product Marketing Manager",
|
||||
"author_url": "https://linkedin.com/in/amogul",
|
||||
"author_image_url": "/images/blog/avatars/ana-mogul.jpeg"
|
||||
},
|
||||
{
|
||||
"author_id": "gregor_vand",
|
||||
"author": "Gregor Vand",
|
||||
"username": "gregorvand",
|
||||
"position": "Technical Program Manager",
|
||||
"author_url": "https://www.linkedin.com/in/gregorvand/",
|
||||
"author_image_url": "https://github.com/gregorvand.png"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { env } from '../env.config.js'
|
||||
import { expectClipboardValue } from '../utils/clipboard.js'
|
||||
import { createTable, dropTable, query } from '../utils/db/index.js'
|
||||
import { test, withSetupCleanup } from '../utils/test.js'
|
||||
import { toUrl } from '../utils/to-url.js'
|
||||
@@ -39,13 +40,15 @@ test.describe('Database', () => {
|
||||
// copies schema definition to clipboard
|
||||
await page.getByRole('button', { name: 'Copy as SQL' }).click()
|
||||
await expect(page.getByTestId('copy-sql-ready')).toBeVisible()
|
||||
const clipboardText = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(clipboardText).toContain(`CREATE TABLE public.${databaseTableName} (
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: `CREATE TABLE public.${databaseTableName} (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
${databaseColumnName} text,
|
||||
CONSTRAINT ${databaseTableName}_pkey PRIMARY KEY (id)
|
||||
);`)
|
||||
);`,
|
||||
})
|
||||
|
||||
// downloads schema diagram when export is triggered
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
@@ -91,6 +94,7 @@ test.describe('Database', () => {
|
||||
await page.getByText(`${databaseTableName} actions`).click()
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit table' })).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Edit table' }).click({ force: true })
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit table' })).not.toBeVisible()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText('timestamptz')).toBeVisible()
|
||||
@@ -103,6 +107,7 @@ test.describe('Database', () => {
|
||||
await page.getByText(`${databaseTableName} actions`).click()
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit table' })).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit table' })).not.toBeVisible()
|
||||
await expect(page.getByRole('dialog')).toBeVisible()
|
||||
// FIXME: For some reason, the dialog is not stable and rerenders, sometimes preventing the description to be filled
|
||||
await page.waitForTimeout(500)
|
||||
@@ -113,9 +118,8 @@ test.describe('Database', () => {
|
||||
await page.getByText(`${databaseTableName} actions`).click()
|
||||
await expect(page.getByRole('menuitem', { name: 'Copy name' })).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Copy name' }).click()
|
||||
await page.waitForTimeout(500)
|
||||
const copiedTableResult = await page.evaluateHandle(() => navigator.clipboard.readText())
|
||||
expect(await copiedTableResult.jsonValue()).toBe(databaseTableName)
|
||||
await expect(page.getByRole('menuitem', { name: 'Copy name' })).not.toBeVisible()
|
||||
await expectClipboardValue({ page, value: databaseTableName, exact: true })
|
||||
|
||||
await page.getByText(`${databaseTableName} actions`).click()
|
||||
await expect(page.getByRole('menuitem', { name: 'View in Table Editor' })).toBeVisible()
|
||||
@@ -175,9 +179,7 @@ test.describe('Database', () => {
|
||||
.click({ force: true })
|
||||
await expect(page.getByRole('menuitem', { name: 'Copy name' })).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Copy name' }).click()
|
||||
await page.waitForTimeout(500)
|
||||
const copiedTableResult = await page.evaluateHandle(() => navigator.clipboard.readText())
|
||||
expect(await copiedTableResult.jsonValue()).toBe(databaseColumnName)
|
||||
await expectClipboardValue({ page, value: databaseColumnName, exact: true })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -105,12 +105,57 @@ test.describe('Queue Table Operations', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Revert', exact: true }).click()
|
||||
|
||||
const confirmDialog = page.getByRole('alertdialog')
|
||||
await expect(confirmDialog.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible()
|
||||
await confirmDialog.getByRole('button', { name: 'Discard changes' }).click()
|
||||
|
||||
await expect(page.getByRole('gridcell', { name: 'keep this value' })).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'should be cancelled' })).not.toBeVisible()
|
||||
|
||||
await expect(page.getByText('pending change')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('revert can be cancelled via discard confirmation dialog', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_discard_cancel`
|
||||
const columnName = 'name'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createTable(tableName, columnName, [{ name: 'keep this value' }])
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await enableQueueOperations(page)
|
||||
await page.reload()
|
||||
await waitForTableToLoad(page, ref)
|
||||
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
const cell = page.getByRole('gridcell', { name: 'keep this value' })
|
||||
await cell.dblclick()
|
||||
const editor = page.getByRole('textbox', { name: /Editor content/ })
|
||||
await editor.fill('edited value')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByText('1 pending change')).toBeVisible()
|
||||
await page.getByRole('button', { name: /Review/ }).click()
|
||||
|
||||
await page.getByRole('button', { name: 'Revert', exact: true }).click()
|
||||
|
||||
const confirmDialog = page.getByRole('alertdialog')
|
||||
await expect(confirmDialog.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible()
|
||||
await confirmDialog.getByRole('button', { name: 'Keep editing' }).click()
|
||||
|
||||
const sidePanel = page.getByRole('dialog')
|
||||
await expect(sidePanel.getByText('Pending changes')).toBeVisible()
|
||||
await expect(sidePanel.getByText('1 operation')).toBeVisible()
|
||||
})
|
||||
|
||||
test('row inserts are queued and can be saved', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_row_insert`
|
||||
const columnName = 'name'
|
||||
@@ -277,6 +322,7 @@ test.describe('Queue Table Operations', () => {
|
||||
await expect(page.getByRole('dialog').getByText('Pending changes')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+.')
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /Review/ })).toBeVisible()
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+s')
|
||||
@@ -285,6 +331,164 @@ test.describe('Queue Table Operations', () => {
|
||||
await expect(page.getByRole('gridcell', { name: 'shortcut test' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('undo reverts the latest operation from the queue', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_undo`
|
||||
const columnName = 'name'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createTable(tableName, columnName, [{ name: 'existing row' }])
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await enableQueueOperations(page)
|
||||
await page.reload()
|
||||
await waitForTableToLoad(page, ref)
|
||||
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await expect(page.getByRole('gridcell', { name: 'existing row' })).toBeVisible()
|
||||
|
||||
await page.getByTestId('table-editor-insert-new-row').click()
|
||||
await page.getByRole('menuitem', { name: 'Insert row Insert a new row' }).click()
|
||||
await page.getByTestId(`${columnName}-input`).fill('undo this row')
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
await expect(page.getByText('1 pending change')).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'undo this row' })).toBeVisible()
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+z')
|
||||
|
||||
await expect(page.getByText('pending change')).not.toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'undo this row' })).not.toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'existing row' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('undo reverts operations one at a time', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_undo_multi`
|
||||
const columnName = 'name'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createTable(tableName, columnName)
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await enableQueueOperations(page)
|
||||
await page.reload()
|
||||
await waitForTableToLoad(page, ref)
|
||||
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await page.getByTestId('table-editor-insert-new-row').click()
|
||||
await page.getByRole('menuitem', { name: 'Insert row Insert a new row' }).click()
|
||||
await page.getByTestId(`${columnName}-input`).fill('first row')
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
await page.getByTestId('table-editor-insert-new-row').click()
|
||||
await page.getByRole('menuitem', { name: 'Insert row Insert a new row' }).click()
|
||||
await page.getByTestId(`${columnName}-input`).fill('second row')
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
await expect(page.getByText('2 pending changes')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+z')
|
||||
|
||||
await expect(page.getByText('1 pending change')).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'first row' })).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'second row' })).not.toBeVisible()
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+z')
|
||||
|
||||
await expect(page.getByText('pending change')).not.toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'first row' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('undo works for cell edits', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_undo_edit`
|
||||
const columnName = 'name'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createTable(tableName, columnName, [{ name: 'original value' }])
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await enableQueueOperations(page)
|
||||
await page.reload()
|
||||
await waitForTableToLoad(page, ref)
|
||||
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await expect(page.getByRole('gridcell', { name: 'original value' })).toBeVisible()
|
||||
|
||||
const cell = page.getByRole('gridcell', { name: 'original value' })
|
||||
await cell.dblclick()
|
||||
|
||||
const editor = page.getByRole('textbox', { name: /Editor content/ })
|
||||
await expect(editor).toBeVisible()
|
||||
await editor.fill('edited value')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByText('1 pending change')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+z')
|
||||
|
||||
await expect(page.getByText('pending change')).not.toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'original value' })).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'edited value' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('undo works for row deletes', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_undo_del`
|
||||
const columnName = 'name'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createTable(tableName, columnName, [{ name: 'row to keep' }])
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await enableQueueOperations(page)
|
||||
await page.reload()
|
||||
await waitForTableToLoad(page, ref)
|
||||
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await expect(page.getByRole('gridcell', { name: 'row to keep' })).toBeVisible()
|
||||
|
||||
const cell = page.getByRole('gridcell', { name: 'row to keep' })
|
||||
await cell.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Delete row' }).click()
|
||||
|
||||
await expect(page.getByText('1 pending change')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+z')
|
||||
|
||||
await expect(page.getByText('pending change')).not.toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'row to keep' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('row deletes via context menu are queued', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_row_delete`
|
||||
const columnName = 'name'
|
||||
@@ -359,6 +563,10 @@ test.describe('Queue Table Operations', () => {
|
||||
await page.getByRole('button', { name: /Review/ }).click()
|
||||
await page.getByRole('button', { name: 'Revert', exact: true }).click()
|
||||
|
||||
const confirmDialog = page.getByRole('alertdialog')
|
||||
await expect(confirmDialog.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible()
|
||||
await confirmDialog.getByRole('button', { name: 'Discard changes' }).click()
|
||||
|
||||
await expect(page.getByRole('gridcell', { name: 'should not be deleted' })).toBeVisible()
|
||||
await expect(page.getByText('pending change')).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { expect, Page } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import { expect, Page } from '@playwright/test'
|
||||
|
||||
import { env } from '../env.config.js'
|
||||
import { expectClipboardValue } from '../utils/clipboard.js'
|
||||
import { isCLI } from '../utils/is-cli.js'
|
||||
import { resetLocalStorage } from '../utils/reset-local-storage.js'
|
||||
import { test } from '../utils/test.js'
|
||||
@@ -189,12 +191,8 @@ test.describe('SQL Editor', () => {
|
||||
await page.getByTestId('sql-run-button').click()
|
||||
|
||||
// verify warning modal blocks execution
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Potential issue detected with' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Query will prevent connections to your database')
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Potential issue detected with' })).toBeVisible()
|
||||
await expect(page.getByText('Query will prevent connections to your database')).toBeVisible()
|
||||
expect(queryDispatched).toBe(false)
|
||||
|
||||
// cancel should dismiss without executing
|
||||
@@ -233,12 +231,8 @@ test.describe('SQL Editor', () => {
|
||||
await page.getByTestId('sql-run-button').click()
|
||||
|
||||
// verify warning modal blocks execution
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Potential issue detected with' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Query will prevent connections to your database')
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Potential issue detected with' })).toBeVisible()
|
||||
await expect(page.getByText('Query will prevent connections to your database')).toBeVisible()
|
||||
expect(queryDispatched).toBe(false)
|
||||
|
||||
// cancel should dismiss without executing
|
||||
@@ -277,9 +271,7 @@ test.describe('SQL Editor', () => {
|
||||
await page.getByTestId('sql-run-button').click()
|
||||
|
||||
// verify warning modal blocks execution
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Potential issue detected with' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Potential issue detected with' })).toBeVisible()
|
||||
await expect(page.getByText('Query uses update without a where clause')).toBeVisible()
|
||||
expect(queryDispatched).toBe(false)
|
||||
|
||||
@@ -332,27 +324,35 @@ test.describe('SQL Editor', () => {
|
||||
// export as markdown
|
||||
await page.getByRole('button', { name: 'Export' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Copy as markdown' }).click()
|
||||
await page.waitForTimeout(500)
|
||||
const copiedMarkdownResult = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedMarkdownResult).toBe(`| ?column? |
|
||||
// Make sure the dropdown has closed otherwise it would make the other assertions unstable
|
||||
await expect(page.getByRole('menuitem', { name: 'Copy as markdown' })).not.toBeVisible()
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: `| ?column? |
|
||||
| ----------- |
|
||||
| hello world |`)
|
||||
| hello world |`,
|
||||
exact: true,
|
||||
})
|
||||
|
||||
// export as JSON
|
||||
await page.getByRole('button', { name: 'Export' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Copy as JSON' }).click()
|
||||
await page.waitForTimeout(500)
|
||||
const copiedJsonResult = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedJsonResult).toBe(`[
|
||||
await expect(page.getByRole('menuitem', { name: 'Copy as JSON' })).not.toBeVisible()
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: `[
|
||||
{
|
||||
"?column?": "hello world"
|
||||
}
|
||||
]`)
|
||||
]`,
|
||||
exact: true,
|
||||
})
|
||||
|
||||
// export as CSV
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: 'Export' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Download CSV' }).click()
|
||||
await expect(page.getByRole('menuitem', { name: 'Download CSV' })).not.toBeVisible()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toContain('.csv')
|
||||
const downloadPath = await download.path()
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'path'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { env } from '../env.config.js'
|
||||
import { expectClipboardValue } from '../utils/clipboard.js'
|
||||
import {
|
||||
createBucket,
|
||||
createFolder,
|
||||
@@ -248,61 +249,55 @@ test.describe('Storage', () => {
|
||||
const folderFile = page.getByTitle(folderFileName)
|
||||
await folderFile.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Get URL' }).click()
|
||||
await expect(async () => {
|
||||
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedUrl).toContain(
|
||||
`storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`
|
||||
)
|
||||
}).toPass({ timeout: 2000 })
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: `storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`,
|
||||
})
|
||||
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
|
||||
|
||||
// Right-click on the root file to open context menu while the folder is still open
|
||||
const rootFile = page.getByTitle(rootFileName)
|
||||
await rootFile.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Get URL' }).click()
|
||||
await expect(async () => {
|
||||
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedUrl).toContain(`storage/v1/object/public/${bucketName}/${rootFileName}`)
|
||||
}).toPass({ timeout: 2000 })
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: `storage/v1/object/public/${bucketName}/${rootFileName}`,
|
||||
})
|
||||
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
|
||||
|
||||
// Click the actions button on the folder file to open dropdown menu
|
||||
await page.getByRole('button', { name: `${folderFileName} actions` }).click()
|
||||
await page.getByRole('menuitem', { name: 'Get URL' }).click()
|
||||
await expect(async () => {
|
||||
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedUrl).toContain(
|
||||
`storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`
|
||||
)
|
||||
}).toPass({ timeout: 2000 })
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: `storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`,
|
||||
})
|
||||
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
|
||||
|
||||
// Click the actions button on the root file to open dropdown menu while the folder is still open
|
||||
await page.getByRole('button', { name: `${rootFileName} actions` }).click()
|
||||
await page.getByRole('menuitem', { name: 'Get URL' }).click()
|
||||
await expect(async () => {
|
||||
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedUrl).toContain(`storage/v1/object/public/${bucketName}/${rootFileName}`)
|
||||
}).toPass({ timeout: 2000 })
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: `storage/v1/object/public/${bucketName}/${rootFileName}`,
|
||||
})
|
||||
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
|
||||
|
||||
// Click the folder file to open its preview pane
|
||||
await folderFile.click()
|
||||
await page.getByRole('button', { name: 'Get URL' }).click()
|
||||
await expect(async () => {
|
||||
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedUrl).toContain(
|
||||
`storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`
|
||||
)
|
||||
}).toPass({ timeout: 2000 })
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: `storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`,
|
||||
})
|
||||
|
||||
// Click the root file to open its preview pane while folder is still open
|
||||
await rootFile.click()
|
||||
await page.getByRole('button', { name: 'Get URL' }).click()
|
||||
await expect(async () => {
|
||||
const copiedUrl = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedUrl).toContain(`storage/v1/object/public/${bucketName}/${rootFileName}`)
|
||||
}).toPass({ timeout: 2000 })
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: `storage/v1/object/public/${bucketName}/${rootFileName}`,
|
||||
})
|
||||
})
|
||||
|
||||
test('resets folder name when renaming with empty string', async ({ page, ref }) => {
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user