From 2c651ddabc306dd0785118126d57dd3176864fe9 Mon Sep 17 00:00:00 2001 From: Sean Oliver <882952+seanoliver@users.noreply.github.com> Date: Sat, 23 May 2026 09:30:25 -0700 Subject: [PATCH 01/12] fix(experiment): make dataApiRevokeOnCreateDefault flag reads shape-agnostic (#46289) --- .../useDataApiRevokeOnCreateDefault.test.ts | 89 +++++++++++++++++++ .../misc/useDataApiRevokeOnCreateDefault.ts | 29 +++++- .../[slug]/deploy-button/new-project.tsx | 7 +- apps/studio/pages/new/[slug].tsx | 22 +++-- packages/common/telemetry-constants.ts | 18 ++-- 5 files changed, 147 insertions(+), 18 deletions(-) diff --git a/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts b/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts index e39dc7a0937..2d74b58dda2 100644 --- a/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts +++ b/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts @@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + isInDataApiRevokeTreatment, useDataApiRevokeOnCreateDefaultEnabled, useTrackDefaultPrivilegesExposure, } from '../useDataApiRevokeOnCreateDefault' @@ -25,6 +26,33 @@ vi.mock('@/lib/telemetry/track', () => ({ useTrack: vi.fn(), })) +describe('isInDataApiRevokeTreatment', () => { + it('returns true for boolean true (current rollout shape)', () => { + expect(isInDataApiRevokeTreatment(true)).toBe(true) + }) + + it("returns true for the 'test' variant (future multivariate shape)", () => { + expect(isInDataApiRevokeTreatment('test')).toBe(true) + }) + + it('returns false for boolean false', () => { + expect(isInDataApiRevokeTreatment(false)).toBe(false) + }) + + it("returns false for the 'control' variant", () => { + expect(isInDataApiRevokeTreatment('control')).toBe(false) + }) + + it('returns false for undefined (flag not resolved)', () => { + expect(isInDataApiRevokeTreatment(undefined)).toBe(false) + }) + + it('returns false for unrelated string values', () => { + expect(isInDataApiRevokeTreatment('something-else')).toBe(false) + expect(isInDataApiRevokeTreatment('')).toBe(false) + }) +}) + describe('useDataApiRevokeOnCreateDefaultEnabled', () => { afterEach(() => { vi.restoreAllMocks() @@ -49,6 +77,18 @@ describe('useDataApiRevokeOnCreateDefaultEnabled', () => { expect(result.current).toBe(true) }) + it("returns true when the PostHog flag is the 'test' variant string", () => { + vi.mocked(usePHFlag).mockReturnValue('test') + const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled()) + expect(result.current).toBe(true) + }) + + it("returns false when the PostHog flag is the 'control' variant string", () => { + vi.mocked(usePHFlag).mockReturnValue('control') + const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled()) + expect(result.current).toBe(false) + }) + it('returns false in test env regardless of flag value', () => { vi.mocked(constants, { partial: true }).IS_TEST_ENV = true vi.mocked(usePHFlag).mockReturnValue(true) @@ -252,4 +292,53 @@ describe('useTrackDefaultPrivilegesExposure', () => { undefined ) }) + + // The next two tests cover the future multivariate flag shape (GROWTH-877). + // Today the flag returns boolean true/false; post-migration it returns the + // variant string. The convergence gate must derive the expected default from + // the variant, not from `!flag` directly — `!'control'` is false (truthy + // string negation), which would have set the wrong expected default and + // either skipped or mis-fired the exposure for control-arm users. + + it("fires for the 'test' variant with the correct convergence default (treatment)", () => { + vi.mocked(usePHFlag).mockReturnValue('test') + renderHook(() => + useTrackDefaultPrivilegesExposure({ + surface: 'main', + dataApiDefaultPrivileges: false, // expected default for treatment + hasUserModified: false, + }) + ) + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith( + 'project_creation_default_privileges_exposed', + { + surface: 'main', + dataApiDefaultPrivileges: false, + dataApiRevokeOnCreateDefaultEnabled: 'test', + }, + undefined + ) + }) + + it("fires for the 'control' variant with the correct convergence default (legacy)", () => { + vi.mocked(usePHFlag).mockReturnValue('control') + renderHook(() => + useTrackDefaultPrivilegesExposure({ + surface: 'main', + dataApiDefaultPrivileges: true, // expected default for non-treatment + hasUserModified: false, + }) + ) + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith( + 'project_creation_default_privileges_exposed', + { + surface: 'main', + dataApiDefaultPrivileges: true, + dataApiRevokeOnCreateDefaultEnabled: 'control', + }, + undefined + ) + }) }) diff --git a/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts b/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts index d180a13f7c0..8ebe9174e0c 100644 --- a/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts +++ b/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts @@ -4,6 +4,26 @@ import { usePHFlag } from '../ui/useFlag' import { IS_TEST_ENV } from '@/lib/constants' import { useTrack } from '@/lib/telemetry/track' +/** + * Returns true iff the user is assigned to the treatment arm of the + * dataApiRevokeOnCreateDefault experiment. Shape-agnostic across the current + * boolean rollout config and a future multivariate experiment with named + * variants. See GROWTH-877 for the migration plan. + * + * Accepts: + * - `true` → treatment (current boolean shape) + * - `'test'` → treatment (future multivariate shape) + * - anything else (`false`, `'control'`, `null`, `undefined`) → not treatment + * + * Use this everywhere the flag's value is read so the PostHog config can + * migrate to multivariate without a coordinated frontend deploy. + */ +export const isInDataApiRevokeTreatment = (flag: boolean | string | undefined): boolean => { + if (flag === true) return true + if (flag === 'test') return true + return false +} + /** * Controls the default state of the "Automatically expose new tables" * checkbox at project creation. When the flag is on, the checkbox defaults @@ -11,7 +31,7 @@ import { useTrack } from '@/lib/telemetry/track' * to checked (current behaviour — default grants remain). */ export const useDataApiRevokeOnCreateDefaultEnabled = (): boolean => { - const flag = usePHFlag('dataApiRevokeOnCreateDefault') + const flag = usePHFlag('dataApiRevokeOnCreateDefault') // Preserve current behaviour (default grants remain) in tests so existing // E2E flows don't change silently. Tests that need the revoke-default path @@ -20,7 +40,7 @@ export const useDataApiRevokeOnCreateDefaultEnabled = (): boolean => { return false } - return !!flag + return isInDataApiRevokeTreatment(flag) } type DefaultPrivilegesExposureOptions = @@ -48,7 +68,7 @@ type DefaultPrivilegesExposureOptions = */ export const useTrackDefaultPrivilegesExposure = (options: DefaultPrivilegesExposureOptions) => { const track = useTrack() - const flag = usePHFlag('dataApiRevokeOnCreateDefault') + const flag = usePHFlag('dataApiRevokeOnCreateDefault') const hasTracked = useRef(false) const { surface, dataApiDefaultPrivileges, hasUserModified } = options @@ -59,7 +79,8 @@ export const useTrackDefaultPrivilegesExposure = (options: DefaultPrivilegesExpo if (flag === undefined) return if (surface === 'vercel' && !orgSlug) return // Gate on form-flag convergence unless the user explicitly dirtied the field. - if (!hasUserModified && dataApiDefaultPrivileges !== !flag) return + const expectedDefault = !isInDataApiRevokeTreatment(flag) + if (!hasUserModified && dataApiDefaultPrivileges !== expectedDefault) return hasTracked.current = true track( 'project_creation_default_privileges_exposed', diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index f1cdab561e3..02a69b9ee89 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -28,6 +28,7 @@ import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel- import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useProjectCreateMutation } from '@/data/projects/project-create-mutation' import { + isInDataApiRevokeTreatment, useDataApiRevokeOnCreateDefaultEnabled, useTrackDefaultPrivilegesExposure, } from '@/hooks/misc/useDataApiRevokeOnCreateDefault' @@ -82,7 +83,9 @@ const CreateProject = () => { const track = useTrack() const snapshot = useIntegrationInstallationSnapshot() const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled() - const dataApiRevokeOnCreateDefaultFlag = usePHFlag('dataApiRevokeOnCreateDefault') + const dataApiRevokeOnCreateDefaultFlag = usePHFlag( + 'dataApiRevokeOnCreateDefault' + ) const [dataApiDefaultPrivileges, setDataApiDefaultPrivileges] = useState( !isDataApiRevokeOnCreateDefault ) @@ -91,7 +94,7 @@ const CreateProject = () => { useEffect(() => { if (dataApiRevokeOnCreateDefaultFlag === undefined) return if (hasUserModifiedDataApiDefaultPrivileges.current) return - setDataApiDefaultPrivileges(!dataApiRevokeOnCreateDefaultFlag) + setDataApiDefaultPrivileges(!isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag)) }, [dataApiRevokeOnCreateDefaultFlag]) const { slug, next, currentProjectId: foreignProjectId, externalId } = useParams() diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index fc4287d2b50..266d8a0e62d 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -58,7 +58,10 @@ import { import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { useDataApiRevokeOnCreateDefaultEnabled } from '@/hooks/misc/useDataApiRevokeOnCreateDefault' +import { + isInDataApiRevokeTreatment, + useDataApiRevokeOnCreateDefaultEnabled, +} from '@/hooks/misc/useDataApiRevokeOnCreateDefault' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' @@ -108,8 +111,11 @@ const Wizard: NextPageWithLayout = () => { // Read the raw flag for telemetry — coerce-undefined-to-false would record false for // users whose flags haven't loaded yet. The raw value preserves undefined (omitted from - // PostHog) so we only record true/false when the flag is resolved. - const dataApiRevokeOnCreateDefaultFlag = usePHFlag('dataApiRevokeOnCreateDefault') + // PostHog) so we only record an actual value (boolean true/false, or a variant string + // like 'test'/'control' post-multivariate migration) once the flag has resolved. + const dataApiRevokeOnCreateDefaultFlag = usePHFlag( + 'dataApiRevokeOnCreateDefault' + ) const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled() const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '') @@ -174,9 +180,13 @@ const Wizard: NextPageWithLayout = () => { useEffect(() => { if (dataApiRevokeOnCreateDefaultFlag === undefined) return if (isDataApiDefaultPrivilegesDirty) return - setValue('dataApiDefaultPrivileges', !dataApiRevokeOnCreateDefaultFlag, { - shouldDirty: false, - }) + setValue( + 'dataApiDefaultPrivileges', + !isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag), + { + shouldDirty: false, + } + ) }, [dataApiRevokeOnCreateDefaultFlag, isDataApiDefaultPrivilegesDirty, setValue]) // [Charis] Since the form is updated in a useEffect, there is an edge case diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 23fd8cebc16..5c092e8084f 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -319,10 +319,13 @@ export interface ProjectCreationDefaultPrivilegesExposedEvent { dataApiDefaultPrivileges: boolean /** * Raw value of the dataApiRevokeOnCreateDefault PostHog flag at exposure time. - * true = revoke cohort (checkbox defaulted to unchecked) - * false = control cohort (checkbox defaulted to checked) + * Accepts boolean (current rollout shape) or string (post-multivariate-migration + * variant name, e.g. 'test' / 'control'). See GROWTH-877 for the migration plan. + * true | 'test' = revoke cohort (checkbox defaulted to unchecked) + * false = outside the rollout (checkbox defaulted to checked) + * 'control' = in-experiment control arm (checkbox defaulted to checked) */ - dataApiRevokeOnCreateDefaultEnabled: boolean + dataApiRevokeOnCreateDefaultEnabled: boolean | string } groups: Omit } @@ -381,14 +384,17 @@ export interface ProjectCreationSimpleVersionSubmittedEvent { */ dataApiDefaultPrivilegesGranted?: boolean /** - * Whether the dataApiRevokeOnCreateDefault PostHog flag was enabled for this user. + * Raw value of the dataApiRevokeOnCreateDefault PostHog flag at submission time. * Controls only the default checkbox state of "Automatically expose new tables and functions" * at project creation. Tracking it lets us correlate flag cohort with user choice. - * true = user is in the staged rollout cohort (checkbox defaulted to unchecked) + * Accepts boolean (current rollout shape) or string (post-multivariate-migration + * variant name, e.g. 'test' / 'control'). See GROWTH-877 for the migration plan. + * true | 'test' = user is in the treatment arm (checkbox defaulted to unchecked) * false = user is outside the rollout (checkbox defaulted to checked) + * 'control' = in-experiment control arm (checkbox defaulted to checked) * omitted = PostHog flags had not loaded at the time of project creation */ - dataApiRevokeOnCreateDefaultEnabled?: boolean + dataApiRevokeOnCreateDefaultEnabled?: boolean | string } groups: TelemetryGroups } From 0840d45417f790b8cc70fae3a09dd347e9ce42c8 Mon Sep 17 00:00:00 2001 From: Timothy Lim Date: Mon, 25 May 2026 08:30:46 +0800 Subject: [PATCH 02/12] chore(docs): Clarify identity linking for SSO (#46273) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? - SSO accounts are not legible for any identity linking - Remove bit about choosing correct account when accepting invite as inviter specifies if invite is linked to SSO or not https://github.com/supabase/supabase/pull/43615 ## Summary by CodeRabbit * **Documentation** * Clarified that accounts created via SAML SSO are ineligible for identity linking to existing user accounts (manual or automatic) * Updated organizational account and identity management guidance for SSO-enabled environments * Enhanced documentation on properly handling non-SSO account memberships in SSO-required organizations and preventing duplicate account creation [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46273?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- apps/docs/content/guides/auth/auth-identity-linking.mdx | 8 ++++++-- .../content/guides/auth/enterprise-sso/auth-sso-saml.mdx | 6 +++--- apps/docs/content/guides/platform/sso.mdx | 8 ++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/docs/content/guides/auth/auth-identity-linking.mdx b/apps/docs/content/guides/auth/auth-identity-linking.mdx index cf3096e3c78..bd4780ae4c6 100644 --- a/apps/docs/content/guides/auth/auth-identity-linking.mdx +++ b/apps/docs/content/guides/auth/auth-identity-linking.mdx @@ -12,14 +12,18 @@ Currently, Supabase Auth supports 2 strategies to link an identity to a user: 1. [Automatic Linking](#automatic-linking) 2. [Manual Linking](#manual-linking-beta) + + +Users that signed up with [SAML SSO](/docs/guides/auth/sso/auth-sso-saml) will not be considered as targets for identity linking (automatic or manual) for security reasons. + + + ### Automatic linking Supabase Auth automatically links identities with the same email address to a single user. This helps to improve the user experience when multiple OAuth login options are presented since the user does not need to remember which OAuth account they used to sign up with. When a new user signs in with OAuth, Supabase Auth will attempt to look for an existing user that uses the same email address. If a match is found, the new identity is linked to the user. In order for automatic linking to correctly identify the user for linking, Supabase Auth needs to ensure that all user emails are unique. It would also be an insecure practice to automatically link an identity to a user with an unverified email address since that could lead to pre-account takeover attacks. To prevent this from happening, when a new identity can be linked to an existing user, Supabase Auth will remove any other unconfirmed identities linked to an existing user. -Users that signed up with [SAML SSO](/docs/guides/auth/sso/auth-sso-saml) will not be considered as targets for automatic linking. - ### Manual linking (beta) ` User accounts and identities created via SSO differ from regular (email, phone, password, social login...) accounts in these ways: -- **No automatic linking.** - Each user account verified using a SSO identity provider will not be automatically linked to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system. +- **No identity linking.** + Each user account verified using an SSO identity provider are not legible for [identity linking](/docs/guides/auth/auth-identity-linking) to existing user accounts for security reasons. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system. - **Emails are not necessarily unique.** - Given the behavior with no automatic linking, email addresses are no longer a unique identifier for a user account. Always use the user's UUID to correctly reference user accounts. + Given the behavior with no identity linking, email addresses are no longer a unique identifier for a user account. Always use the user's UUID to correctly reference user accounts. - **Sessions may have a maximum duration.** Depending on the configuration of the identity provider, a login session established with SSO may forcibly log out a user after a certain period of time. diff --git a/apps/docs/content/guides/platform/sso.mdx b/apps/docs/content/guides/platform/sso.mdx index 8c5e8059cc1..9a690a703e7 100644 --- a/apps/docs/content/guides/platform/sso.mdx +++ b/apps/docs/content/guides/platform/sso.mdx @@ -100,17 +100,17 @@ When SSO is enabled for an organization: 6. Thoroughly test using our [SSO Testing and Best Practices](/docs/guides/platform/sso/testing-best-practices) guide 7. Invite users to the organization or let them auto-join on login - + If a user is already a member of the organization under a non-SSO account, they will need to be removed and invited again with an SSO-required invitation to join under their SSO account. SSO and non-SSO accounts with the same email are treated as separate accounts. - + -Each user account verified using a SSO identity provider will not be automatically linked to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system. +Each user account verified using an SSO identity provider will not be legible for [identity linking](/docs/guides/auth/auth-identity-linking) to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system. -Users will need to ensure they are logged in with the correct account when accepting invites or accessing organizations/projects. +Users will need to ensure they are logged in with the correct account when accessing organizations/projects. From 7f2339481826fb430a3355b13752be3998e2de2c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 25 May 2026 11:20:43 +0700 Subject: [PATCH 03/12] Persist connect sheet user preference (#46213) ## Context As per PR title - persists the opened state of the connect sheet into local storage so re-opening it again will have the same parameters chosen ## To test - [ ] Verify that the persisting of the opened state of the connect sheet works ## Summary by CodeRabbit * **New Features** * Connect Sheet now saves user preferences so selections persist across sessions. * **Improvements** * Better synchronization between the UI and the URL for more consistent state when opening or sharing links. * Clearing selections reliably removes all relevant filters. * **Refactor** * Internal state handling simplified for more predictable mode and field changes. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46213?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- .../interfaces/ConnectSheet/ConnectSheet.tsx | 120 ++++++++++++------ .../ConnectSheet/useConnectSheetParams.ts | 58 +++++++++ packages/common/constants/local-storage.ts | 3 + 3 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 apps/studio/components/interfaces/ConnectSheet/useConnectSheetParams.ts diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx index 2db9e13fd30..01e5a58f318 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' +import { parseAsBoolean, useQueryState } from 'nuqs' import { useEffect, useMemo, useRef } from 'react' import { cn, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from 'ui' @@ -9,6 +9,7 @@ import { CONNECT_MODES } from './Connect.types' import { ConnectConfigSection, ModeSelector } from './ConnectConfigSection' import { ConnectStepsSection } from './ConnectStepsSection' import { useAvailableConnectModes } from './useAvailableConnectModes' +import { useConnectSheetParams } from './useConnectSheetParams' import { useConnectSheetShortcut } from './useConnectSheetShortcut' import { useConnectState } from './useConnectState' import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query' @@ -35,28 +36,32 @@ function mapConnectTabToMode(tab: string | null): ConnectMode | null { } export const ConnectSheet = () => { + const track = useTrack() + const prevShowConnect = useRef(false) const { ref: projectRef } = useParams() useConnectSheetShortcut() const availableModeIds = useAvailableConnectModes() + const { connectSheetSource, setConnectSheetSource } = useAppStateSnapshot() + const { state, activeFields, resolvedSteps, schema, getFieldOptions, setMode, updateField } = + useConnectState() const [showConnect, setShowConnect] = useQueryState( 'showConnect', parseAsBoolean.withDefault(false) ) - const [connectTab, setConnectTab] = useQueryState('connectTab', parseAsString) - const [queryFramework, setQueryFramework] = useQueryState('framework', parseAsString) - const [queryUsing, setQueryUsing] = useQueryState('using', parseAsString) - const [queryMethod, setQueryMethod] = useQueryState('method', parseAsString) - const [queryType, setQueryType] = useQueryState('type', parseAsString) - const [queryMcpClient, setQueryMcpClient] = useQueryState('mcpClient', parseAsString) - const { connectSheetSource, setConnectSheetSource } = useAppStateSnapshot() - const track = useTrack() - const prevShowConnect = useRef(false) - - const { state, activeFields, resolvedSteps, schema, getFieldOptions, setMode, updateField } = - useConnectState() + // URL params have no defaults: a `null` value signals "not in URL" so we can + // fall back to the user's last-used selections from localStorage. + const { params, storedPrefs, setConnectParams, setQueryParams } = useConnectSheetParams() + const { + connectTab, + framework: queryFramework, + using: queryUsing, + method: queryMethod, + type: queryType, + mcpClient: queryMcpClient, + } = params useEffect(() => { const justOpened = showConnect && !prevShowConnect.current @@ -67,24 +72,53 @@ export const ConnectSheet = () => { track('connect_sheet_opened', { source: connectSheetSource }) setConnectSheetSource('header_button') - const mappedMode = mapConnectTabToMode(connectTab) + const effectiveTab = connectTab ?? storedPrefs.connectTab + const effectiveFramework = queryFramework ?? storedPrefs.framework + const effectiveUsing = queryUsing ?? storedPrefs.using + const effectiveMethod = queryMethod ?? storedPrefs.method + const effectiveType = queryType ?? storedPrefs.type + const effectiveMcpClient = queryMcpClient ?? storedPrefs.mcpClient + + const mappedMode = mapConnectTabToMode(effectiveTab ?? null) if (mappedMode && availableModeIds.includes(mappedMode)) { setMode(mappedMode) } + // Hydrate URL from storedPrefs so the URL reflects the restored state. + // Only write params relevant to the active mode (matches how + // handleModeChange/handleFieldChange manage URL params). + const urlUpdates: Parameters[0] = {} + if (connectTab === null && effectiveTab) urlUpdates.connectTab = effectiveTab if (mappedMode === 'framework') { - if (queryFramework) { - updateField('framework', queryFramework) - if (queryUsing) updateField('frameworkVariant', queryUsing) + if (effectiveFramework) { + updateField('framework', effectiveFramework) + if (queryFramework === null) urlUpdates.framework = effectiveFramework + if (effectiveUsing) { + updateField('frameworkVariant', effectiveUsing) + if (queryUsing === null) urlUpdates.using = effectiveUsing + } } } else if (mappedMode === 'orm') { - if (queryFramework) updateField('orm', queryFramework) + if (effectiveFramework) { + updateField('orm', effectiveFramework) + if (queryFramework === null) urlUpdates.framework = effectiveFramework + } } else if (mappedMode === 'direct') { - if (queryMethod) updateField('connectionMethod', queryMethod) - if (queryType) updateField('connectionType', queryType) + if (effectiveMethod) { + updateField('connectionMethod', effectiveMethod) + if (queryMethod === null) urlUpdates.method = effectiveMethod + } + if (effectiveType) { + updateField('connectionType', effectiveType) + if (queryType === null) urlUpdates.type = effectiveType + } } else if (mappedMode === 'mcp') { - if (queryMcpClient) updateField('mcpClient', queryMcpClient) + if (effectiveMcpClient) { + updateField('mcpClient', effectiveMcpClient) + if (queryMcpClient === null) urlUpdates.mcpClient = effectiveMcpClient + } } + if (Object.keys(urlUpdates).length > 0) setQueryParams(urlUpdates) }, [ showConnect, connectSheetSource, @@ -94,20 +128,24 @@ export const ConnectSheet = () => { queryMethod, queryType, queryMcpClient, + storedPrefs, availableModeIds, track, setConnectSheetSource, setMode, updateField, + setQueryParams, ]) const clearAllQueryParams = () => { - setConnectTab(null) - setQueryFramework(null) - setQueryUsing(null) - setQueryMethod(null) - setQueryType(null) - setQueryMcpClient(null) + setQueryParams({ + connectTab: null, + framework: null, + using: null, + method: null, + type: null, + mcpClient: null, + }) } const handleOpenChange = (sheetOpen: boolean) => { @@ -141,31 +179,31 @@ export const ConnectSheet = () => { const handleModeChange = (mode: ConnectMode) => { setMode(mode) - setConnectTab(mode) - setQueryFramework(null) - setQueryUsing(null) - setQueryMethod(null) - setQueryType(null) - setQueryMcpClient(null) + setConnectParams({ + connectTab: mode, + framework: null, + using: null, + method: null, + type: null, + mcpClient: null, + }) } const handleFieldChange = (fieldId: string, value: string | boolean | string[]) => { updateField(fieldId, value) const str = String(value) if (fieldId === 'framework') { - setQueryFramework(str) - setQueryUsing(null) + setConnectParams({ framework: str, using: null }) } else if (fieldId === 'frameworkVariant') { - setQueryUsing(str) + setConnectParams({ using: str }) } else if (fieldId === 'orm') { - setQueryFramework(str) + setConnectParams({ framework: str }) } else if (fieldId === 'connectionMethod') { - setQueryMethod(str) - setQueryType(null) + setConnectParams({ method: str, type: null }) } else if (fieldId === 'connectionType') { - setQueryType(str) + setConnectParams({ type: str }) } else if (fieldId === 'mcpClient') { - setQueryMcpClient(str) + setConnectParams({ mcpClient: str }) } } diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectSheetParams.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectSheetParams.ts new file mode 100644 index 00000000000..01f3f23bca2 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectSheetParams.ts @@ -0,0 +1,58 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { parseAsString, useQueryStates } from 'nuqs' +import { useCallback } from 'react' + +import { useLocalStorage } from '@/hooks/misc/useLocalStorage' + +export type ConnectSheetPrefs = { + connectTab?: string + framework?: string + using?: string + method?: string + type?: string + mcpClient?: string +} + +const DEFAULT_CONNECT_SHEET_PREFS: ConnectSheetPrefs = {} + +type ConnectParamKey = keyof ConnectSheetPrefs +type ConnectParamUpdates = Partial> + +/** + * Owns the URL params + localStorage that drive the Connect sheet's initial + * state. `setConnectParams` mirrors writes to both. `setQueryParams` writes + * the URL only — used to clear URL on close and to hydrate URL from storage + * on open (where storage is already the source of truth). + */ +export function useConnectSheetParams() { + const [params, setQueryParams] = useQueryStates({ + connectTab: parseAsString, + framework: parseAsString, + using: parseAsString, + method: parseAsString, + type: parseAsString, + mcpClient: parseAsString, + }) + + const [storedPrefs, setStoredPrefs] = useLocalStorage( + LOCAL_STORAGE_KEYS.CONNECT_SHEET_PREFS, + DEFAULT_CONNECT_SHEET_PREFS + ) + + const setConnectParams = useCallback( + (updates: ConnectParamUpdates) => { + setQueryParams(updates) + setStoredPrefs((prev) => { + const next: ConnectSheetPrefs = { ...prev } + for (const key of Object.keys(updates) as ConnectParamKey[]) { + // null in URL maps to undefined in storage so the preference is truly forgotten + next[key] = updates[key] ?? undefined + } + return next + }) + }, + [setQueryParams, setStoredPrefs] + ) + + return { params, storedPrefs, setConnectParams, setQueryParams } +} diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 2701f03f994..fb6cc904bf1 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -80,6 +80,9 @@ export const LOCAL_STORAGE_KEYS = { // api keys view switcher for new and legacy api keys API_KEYS_VIEW: (ref: string) => `supabase-api-keys-view-${ref}`, + // Connect sheet: remember last-used tab/framework/method etc. across opens + CONNECT_SHEET_PREFS: 'supabase-connect-sheet-prefs', + // Shortcut preferences SHORTCUT_STORAGE_KEY: 'supabase-shortcut-preferences', From 65c570e85b35fecdc67f5c91371349a4d6f77997 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 25 May 2026 11:26:43 +0700 Subject: [PATCH 04/12] Fix copy / export large values in table editor (#46268) ## Context There's an issue with copying / exporting rows from the table editor with the following conditions: - Row has a column value that exceeds 10,240 and hence is truncated for performance reasons image - User is trying to copy/export selected rows (not all rows in the table) image The copy/export action will return the truncated data which is incorrect (Should return the full data) ## Problem This is happening as if we're only copying/exporting selected rows, we're just using what's been loaded in the table editor to export (as opposed to if the user is copying/exporting all rows in the table, we'd be fetching the data from the database first before doing so) Hence am opting to add a data hydration logic, such that if there's a selected row that's been truncated, we'd fetch them on demand first before copying/exporting. There's limitations to this though - e.g if the table doesn't have a primary key we can't do this (since we need to run a query to fetch the data). This is already an existing behaviour when trying to load the column value in the table editor in the grid so no issues I believe. We'll just show this toast: image ## To test You'll need a column with a really large value - the way I do it is to load the data directly into the DB via TablePlus - [ ] Verify that copying / exporting selected rows with really large column values copies/exports all the data correctly (there shouldn't be any truncated value) ## Summary by CodeRabbit * **New Features** * Improved handling of truncated cell values during copy and export operations * **Bug Fixes** * Copy and export operations now require an active project selection * Fixed data export for tables without primary keys * **Style** * Updated grid header copy and export control layout [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46268?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- .../grid/components/header/Header.tsx | 247 +++++++++--------- .../grid/components/header/Header.utils.ts | 70 +++++ .../TableEditorLayout/ExportAllRows.errors.ts | 9 + .../TableEditorLayout/ExportAllRows.tsx | 41 ++- 4 files changed, 242 insertions(+), 125 deletions(-) diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 93fc9c4b12d..dc4c830bf04 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -17,11 +17,12 @@ import { import { useInitializeFiltersFromUrl, useSyncFiltersToUrl } from '../../hooks/useFilterLifeCycle' import { ExportDialog } from './ExportDialog' import { FilterPopoverNew } from './filter/FilterPopoverNew' -import { formatRowsForCSV } from './Header.utils' +import { formatRowsForCSV, hydrateTruncatedRows } from './Header.utils' import { SortPopover } from './sort/SortPopover' import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations' import { useTableSort } from '@/components/grid/hooks/useTableSort' import { GridHeaderActions } from '@/components/interfaces/TableGridEditor/GridHeaderActions' +import { isValueTruncated } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' import { formatTableRowsToSQL } from '@/components/interfaces/TableGridEditor/TableEntity.utils' import { useExportAllRowsAsCsv, @@ -145,6 +146,7 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { const filters = snap.filters const { sorts } = useTableSort() + const [isCopying, setIsCopying] = useState(false) const [isExporting, setIsExporting] = useState(false) const [showExportModal, setShowExportModal] = useState(false) @@ -206,22 +208,53 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { } const onCopyRows = (type: 'csv' | 'json' | 'sql') => { - const rows = allRows.filter((x) => snap.selectedRows.has(x.idx)) + if (!project) return toast.error('Project is required') - if (type === 'csv') { - const csv = formatRowsForCSV({ - rows, - columns: snap.table!.columns.map((column) => column.name), - }) - copyToClipboard(csv) - } else if (type === 'sql') { - const sqlStatements = formatTableRowsToSQL(snap.table, rows) - copyToClipboard(sqlStatements) - } else if (type === 'json') { - copyToClipboard(JSON.stringify(rows)) + const selected = allRows.filter((x) => snap.selectedRows.has(x.idx)) + + const hasTruncated = selected.some((row) => + Object.values(row).some((v) => typeof v === 'string' && isValueTruncated(v)) + ) + if (hasTruncated && (!snap.table.primaryKey || snap.table.primaryKey.length === 0)) { + return toast( +
+

Unable to copy selected rows

+

+ A selected row has a column value that needs to be fetched on demand due to its size, + but the table has no primary key. +

+
, + { duration: 8000 } + ) } - toast.success('Copied rows to clipboard') + setIsCopying(true) + const formatted = (async () => { + const hydrated = await hydrateTruncatedRows({ + rows: selected, + table: snap.table, + projectRef: project.ref, + connectionString: project.connectionString ?? null, + }) + if (hydrated.status !== 'ok') { + throw new Error('Failed to fetch full values for truncated cells') + } + const rows = hydrated.rows + if (type === 'csv') { + return formatRowsForCSV({ + rows, + columns: snap.table!.columns.map((column) => column.name), + }) + } else if (type === 'sql') { + return formatTableRowsToSQL(snap.table, rows) + } else { + return JSON.stringify(rows) + } + })() + + copyToClipboard(formatted, () => toast.success('Copied rows to clipboard')).finally(() => { + setIsCopying(false) + }) } const exportParams = snap.allRowsSelected @@ -245,15 +278,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { : { enabled: false } ) const onRowsExportCSV = async () => { + if (!project) return toast.error('Project is required') + setIsExporting(true) - - if (!project) { - toast.error('Project is required') - return setIsExporting(false) - } - - exportCsv() - + await exportCsv() setIsExporting(false) } @@ -269,15 +297,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { : { enabled: false } ) const onRowsExportSQL = async () => { + if (!project) return toast.error('Project is required') + setIsExporting(true) - - if (!project) { - toast.error('Project is required') - return setIsExporting(false) - } - - exportSql() - + await exportSql() setIsExporting(false) } @@ -293,14 +316,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { : { enabled: false } ) const onRowsExportJSON = async () => { - if (!project) { - return toast.error('Project is required') - } + if (!project) return toast.error('Project is required') setIsExporting(true) - - exportJson() - + await exportJson() setIsExporting(false) } @@ -312,7 +331,78 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { return ( <> -
+
+
+ {!snap.allRowsSelected ? ( + + + + + + onCopyRows('csv')}>Copy as CSV + onCopyRows('sql')}>Copy as SQL + onCopyRows('json')}>Copy as JSON + + + ) : ( + + Copy + + )} + + + + + + + Export as CSV + Export as SQL + {snap.allRowsSelected ? ( + setShowExportModal(true)}> +
+

Export via CLI

+

Recommended for large tables

+
+
+ ) : ( + Export as JSON + )} +
+
+ + {snap.selectedRows.size > 0 && totalRows > allRows.length && ( + <> +
+ +
+ + + + + )} +
+ {snap.editable && ( { }} > } onClick={onRowsDelete} @@ -347,87 +437,6 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { )} - - {!snap.allRowsSelected ? ( - - - - - - onCopyRows('csv')}>Copy as CSV - onCopyRows('sql')}>Copy as SQL - onCopyRows('json')}>Copy as JSON - - - ) : ( - - Copy - - )} - - - - - - - Export as CSV - Export as SQL - {snap.allRowsSelected ? ( - setShowExportModal(true)}> -
-

Export via CLI

-

Recommended for large tables

-
-
- ) : ( - Export as JSON - )} -
-
- - {snap.selectedRows.size > 0 && totalRows > allRows.length && ( - <> -
- -
- - - - - )}
{ const formattedRows = rows.map((row) => { const formattedRow = row @@ -12,3 +16,69 @@ export const formatRowsForCSV = ({ rows, columns }: { rows: any[]; columns: stri const csv = Papa.unparse(formattedRows, { columns }) return csv } + +export type HydrateTruncatedRowsResult = + | { status: 'ok'; rows: Record[] } + | { status: 'no_primary_key' } + | { status: 'fetch_error'; error: unknown } + +/** + * For each cell whose value was truncated for display (see isValueTruncated), + * refetch the full value via getCellValue and return a new array with the + * truncated cells replaced. If any row has a truncated cell but the table has + * no primary key, returns { status: 'no_primary_key' } so the caller can + * surface a clear error instead of silently exporting truncated data. + */ +export const hydrateTruncatedRows = async ({ + rows, + table, + projectRef, + connectionString, +}: { + rows: Record[] + table: SupaTable + projectRef: string + connectionString: string | null +}): Promise => { + const jobs: { rowIdx: number; column: string }[] = [] + rows.forEach((row, rowIdx) => { + Object.keys(row).forEach((column) => { + const value = row[column] + if (typeof value === 'string' && isValueTruncated(value)) { + jobs.push({ rowIdx, column }) + } + }) + }) + + if (jobs.length === 0) return { status: 'ok', rows } + + if (!table.primaryKey || table.primaryKey.length === 0) { + return { status: 'no_primary_key' } + } + + const pkColumns = table.primaryKey + const hydrated = rows.map((row) => ({ ...row })) + + try { + await Promise.all( + jobs.map(async ({ rowIdx, column }) => { + const sourceRow = rows[rowIdx] + const pkMatch = pkColumns.reduce>( + (acc, pk) => ({ ...acc, [pk]: sourceRow[pk] }), + {} + ) + hydrated[rowIdx][column] = await getCellValue({ + projectRef, + connectionString, + table: { schema: table.schema ?? 'public', name: table.name }, + column, + pkMatch, + }) + }) + ) + } catch (error: unknown) { + return { status: 'fetch_error', error } + } + + return { status: 'ok', rows: hydrated } +} diff --git a/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.errors.ts b/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.errors.ts index 73d0e0065a9..cf9a7886f2d 100644 --- a/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.errors.ts +++ b/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.errors.ts @@ -91,3 +91,12 @@ export class DownloadSaveError extends ExportAllRowsErrorFamily { this.name = 'DownloadSaveError' } } + +export class NoPrimaryKeyForTruncatedRowsError extends ExportAllRowsErrorFamily { + constructor(tableName: string) { + super( + `Cannot export rows with truncated values from "${tableName}" because the table has no primary key.` + ) + this.name = 'NoPrimaryKeyForTruncatedRowsError' + } +} diff --git a/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx b/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx index 4d01a6adada..a3bbe44c604 100644 --- a/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx @@ -10,6 +10,7 @@ import { DownloadSaveError, FetchRowsError, NoConnectionStringError, + NoPrimaryKeyForTruncatedRowsError, NoRowsToExportError, NoTableError, OutputConversionError, @@ -18,6 +19,7 @@ import { type ExportAllRowsErrorFamily, } from './ExportAllRows.errors' import { useProgressToasts } from './ExportAllRows.progress' +import { hydrateTruncatedRows } from '@/components/grid/components/header/Header.utils' import { parseSupaTable } from '@/components/grid/SupabaseGrid.utils' import type { Filter, Sort, SupaTable } from '@/components/grid/types' import { formatTableRowsToSQL } from '@/components/interfaces/TableGridEditor/TableEntity.utils' @@ -287,13 +289,37 @@ export const useExportAllRowsGeneric = ( const { projectRef, connectionString, entity, totalRows } = params - const exportResult = + const exportResult: FetchAllRowsReturn = params.type === 'provided_rows' - ? convertAndDownload(formatRowsForExport(params.rows, params.table), params.table, { - convertToOutputFormat, - convertToBlob, - save, - }) + ? await (async () => { + const hydrated = await hydrateTruncatedRows({ + rows: params.rows, + table: params.table, + projectRef, + connectionString, + }) + if (hydrated.status === 'no_primary_key') { + return { + status: 'error' as const, + error: new NoPrimaryKeyForTruncatedRowsError(params.table.name), + } + } + if (hydrated.status === 'fetch_error') { + return { + status: 'error' as const, + error: new FetchRowsError(params.table.name, hydrated.error), + } + } + return convertAndDownload( + formatRowsForExport(hydrated.rows, params.table), + params.table, + { + convertToOutputFormat, + convertToBlob, + save, + } + ) + })() : await fetchAllRows({ queryClient, projectRef: projectRef, @@ -337,6 +363,9 @@ export const useExportAllRowsGeneric = ( if (error instanceof TableTooLargeError) { return stopTrackerWithError(entity.id, entity.name, MAX_EXPORT_ROW_COUNT_MESSAGE) } + if (error instanceof NoPrimaryKeyForTruncatedRowsError) { + return stopTrackerWithError(entity.id, entity.name, error.message) + } console.error( `Export All Rows > Error: %s%s%s`, error.message, From 7edbedcbec7372c6a6dde78281e37be4969c4c5f Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Mon, 25 May 2026 09:08:38 +0200 Subject: [PATCH 05/12] fix integration overview tab contents (#46179) Restores proper content in new marketplace detail overview pages compared to the legacy overview pages. ## Summary by CodeRabbit * **New Features** * Added Data API URL settings and a visible "Required extensions" section across integration overviews. * Unified install/manage UIs for webhooks, Stripe Sync, wrappers, queues, and others; marketplace mode now shows marketplace-specific overview content. * **Style** * Improved marketplace detail rail and filter-bar button styling; refined list/link row visuals. * **Refactor** * Overview pages reorganized to branch on marketplace mode and extract shared overview content for consistency. * **Tests** * Stabilized integration overview test data for deterministic runs. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46179?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --------- Co-authored-by: Joshen Lim --- .../Integrations/CronJobs/OverviewTab.tsx | 10 + .../DataApi/DataApiDisabledState.tsx | 4 +- .../DataApi/DataApiURLSettings.tsx | 47 +++ .../Integrations/DataApi/OverviewTab.tsx | 73 +--- .../Integrations/DataApi/SettingsTab.tsx | 2 +- .../Integrations/GraphQL/OverviewTab.tsx | 10 + .../IntegrationOverviewTab.test.tsx | 11 + .../Integration/IntegrationOverviewTab.tsx | 110 ++--- .../InstallationOverview.tsx | 8 +- .../IntegrationOverviewTabWrapper.tsx | 7 +- .../MarketplaceIntegrationOverviewTab.tsx | 11 + .../Integration/RequiredExtensionsSection.tsx | 84 ++++ .../Landing/Integrations.constants.tsx | 12 +- .../Landing/useAvailableIntegrations.tsx | 4 +- .../Marketplace/MarketplaceDetail.tsx | 10 +- .../Marketplace/MarketplaceDetailRail.tsx | 40 +- .../Marketplace/MarketplaceFilterBar.tsx | 4 +- .../Integrations/Marketplace/OverviewTab.tsx | 9 +- .../Integrations/Queues/OverviewTab.tsx | 23 +- .../Integrations/Vault/OverviewTab.tsx | 10 + .../Integrations/Webhooks/OverviewTab.tsx | 75 ++-- .../Integrations/Wrappers/OverviewTab.tsx | 46 +-- .../Integrations/Wrappers/WrappersTab.tsx | 20 +- .../StripeSyncEngine/OverviewTab.tsx | 387 ++++++++---------- 24 files changed, 556 insertions(+), 461 deletions(-) create mode 100644 apps/studio/components/interfaces/Integrations/CronJobs/OverviewTab.tsx create mode 100644 apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx create mode 100644 apps/studio/components/interfaces/Integrations/GraphQL/OverviewTab.tsx create mode 100644 apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab.tsx create mode 100644 apps/studio/components/interfaces/Integrations/Integration/RequiredExtensionsSection.tsx create mode 100644 apps/studio/components/interfaces/Integrations/Vault/OverviewTab.tsx diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/OverviewTab.tsx new file mode 100644 index 00000000000..491455185b1 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/OverviewTab.tsx @@ -0,0 +1,10 @@ +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' +import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' + +export const CronOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) return + return +} diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DataApiDisabledState.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DataApiDisabledState.tsx index e087da46dff..0a9bd324e8d 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DataApiDisabledState.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/DataApiDisabledState.tsx @@ -12,8 +12,8 @@ export const DataApiDisabledState = ({ description }: DataApiDisabledStateProps) const { ref: projectRef } = useParams() return ( -
- +
+ Data API is disabled diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx new file mode 100644 index 00000000000..db601d2f0c5 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx @@ -0,0 +1,47 @@ +import { useParams } from 'common' +import { AlertCircle } from 'lucide-react' +import { Alert, AlertTitle, cn } from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +import { DataApiEnableSwitch } from '@/components/interfaces/Settings/API/DataApiEnableSwitch' +import { DataApiProjectUrlCard } from '@/components/interfaces/Settings/API/DataApiProjectUrlCard' +import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants' + +export const DataApiURLSettings = () => { + const { ref: projectRef } = useParams() + const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery() + const { isEnabled, isPending: isConfigLoading } = useIsDataApiEnabled({ projectRef }) + const isLoading = isProjectLoading || isConfigLoading + + return ( +
+ {!isProjectLoading && project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY ? ( + + + API settings are unavailable as the project is not active + + ) : ( + <> +
+ +
+ {IS_PLATFORM ? ( + + ) : ( + + )} + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx index e4e0f82de23..54e702b3748 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx @@ -1,70 +1,17 @@ -import { useParams } from 'common' -import { AlertCircle } from 'lucide-react' -import { Alert, AlertTitle, cn } from 'ui' -import { Admonition } from 'ui-patterns/admonition' - import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' -import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2' +import { DataApiURLSettings } from './DataApiURLSettings' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { DataApiEnableSwitch } from '@/components/interfaces/Settings/API/DataApiEnableSwitch' -import { DataApiProjectUrlCard } from '@/components/interfaces/Settings/API/DataApiProjectUrlCard' -import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants' - -const DataApiOverview = () => { - const { ref: projectRef } = useParams() - const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery() - const { isEnabled, isPending: isConfigLoading } = useIsDataApiEnabled({ projectRef }) - const isLoading = isProjectLoading || isConfigLoading - - return ( -
- {!isProjectLoading && project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY ? ( - - - API settings are unavailable as the project is not active - - ) : ( - <> -
- -
- {IS_PLATFORM ? ( - - ) : ( - - )} - - )} -
- ) -} export const DataApiOverviewTab = () => { const isMarketplaceEnabled = useIsMarketplaceEnabled() - if (isMarketplaceEnabled) { - return ( - - - - ) - } else { - return ( - -
- -
-
- ) - } + if (isMarketplaceEnabled) return + + return ( + +
+ +
+
+ ) } diff --git a/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx index baab4258916..fe2eb615d30 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx @@ -12,7 +12,7 @@ export const DataApiSettingsTab = () => { if (IS_PLATFORM && !isPending && !isEnabled) { return ( - + ) diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/OverviewTab.tsx new file mode 100644 index 00000000000..573b2b59e21 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/OverviewTab.tsx @@ -0,0 +1,10 @@ +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' +import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' + +export const GraphQLOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) return + return +} diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx index 734b2281218..805ba873c3a 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx @@ -54,6 +54,17 @@ vi.mock('./MarkdownContent', () => ({ MarkdownContent: () => null, })) +vi.mock('../Landing/useIntegrationDetail', () => ({ + useIntegrationDetail: () => ({ + integration: { + id: 'test-integration', + name: 'Test Integration', + requiredExtensions: ['pg_net'], + missingExtensionsAlert: null, + }, + }), +})) + describe('IntegrationOverviewTab', () => { beforeEach(() => { routerMock.setCurrentUrl('/project/default/integrations/test-integration/overview') diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx index 210c520fe44..f9ec67855ae 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx @@ -1,11 +1,11 @@ import { useParams } from 'common' import { PropsWithChildren, ReactNode } from 'react' -import { Badge, Card, CardContent, cn, Separator } from 'ui' +import { cn } from 'ui' import { INTEGRATIONS } from '../Landing/Integrations.constants' import { BuiltBySection } from './BuildBySection' import { MarkdownContent } from './MarkdownContent' -import { MissingExtensionAlert } from './MissingExtensionAlert' +import { RequiredExtensionsSection } from './RequiredExtensionsSection' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -16,19 +16,20 @@ export interface IntegrationOverviewTabProps { hideRequiredExtensionsSection?: boolean } -/** [Joshen] This will eventually get replaced by IntegrationOverviewTabV2 */ -export const IntegrationOverviewTab = ({ +export const OverviewTabSharedContent = ({ + integration, + hideRequiredExtensionsSection = false, actions, alert, - status, children, - hideRequiredExtensionsSection = false, -}: PropsWithChildren) => { - const { id } = useParams() +}: PropsWithChildren<{ + integration: any + hideRequiredExtensionsSection?: boolean + actions?: ReactNode + alert?: ReactNode +}>) => { const { data: project } = useSelectedProjectQuery() - const integration = INTEGRATIONS.find((i) => i.id === id) - const { data: extensions } = useDatabaseExtensionsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, @@ -38,71 +39,21 @@ export const IntegrationOverviewTab = ({ return
Unsupported integration type
} - const dependsOnExtension = (integration.requiredExtensions ?? []).length > 0 - const installableExtensions = (extensions ?? []).filter((ext) => (integration.requiredExtensions ?? []).includes(ext.name) ) const hasToInstallExtensions = installableExtensions.some((x) => !x.installed_version) - // The integration requires extensions that are not available to install on the current database image - const hasMissingExtensions = - installableExtensions.length !== integration.requiredExtensions.length - return ( -
- - + <> {!!alert &&
{alert}
} - - - {dependsOnExtension && !hideRequiredExtensionsSection && ( -
-

Required extensions

- - -
    - {(integration.requiredExtensions ?? []).map((requiredExtension, idx) => { - const extension = (extensions ?? []).find((ext) => ext.name === requiredExtension) - const isInstalled = !!extension?.installed_version - const isLastRow = idx === (integration.requiredExtensions?.length ?? 0) - 1 - - return ( -
  • - {requiredExtension} - -
    - {extension ? ( - isInstalled ? ( - Installed - ) : ( - - ) - ) : ( - Unavailable - )} -
    -
  • - ) - })} -
- - {hasMissingExtensions && ( -
{integration.missingExtensionsAlert}
- )} -
-
-
- )} + {!!actions && (
)} {children} + + ) +} + +export const IntegrationOverviewTab = ({ + actions, + alert, + status, + children, + hideRequiredExtensionsSection = false, +}: PropsWithChildren) => { + const { id } = useParams() + const integration = INTEGRATIONS.find((i) => i.id === id) + + if (!integration) { + return
Unsupported integration type
+ } + + return ( +
+ + + {children} +
) } diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallationOverview.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallationOverview.tsx index b46b190a6d3..541989a7ebe 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallationOverview.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallationOverview.tsx @@ -104,13 +104,9 @@ export const InstallationOverview = ({ {!ext ? ( Unavailable ) : ext.installed_version ? ( - - Installed - + Installed ) : ( - - Required - + Required )}
) diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx index 45c52ac651c..5369bef2347 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx @@ -6,7 +6,6 @@ import { PropsWithChildren } from 'react' import { IntegrationOverviewTab, IntegrationOverviewTabProps } from './IntegrationOverviewTab' -import { IntegrationOverviewTabV2 } from './IntegrationOverviewTabV2' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' export const IntegrationOverviewTabWrapper = ( @@ -15,8 +14,8 @@ export const IntegrationOverviewTabWrapper = ( const isMarketplaceEnabled = useIsMarketplaceEnabled() if (isMarketplaceEnabled) { - return {props.children} - } else { - return + return null } + + return } diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab.tsx new file mode 100644 index 00000000000..b6cc6f0aed3 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab.tsx @@ -0,0 +1,11 @@ +import type { PropsWithChildren } from 'react' + +import { IntegrationOverviewTab } from './IntegrationOverviewTab' +import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' + +export const MarketplaceIntegrationOverviewTab = (props: PropsWithChildren) => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) return null + return {props.children} +} diff --git a/apps/studio/components/interfaces/Integrations/Integration/RequiredExtensionsSection.tsx b/apps/studio/components/interfaces/Integrations/Integration/RequiredExtensionsSection.tsx new file mode 100644 index 00000000000..070985b792c --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/RequiredExtensionsSection.tsx @@ -0,0 +1,84 @@ +import { Badge, Card, CardContent, cn, Separator } from 'ui' + +import { useIsMarketplaceEnabled } from '../../App/FeaturePreview/FeaturePreviewContext' +import { MissingExtensionAlert } from './MissingExtensionAlert' +import { useIntegrationDetail } from '@/components/interfaces/Integrations/Landing/useIntegrationDetail' +import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +export const RequiredExtensionsSection = ({ + hide = false, + hideSeparator = false, + className, +}: { + hide?: boolean + hideSeparator?: boolean + className?: string +}) => { + const { integration } = useIntegrationDetail() + const { data: project } = useSelectedProjectQuery() + const { data: extensions } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + const requiredExtensions = integration?.requiredExtensions ?? [] + + if (hide || requiredExtensions.length === 0) return null + + const installableExtensions = (extensions ?? []).filter((ext) => + requiredExtensions.includes(ext.name) + ) + const hasMissingExtensions = installableExtensions.length !== requiredExtensions.length + + return ( + <> + {!hideSeparator && } +
+

Required extensions

+ + +
    + {requiredExtensions.map((requiredExtension, idx) => { + const extension = (extensions ?? []).find((ext) => ext.name === requiredExtension) + const isInstalled = !!extension?.installed_version + const isLastRow = idx === requiredExtensions.length - 1 + + return ( +
  • + {requiredExtension} + +
    + {extension ? ( + isInstalled ? ( + Installed + ) : isMarketplaceEnabled ? ( + Required + ) : ( + + ) + ) : ( + Unavailable + )} +
    +
  • + ) + })} +
+ + {hasMissingExtensions && integration?.missingExtensionsAlert && ( +
{integration.missingExtensionsAlert}
+ )} +
+
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index ca63108e40b..617ba40e1e7 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -231,8 +231,8 @@ const SUPABASE_INTEGRATIONS: Array = [ case 'overview': return dynamic( () => - import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper').then( - (mod) => mod.IntegrationOverviewTabWrapper + import('@/components/interfaces/Integrations/CronJobs/OverviewTab').then( + (mod) => mod.CronOverviewTab ), { loading: Loading, @@ -275,8 +275,8 @@ const SUPABASE_INTEGRATIONS: Array = [ case 'overview': return dynamic( () => - import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper').then( - (mod) => mod.IntegrationOverviewTabWrapper + import('@/components/interfaces/Integrations/Vault/OverviewTab').then( + (mod) => mod.VaultOverviewTab ), { loading: Loading, @@ -445,8 +445,8 @@ const SUPABASE_INTEGRATIONS: Array = [ case 'overview': return dynamic( () => - import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper').then( - (mod) => mod.IntegrationOverviewTabWrapper + import('@/components/interfaces/Integrations/GraphQL/OverviewTab').then( + (mod) => mod.GraphQLOverviewTab ), { loading: Loading, diff --git a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx index 066a5752649..0d13f17615d 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx @@ -114,8 +114,8 @@ export const useAvailableIntegrations = () => { case 'overview': return dynamic( () => - import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/index').then( - (mod) => mod.IntegrationOverviewTabV2 + import('@/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab').then( + (mod) => mod.MarketplaceIntegrationOverviewTab ), { loading: Loading, diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx index f9aae45156d..58c13225e3f 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight, BookOpen, Settings } from 'lucide-react' +import { ArrowUpRight, BookOpen } from 'lucide-react' import { Button, cn } from 'ui' import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns' import { Admonition } from 'ui-patterns/admonition' @@ -70,7 +70,7 @@ export const MarketplaceDetail = () => { } if (isInstalled) { return ( - ) @@ -78,6 +78,8 @@ export const MarketplaceDetail = () => { return } + // For overview route, get the integration-specific overview component if available + const OverviewComponent = activeRoute === 'overview' ? Component : null const CustomPageComponent = activeRoute !== 'overview' && isKnownRoute ? Component : null return ( @@ -114,7 +116,9 @@ export const MarketplaceDetail = () => { {activeRoute === 'overview' ? (
- + + {OverviewComponent && } +
) : CustomPageComponent ? ( layout === 'constrained' ? ( diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx index 32ec02633c6..101c76773b9 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight } from 'lucide-react' +import { ArrowUpRight, BookOpen, Globe } from 'lucide-react' import type { ReactNode } from 'react' import { cn } from 'ui' @@ -6,16 +6,17 @@ import { getMarketplaceType, getMarketplaceTypeLabel } from './Marketplace.const import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants' interface RailRowProps { - label: string + label?: string value: ReactNode href?: string mono?: boolean + icon?: ReactNode } -const RailRow = ({ label, value, href, mono }: RailRowProps) => { +const RailRow = ({ label, value, href, mono, icon }: RailRowProps) => { const valueCls = cn( 'flex items-center gap-1 text-sm', - href ? 'text-brand-link' : 'text-foreground', + href ? 'text-foreground-lighter hover:text-foreground' : 'text-foreground', mono && 'font-mono' ) const content = ( @@ -26,14 +27,17 @@ const RailRow = ({ label, value, href, mono }: RailRowProps) => { ) return (
-
{label}
- {href ? ( - - {content} - - ) : ( -
{content}
- )} + {label &&
{label}
} +
+ {icon && {icon}} + {href ? ( + + {content} + + ) : ( +
{content}
+ )} +
) } @@ -45,9 +49,7 @@ interface RailGroupProps { const RailGroup = ({ title, children }: RailGroupProps) => (
-
- {title} -
+
{title}
{children}
) @@ -82,8 +84,12 @@ export const MarketplaceDetailRail = ({ integration, isInstalled }: MarketplaceD {(docsUrl || siteUrl) && ( - {docsUrl && } - {siteUrl && siteHost && } + {docsUrl && ( + } href={docsUrl} /> + )} + {siteUrl && siteHost && ( + } href={siteUrl} /> + )} )} diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx index 0ff6e79b859..1123a2d8d4a 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx @@ -210,7 +210,7 @@ export const MarketplaceFilterBar = ({ aria-label="Grid view" onClick={() => onViewModeChange('grid')} className={cn( - 'border-r px-2 py-1.5 rounded-l-md', + 'border-r px-2 py-1.5 rounded-l-md cursor-pointer', 'focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-muted focus-visible:ring-offset-1 focus-visible:ring-offset-background', viewMode === 'grid' ? 'bg-surface-200 text-foreground' @@ -224,7 +224,7 @@ export const MarketplaceFilterBar = ({ aria-label="List view" onClick={() => onViewModeChange('list')} className={cn( - 'px-2 py-1.5 rounded-r-md', + 'px-2 py-1.5 rounded-r-md cursor-pointer', 'focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-muted focus-visible:ring-offset-1 focus-visible:ring-offset-background', viewMode === 'list' ? 'bg-surface-200 text-foreground' diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/OverviewTab.tsx index 3c2af5379e5..ccd26681f7d 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/OverviewTab.tsx @@ -1,23 +1,26 @@ +import type { PropsWithChildren } from 'react' + import { MarketplaceDetailRail } from './MarketplaceDetailRail' import { ConstrainedIntegrationTabScaffold } from '@/components/interfaces/Integrations/ConstrainedIntegrationTabScaffold' import { FilesViewer } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/FilesViewer' import { MarkdownContent } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent' import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants' -interface OverviewTabProps { +interface OverviewTabProps extends PropsWithChildren { integration: IntegrationDefinition isInstalled: boolean } -export const OverviewTab = ({ integration, isInstalled }: OverviewTabProps) => { +export const OverviewTab = ({ integration, isInstalled, children }: OverviewTabProps) => { const { content, files = [] } = integration return (
-
+
{files.length > 0 && } + {children}
diff --git a/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx index d0295b878f0..3a89b11a2f7 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx @@ -4,15 +4,19 @@ import { Button } from 'ui' import { Admonition } from 'ui-patterns' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' -import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useQueuesExposePostgrestStatusQuery } from '@/data/database-queues/database-queues-expose-postgrest-status-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -const QueuesAdmonition = () => { +const QueuesContent = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { data: isExposed } = useQueuesExposePostgrestStatusQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) const { data: extensions = [] } = useDatabaseExtensionsQuery({ projectRef: project?.ref, @@ -20,6 +24,8 @@ const QueuesAdmonition = () => { }) const isQueuesInstalled = !!extensions.find((x) => x.name === 'pgmq')?.installed_version + if (isExposed) return null + return ( { } export const QueuesOverviewTab = () => { - const { data: project } = useSelectedProjectQuery() const isMarketplaceEnabled = useIsMarketplaceEnabled() - const { data: isExposed } = useQueuesExposePostgrestStatusQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - if (isMarketplaceEnabled) { - return {!isExposed && } - } else { - return : null} /> - } + if (isMarketplaceEnabled) return + return } /> } diff --git a/apps/studio/components/interfaces/Integrations/Vault/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Vault/OverviewTab.tsx new file mode 100644 index 00000000000..120e6f7c16b --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Vault/OverviewTab.tsx @@ -0,0 +1,10 @@ +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' +import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' + +export const VaultOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) return + return +} diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx index c88dac94694..371c1e5d51e 100644 --- a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx @@ -5,7 +5,7 @@ import { Admonition } from 'ui-patterns' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' -import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import NoPermission from '@/components/ui/NoPermission' @@ -14,12 +14,10 @@ import { useSchemasQuery } from '@/data/database/schemas-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -export const WebhooksOverviewTab = () => { +const WebhooksContent = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() - const isMarketplaceEnabled = useIsMarketplaceEnabled() - const { data: schemas, isSuccess: isSchemasLoaded, @@ -63,42 +61,39 @@ export const WebhooksOverviewTab = () => { ) } + if (isSchemasLoaded && isHooksEnabled) return null + + return ( + +

+ Database Webhooks can be used to trigger serverless functions or send requests to an HTTP + endpoint +

+ enableHooksForProject()} + disabled={isEnablingHooks} + tooltip={{ + content: { + side: 'bottom', + text: !canReadWebhooks + ? 'You need additional permissions to enable webhooks' + : undefined, + }, + }} + > + Enable webhooks + +
+ ) +} + +export const WebhooksOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + if (isMarketplaceEnabled) { - return - } else { - return ( - -

- Database Webhooks can be used to trigger serverless functions or send requests to an - HTTP endpoint -

- enableHooksForProject()} - disabled={isEnablingHooks} - tooltip={{ - content: { - side: 'bottom', - text: !canReadWebhooks - ? 'You need additional permissions to enable webhooks' - : undefined, - }, - }} - > - Enable webhooks - -
- ) - } - /> - ) + return } + + return } /> } diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx index 5854ba3f4d4..0c8ee3f9c59 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx @@ -7,7 +7,7 @@ import { Button, Sheet, SheetContent } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' -import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' import { useAvailableIntegrations } from '../Landing/useAvailableIntegrations' import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet' import { CreateWrapperSheet } from './CreateWrapperSheet' @@ -155,10 +155,9 @@ const AddNewWrapperCTA = () => { ) } -export const WrapperOverviewTab = () => { +export const WrapperContent = () => { const { id } = useParams() const { data: project } = useSelectedProjectQuery() - const isMarketplaceEnabled = useIsMarketplaceEnabled() const { data: integrations = [] } = useAvailableIntegrations() const integration = integrations.find((i) => i.id === id) @@ -183,24 +182,25 @@ export const WrapperOverviewTab = () => { ) } - if (isMarketplaceEnabled) { - return ( - - {isInstalled && ( - <> - - - - )} - - ) - } else { - return ( - }> -
- -
-
- ) - } + return ( + <> + + + {isInstalled && } + + ) +} + +export const WrapperOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) return + + return ( + }> +
+ +
+
+ ) } diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx index 2c2de5dd6e3..4b14d20689e 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx @@ -9,6 +9,7 @@ import { wrapperMetaComparator } from './Wrappers.utils' import { WrapperTable } from './WrapperTable' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { DocsButton } from '@/components/ui/DocsButton' import { useFDWsQuery } from '@/data/fdw/fdws-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -48,7 +49,7 @@ export const WrappersTab = () => { const Container = useCallback( ({ ...props }: { children: ReactNode } & HTMLProps) => ( -
+
{props.children} @@ -101,6 +102,23 @@ export const WrappersTab = () => { return ( +
+ + setCreateWrapperShown(true)} + disabled={!canCreateWrapper} + tooltip={{ + content: { + text: !canCreateWrapper + ? 'You need additional permissions to create a foreign data wrapper' + : undefined, + }, + }} + > + Add new wrapper + +
diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx index bdf33fee751..8641d799f47 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx @@ -24,10 +24,9 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { IntegrationOverviewTab } from '../../Integration/IntegrationOverviewTab' -import { IntegrationOverviewTabV2 } from '../../Integration/IntegrationOverviewTabV2' +import { RequiredExtensionsSection } from '../../Integration/RequiredExtensionsSection' import { InstallationError } from './InstallationError' import { IntegrationInstalledActions, IntegrationNotInstalledActions } from './IntegrationActions' -import { StatusDisplay } from './StatusDisplay' import { canInstall as checkCanInstall, hasInstallError, @@ -52,11 +51,10 @@ const installFormSchema = z.object({ stripeSecretKey: z.string().min(1, 'Stripe API key is required'), }) -export const StripeSyncEngineOverviewTab = () => { +const StripeSyncContent = () => { const track = useTrack() const hasTrackedInstallFailed = useRef(false) const { data: project } = useSelectedProjectQuery() - const isMarketplaceEnabled = useIsMarketplaceEnabled() const [showUninstallModal, setShowUninstallModal] = useState(false) const [shouldShowInstallSheet, setShouldShowInstallSheet] = useState(false) @@ -203,219 +201,167 @@ export const StripeSyncEngineOverviewTab = () => { return ( <> - {isMarketplaceEnabled ? ( - - {hasError && ( - + )} + + {!installed && !uninstalling && !uninstallError ? ( + <> + + + + ) : ( + (installed || uninstalling || uninstallError) && ( + <> + + - )} - - {!installed && !uninstalling && !uninstallError ? ( - - ) : ( - (installed || uninstalling || uninstallError) && ( - - ) - )} - - ) : ( - - ) : null - } - status={ - - } - actions={ - !installed && !uninstalling && !uninstallError ? ( - <> - - - - ) : installed || uninstalling || uninstallError ? ( - <> - - - - ) : null - } - > - - -
- { - if (!project?.ref) return - installStripeSync({ - projectRef: project.ref, - stripeSecretKey, - startTime: Date.now(), - }) - })} - className="overflow-auto grow px-0 flex flex-col" - > - - - {upgradeAvailable ? 'Upgrade' : 'Install'} Stripe Sync Engine - - - - - -

Configuration

- -
- ( - - - field.onChange(e.target.value)} - /> - - - )} - /> - -
- - -
-
- - {installRequestError && ( - - )} -
- - - - - -
- -
-
-
+ + ) )} + + + +
+ { + if (!project?.ref) return + installStripeSync({ + projectRef: project.ref, + stripeSecretKey, + startTime: Date.now(), + }) + })} + className="overflow-auto grow px-0 flex flex-col" + > + + + {upgradeAvailable ? 'Upgrade' : 'Install'} Stripe Sync Engine + + + + + +

Configuration

+ +
+ ( + + + field.onChange(e.target.value)} + /> + + + )} + /> + +
+ + +
+
+ + {installRequestError && ( + + )} +
+ + + + + +
+ +
+
+ { ) } + +export const StripeSyncEngineOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) { + return ( + <> + + + + ) + } + + return ( + +
+ +
+
+ ) +} From fdd6131567702e9f29b0170ace90eecfb55a2c82 Mon Sep 17 00:00:00 2001 From: Kostas Botsas Date: Mon, 25 May 2026 11:50:48 +0300 Subject: [PATCH 06/12] Clarify SLA types and eligible plans (#45397) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Support policy update with current offerings for Uptime and Support SLA for Teams and Enterprise customers. Applies latest prettier config formatting. ## Summary by CodeRabbit * **Documentation** * Clarified Service Level Agreement details to distinguish between "Uptime" SLAs (Enterprise-only) and "Support" SLAs (Teams and Enterprise). --- apps/www/pages/support-policy.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/www/pages/support-policy.mdx b/apps/www/pages/support-policy.mdx index bc037b438cc..9decc3cba6a 100644 --- a/apps/www/pages/support-policy.mdx +++ b/apps/www/pages/support-policy.mdx @@ -1,5 +1,5 @@ -import Layout from '../layouts/Layout' import SectionContainer from '../components/Layouts/SectionContainer' +import Layout from '../layouts/Layout' export const meta = { title: 'Support Policy', @@ -65,7 +65,7 @@ submitted through one of the official channels above. ## Service Level Agreement -Service Level Agreements are only available to Enterprise customers. You can details of our SLA including support response times on the dedicated page for this topic - +Uptime Service Level Agreements are only available to Enterprise customers. Support Service Level Agreements are available to Teams and Enterprise customers. You can find details of our SLA including support response times on the dedicated page for this topic - [https://supabase.com/sla](https://supabase.com/sla) ## Proactive Monitoring From d4274ea27e45caeb27eff40b9817cd9c5fc7acf0 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 25 May 2026 11:23:35 +0200 Subject: [PATCH 07/12] Fix wrapper forms design (#46283) ## Screenshots Before: image image After: image image ## Summary by CodeRabbit * **Style** * Clearer section separation using visual separators for wrapper creation/edit flows. * Adjusted column selector sizing for improved responsiveness. * Secure fields show a loading state while retrieving secrets. * **Refactor** * Unified form section layout across wrapper management for more consistent, scannable configuration panels. * Simplified data-target and schema areas for easier navigation and editing. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46283?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- .../Wrappers/CreateIcebergWrapperSheet.tsx | 314 ++++++++---------- .../Wrappers/CreateWrapperSheet.tsx | 276 +++++++-------- .../Wrappers/EditWrapperSheet.tsx | 152 ++++----- .../Wrappers/WrapperTableEditor.tsx | 2 +- 4 files changed, 338 insertions(+), 406 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx index 437857d3681..2e10cf991c2 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx @@ -4,8 +4,6 @@ import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, - Card, - CardContent, Form, FormControl, FormField, @@ -18,18 +16,15 @@ import { SheetTitle, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { - PageSection, - PageSectionContent, - PageSectionDescription, - PageSectionMeta, - PageSectionSummary, - PageSectionTitle, -} from 'ui-patterns/PageSection' import * as z from 'zod' import { CreateWrapperSheetProps } from './CreateWrapperSheet' import InputField from './InputField' +import { + FormSection, + FormSectionContent, + FormSectionLabel, +} from '@/components/ui/Forms/FormSection' import { useSchemaCreateMutation } from '@/data/database/schema-create-mutation' import { useSchemasQuery } from '@/data/database/schemas-query' import { useFDWCreateMutation } from '@/data/fdw/fdw-create-mutation' @@ -258,173 +253,144 @@ export const CreateIcebergWrapperSheet = ({ Create a {wrapperMeta.label} wrapper - - - - Wrapper Configuration - - - - - - ( - 0 ? ( - <> - Your wrapper's server name will be{' '} - {wrapperName}_server - - ) : ( - '' - ) - } - > - - - - - )} - /> - - - - - - - - Data target - - - - - - ( - -
- - -
-
-

- AWS S3 storage that's optimized for analytics workloads. -

-
-
-
- -
-
-

- Managed Apache Iceberg built directly into your R2 bucket. -

-
-
-
- -
-
-

- Can be used with any S3-compatible storage. -

-
-
-
-
-
-
- )} - /> -
-
-
-
- - - - - {wrapperMeta.label} Configuration - - - - - {targetOptions.map((option) => - option.hidden ? ( - - ) : ( - - - - ) + Wrapper Configuration}> + + ( + 0 ? ( + <> + Your wrapper's server name will be{' '} + {wrapperName}_server + + ) : ( + '' + ) + } + > + + + + )} - - - - - - - Foreign Schema - + /> + + + Data target}> + + ( + +
+ + +
+
+

+ AWS S3 storage that's optimized for analytics workloads. +

+
+
+
+ +
+
+

+ Managed Apache Iceberg built directly into your R2 bucket. +

+
+
+
+ +
+
+

+ Can be used with any S3-compatible storage. +

+
+
+
+
+
+
+ )} + /> +
+
+ + {wrapperMeta.label} Configuration} + > + + {targetOptions.map((option) => + option.hidden ? ( + + ) : ( + + ) + )} + + + +

Foreign Schema

+

You can query your data from the foreign tables in the specified schema after the wrapper is created. - - - - - - - {wrapperMeta.sourceSchemaOption && ( - - )} - - - - - - - +

+ + } + > + + {wrapperMeta.sourceSchemaOption && ( + + )} + + +
diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx index 39779c53d9d..26362ec67d6 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -6,35 +6,30 @@ import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form import { toast } from 'sonner' import { Button, - Card, - CardContent, Form, FormControl, FormField, Input, RadioGroupStacked, RadioGroupStackedItem, + Separator, SheetFooter, SheetHeader, - SheetSection, SheetTitle, WarningIcon, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { - PageSection, - PageSectionContent, - PageSectionDescription, - PageSectionMeta, - PageSectionSummary, - PageSectionTitle, -} from 'ui-patterns/PageSection' import * as z from 'zod' import InputField from './InputField' import { WrapperMeta } from './Wrappers.types' import { FormattedWrapperTable, getWrapperCreationFormSchema, NewTable } from './Wrappers.utils' import WrapperTableEditor from './WrapperTableEditor' +import { + FormSection, + FormSectionContent, + FormSectionLabel, +} from '@/components/ui/Forms/FormSection' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useSchemaCreateMutation } from '@/data/database/schema-create-mutation' import { invalidateSchemasQuery, useSchemasQuery } from '@/data/database/schemas-query' @@ -221,70 +216,51 @@ export const CreateWrapperSheet = ({ Create a {wrapperMeta.label} wrapper - - - - - Wrapper Configuration - - - - - - + Wrapper Configuration}> + + ( + ( - 0 ? ( - <> - Your wrapper's server name will be{' '} - {wrapper_name}_server - - ) : ( - '' - ) - } - > - - - - - )} - /> - - - - - - - - {wrapperMeta.label} Configuration - - - - - {wrapperMeta.server.options - .filter((option) => !option.hidden) - .map((option) => ( - - - - ))} - - - - - - - Data target - - - + description={ + wrapper_name.length > 0 ? ( + <> + Your wrapper's server name will be{' '} + {wrapper_name}_server + + ) : ( + '' + ) + } + > + + + + + )} + /> + + + + {wrapperMeta.label} Configuration} + > + + {wrapperMeta.server.options + .filter((option) => !option.hidden) + .map((option) => ( + + ))} + + + + Data target}> + )} /> - - + + + {mode === 'tables' && ( - - - - Foreign Tables - + +

Foreign Tables

+

You can query your data from these foreign tables after the wrapper is created - - - - - {tablesField.map((t, tableIndex) => { - // FIXME: make inference work - const table = t as unknown as FormattedWrapperTable - return ( -

-
-

- {table.schema_name}.{table.table_name} -

-

- Columns:{' '} - {(table.columns ?? []).map((column: any) => column.name).join(', ')} -

-
-
-
-
- ) - })} - -
- -
- {tablesField.length === 0 && errors.tables && ( -

- {errors.tables.message?.toString()}

- )} - -
+ + } + > + +
+ {tablesField.map((t, tableIndex) => { + // FIXME: make inference work + const table = t as unknown as FormattedWrapperTable + return ( +
+
+

+ {table.schema_name}.{table.table_name} +

+

+ Columns:{' '} + {(table.columns ?? []).map((column: any) => column.name).join(', ')} +

+
+
+
+
+ ) + })} + +
+ +
+ {tablesField.length === 0 && errors.tables && ( +

+ {errors.tables.message?.toString()} +

+ )} +
+
+ )} + {mode === 'schema' && ( - - - - Foreign Schema - + +

Foreign Schema

+

You can query your data from the foreign tables in the specified schema after the wrapper is created. - - - - +

+ + } + > + {wrapperMeta.sourceSchemaOption && !wrapperMeta.sourceSchemaOption?.readOnly && ( // Hide the field if the source schema is read-only @@ -462,10 +444,10 @@ export const CreateWrapperSheet = ({ control={form.control} />
- - + + )} - +