diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 78de85cb5b91b..712db4ab82876 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -89,6 +89,7 @@ Deji I Dennis Senn Dimitrios Liappis Div Arora +Dion Zeneli Divit D Divya Sharma Donna Alexandra @@ -318,6 +319,7 @@ Tyler Shukert TzeYiing L Utkarash Singh Victor Farazdagi +Warda Bibi Warwick Mitchell Wen Bo Xie Wendie Cheung diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts index 830692e2b2c62..ca320cd6ab682 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts @@ -16,6 +16,8 @@ import type { Permission } from '@/types' type AccessControlPermission = components['schemas']['AccessControlPermission'] type OrganizationResponse = components['schemas']['OrganizationResponse'] type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse'] +type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse'] +type OrganizationProject = OrganizationProjectsResponse['projects'][number] /** Satisfies both Studio's `Permission` type and the API's `AccessControlPermission` row shape. */ export type PermissionRowFixture = Permission & { @@ -73,7 +75,27 @@ export const ownerRows = (slug: string, refs: string[] = []) => [ ] export const MOCK_ORG = { slug: 'acme-prod', name: 'Acme Production' } +export const MOCK_ORG_2 = { slug: 'acme-staging', name: 'Acme Staging' } export const MOCK_PROJECT = { ref: 'project-1', name: 'Project 1' } +export const MOCK_PROJECT_2 = { ref: 'project-2', name: 'Project 2' } + +const toOrganizationProject = (project: { ref: string; name: string }): OrganizationProject => ({ + cloud_provider: 'AWS', + databases: [], + inserted_at: new Date().toISOString(), + integration_source: null, + is_branch: false, + name: project.name, + ref: project.ref, + region: 'us-east-1', + status: 'ACTIVE_HEALTHY', +}) + +/** Per-org project lists backing the `/platform/organizations/{slug}/projects` mock below. */ +const PROJECTS_BY_ORG: Record = { + [MOCK_ORG.slug]: [MOCK_PROJECT], + [MOCK_ORG_2.slug]: [MOCK_PROJECT_2], +} /** * Registers the GET mocks every scoped-token surface fires on mount: one organization @@ -103,6 +125,18 @@ export const mockScopedTokenEnvironment = () => { ], }), }) + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/projects', + response: ({ params }) => { + const slug = (params as { slug: string }).slug + const projects = (PROJECTS_BY_ORG[slug] ?? []).map(toOrganizationProject) + return HttpResponse.json({ + projects, + pagination: { count: projects.length, limit: 100, offset: 0 }, + }) + }, + }) addAPIMock({ method: 'get', // @ts-expect-error Studio API is missing from types diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx index d21edc783a82c..84f80fef84263 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx @@ -1,18 +1,26 @@ import { fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { platformComponents as components } from 'api-types' +import { HttpResponse } from 'msw' import { beforeEach, describe, expect, test, vi } from 'vitest' import { MOCK_ORG, + MOCK_ORG_2, MOCK_PROJECT, + MOCK_PROJECT_2, mockPermissionsApi, mockScopedTokenEnvironment, readonlyRows, } from '../../AccessToken.fixtures' import { NewScopedTokenSheet } from '../NewScopedTokenSheet' +import { createMockOrganizationResponse } from '@/tests/helpers' import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' import { createMockProfileContext } from '@/tests/lib/profile-helpers' +type OrganizationResponse = components['schemas']['OrganizationResponse'] + // Disabling orgs for project-scoped members reads /platform/profile/permissions, which only // fires on the platform for a logged-in user — neither is true in the default test environment. vi.mock('common', async (importOriginal) => { @@ -68,3 +76,57 @@ describe('ResourceAccessStep organization selector', () => { ).toBeNull() }) }) + +describe('ResourceAccessStep project selector', () => { + beforeEach(() => { + mockScopedTokenEnvironment() + }) + + const openTokenForm = async () => { + customRender( {}} />, { + profileContext: createMockProfileContext(), + }) + fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' })) + await screen.findByRole('dialog') + } + + const selectOrganization = async (name: string) => { + fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' })) + fireEvent.click(await screen.findByRole('option', { name })) + } + + test('loads projects scoped to the selected organization', async () => { + mockPermissionsApi(readonlyRows(MOCK_ORG.slug)) + await openTokenForm() + await selectOrganization(MOCK_ORG.name) + + fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' })) + expect(await screen.findByRole('option', { name: MOCK_PROJECT.name })).toBeInTheDocument() + }) + + // Regression test: the project list used to be fetched cross-org (a single page of the user's + // first 100 projects, filtered client-side by org), so switching to an org whose projects + // didn't fall in that page left the selector permanently empty. + test('refreshes the project list when switching organizations', async () => { + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: () => + HttpResponse.json([ + createMockOrganizationResponse({ slug: MOCK_ORG.slug, name: MOCK_ORG.name }), + createMockOrganizationResponse({ slug: MOCK_ORG_2.slug, name: MOCK_ORG_2.name }), + ]), + }) + mockPermissionsApi([...readonlyRows(MOCK_ORG.slug), ...readonlyRows(MOCK_ORG_2.slug)]) + + await openTokenForm() + await selectOrganization(MOCK_ORG.name) + fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' })) + expect(await screen.findByRole('option', { name: MOCK_PROJECT.name })).toBeInTheDocument() + + await selectOrganization(MOCK_ORG_2.name) + fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' })) + expect(await screen.findByRole('option', { name: MOCK_PROJECT_2.name })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: MOCK_PROJECT.name })).toBeNull() + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx index 0743b1c3201c8..e8678654068e5 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx @@ -30,10 +30,10 @@ import { InlineLinkClassName } from '@/components/ui/InlineLink' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { usePermissionsQuery } from '@/data/permissions/permissions-query' import { - ProjectInfoInfinite, - ProjectsInfiniteData, - useProjectsInfiniteQuery, -} from '@/data/projects/projects-infinite-query' + OrgProject, + OrgProjectsResponse, + useOrgProjectsInfiniteQuery, +} from '@/data/projects/org-projects-infinite-query' import { Organization } from '@/types' interface ResourceAccessStepProps { @@ -69,15 +69,21 @@ export const ResourceAccessStep = ({ onSelectLegacyToken, }: ResourceAccessStepProps) => { const { data: organizations = [] } = useOrganizationsQuery() + + const resourceAccess = useWatch({ control, name: 'resourceAccess' }) + const organizationSlugs = useWatch({ control, name: 'organizationSlugs', defaultValue: [] }) + const selectedOrgSlug = organizationSlugs[0] + const { data: projectsData, hasNextPage, fetchNextPage, - } = useProjectsInfiniteQuery({ + } = useOrgProjectsInfiniteQuery({ + slug: selectedOrgSlug, limit: 100, }) - const projects = useMemo( + const projectsForOrg = useMemo( () => projectsData?.pages.flatMap((page) => page.projects) ?? [], [projectsData] ) @@ -94,23 +100,20 @@ export const ResourceAccessStep = ({ ) const projectsByRef = useMemo( () => - projects.reduce( + projectsForOrg.reduce( (acc, project) => { acc[project.ref] = project return acc }, - {} as Record + {} as Record ), - [projects] + [projectsForOrg] ) - const resourceAccess = useWatch({ control, name: 'resourceAccess' }) - const organizationSlugs = useWatch({ control, name: 'organizationSlugs', defaultValue: [] }) - // Users invited to specific projects (rather than the whole org) can't select that org for an // org-wide token. Skipped while permissions are still loading so nothing gets disabled by - // mistake. The project list itself needs no permission filter — /platform/projects is already - // scoped server-side to what the user can access. + // mistake. The project list itself needs no permission filter — the org projects endpoint is + // already scoped server-side to what the user can access. const { data: permissions } = usePermissionsQuery() const projectScopedOrgSlugs = useMemo(() => { if (permissions === undefined) return new Set() @@ -121,11 +124,6 @@ export const ResourceAccessStep = ({ ) }, [permissions, organizations]) - const projectsForOrg = useMemo( - () => projects.filter((project) => organizationSlugs.includes(project.organization_slug)), - [projects, organizationSlugs] - ) - return (
void }) => { @@ -339,7 +337,7 @@ const ProjectMultiSelectList = ({ return ( {projects.map((project) => ( - + {project.name} ))} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx index 1b4c3072a2c3e..7f4480086bfeb 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx @@ -12,6 +12,7 @@ import { addAPIMock } from '@/tests/lib/msw' type OrganizationResponse = components['schemas']['OrganizationResponse'] type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse'] +type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse'] type CreateTokenResponse = components['schemas']['CreateScopedAccessTokenResponse'] type CreateClassicTokenResponse = components['schemas']['CreateAccessTokenResponse'] @@ -81,6 +82,29 @@ const mockProjects = () => }), }) +const mockOrgProjects = () => + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/projects', + response: () => + HttpResponse.json({ + pagination: { count: 1, limit: 100, offset: 0 }, + projects: [ + { + cloud_provider: 'AWS', + databases: [], + inserted_at: new Date().toISOString(), + integration_source: null, + is_branch: false, + name: 'Project 1', + ref: 'project-1', + region: 'us-east-1', + status: 'ACTIVE_HEALTHY', + }, + ], + }), + }) + const mockPermissionsMap = () => addAPIMock({ method: 'get', @@ -145,6 +169,7 @@ describe('NewScopedTokenSheet', () => { mockPermissionsMap() mockOrganizations() mockProjects() + mockOrgProjects() mockCreateToken() mockCreateClassicToken() }) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx index 4c66ffe335275..06943c0636f5f 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx @@ -1,5 +1,5 @@ import { useParams } from 'common' -import { Eye, EyeOff, Loader2 } from 'lucide-react' +import { Eye, EyeOff } from 'lucide-react' import { useState } from 'react' import { useWatch, type UseFormReturn } from 'react-hook-form' import { @@ -13,11 +13,11 @@ import { SelectItem, SelectSeparator, SelectTrigger, - WarningIcon, } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { SelectionListState } from 'ui-patterns/SelectionListState' import { CREATE_NEW_KEY, @@ -25,6 +25,11 @@ import { STORED_SECRET_PLACEHOLDER, } from '../DestinationForm.constants' import type { DestinationPanelSchemaType } from '../DestinationForm.schema' +import { + isMetadataListErrorVisible, + isMetadataListLoading, + useRefreshOnOpen, +} from '../useRefreshOnOpen' import { InlineLink } from '@/components/ui/InlineLink' import { useAnalyticsBucketsQuery } from '@/data/storage/analytics-buckets-query' import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query' @@ -52,6 +57,19 @@ const getS3AccessKeyTriggerLabel = ({ return value } +const getNamespaceTriggerLabel = ({ + canSelectNamespace, + value, +}: { + canSelectNamespace: boolean + value: string | undefined +}) => { + if (!canSelectNamespace) return 'Select a bucket first' + if (value === CREATE_NEW_NAMESPACE) return 'Create a new namespace' + + return value || 'Select a namespace' +} + export const AnalyticsBucketFields = ({ form, editMode, @@ -73,10 +91,14 @@ export const AnalyticsBucketFields = ({ const { data: keysData, isSuccess: isSuccessKeys, - isPending: isLoadingKeys, + isPending: isPendingKeys, + isFetching: isFetchingKeys, isError: isErrorKeys, + refetch: refetchKeys, } = useStorageCredentialsQuery({ projectRef }) const s3Keys = keysData?.data ?? [] + const isLoadingKeys = isMetadataListLoading(isPendingKeys || isFetchingKeys, s3Keys.length) + const isKeysErrorVisible = isMetadataListErrorVisible(isErrorKeys, s3Keys.length) const keyNoLongerExists = (s3AccessKeyId ?? '').length > 0 && s3AccessKeyId !== CREATE_NEW_KEY && @@ -84,20 +106,44 @@ export const AnalyticsBucketFields = ({ const { data: analyticsBuckets = [], - isPending: isLoadingBuckets, + isPending: isPendingBuckets, + isFetching: isFetchingBuckets, isError: isErrorBuckets, + refetch: refetchBuckets, } = useAnalyticsBucketsQuery({ projectRef }) + const isLoadingBuckets = isMetadataListLoading( + isPendingBuckets || isFetchingBuckets, + analyticsBuckets.length + ) + const isBucketsErrorVisible = isMetadataListErrorVisible(isErrorBuckets, analyticsBuckets.length) const canSelectNamespace = !!warehouseName const { data: namespaces = [], - isPending: isLoadingNamespaces, + isPending: isPendingNamespaces, + isFetching: isFetchingNamespaces, isError: isErrorNamespaces, + refetch: refetchNamespaces, } = useIcebergNamespacesQuery( { projectRef, warehouse: warehouseName }, { enabled: !!warehouseName } ) + const isLoadingNamespaces = isMetadataListLoading( + isPendingNamespaces || isFetchingNamespaces, + namespaces.length + ) + const isNamespacesErrorVisible = isMetadataListErrorVisible(isErrorNamespaces, namespaces.length) + const { handleOpenChange: handleRefreshBucketsOnOpen } = useRefreshOnOpen({ + refetch: refetchBuckets, + }) + const { handleOpenChange: handleRefreshNamespacesOnOpen } = useRefreshOnOpen({ + isEnabled: canSelectNamespace, + refetch: refetchNamespaces, + }) + const { handleOpenChange: handleRefreshKeysOnOpen } = useRefreshOnOpen({ + refetch: refetchKeys, + }) return (
@@ -113,61 +159,45 @@ export const AnalyticsBucketFields = ({ layout="horizontal" description="The Analytics Bucket where data will be stored" > - {isLoadingBuckets ? ( - - ) : isErrorBuckets ? ( -
} > - {isLoadingKeys ? ( - - ) : isErrorKeys ? ( - - ) - } - if (isErrorProjects) { - return ( - - ) - } return ( - {projectLabel(value) ?? placeholder} - {projects.length === 0 ? ( - - No active projects available + + {projects.map((project) => ( + +
+ {project.name} + + {project.ref} · {project.region} + +
- ) : ( - projects.map((project) => ( - -
- {project.name} - - {project.ref} · {project.region} - -
-
- )) - )} + ))}
@@ -785,8 +779,10 @@ const BucketSelection = ({ const { data: bucketsData, - isPending: isLoadingBuckets, + isPending: isPendingBuckets, + isFetching: isFetchingBuckets, isError: isErrorBuckets, + refetch: refetchBuckets, } = usePaginatedBucketsQuery( { projectRef: ducklakeStorageProjectRef }, { enabled: !!ducklakeStorageProjectRef } @@ -799,44 +795,24 @@ const BucketSelection = ({ ), [bucketsData] ) + const isBucketsErrorVisible = isMetadataListErrorVisible(isErrorBuckets, buckets.length) + const { handleOpenChange: handleRefreshBucketsOnOpen } = useRefreshOnOpen({ + isEnabled: !!ducklakeStorageProjectRef, + refetch: refetchBuckets, + }) if (!ducklakeStorageProjectRef) { return ( - - ) - } - if (isLoadingBuckets) { - return ( - - ) - } - if (isErrorBuckets) { - return ( - + ) } return ( diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.test.ts new file mode 100644 index 0000000000000..d3577c581291b --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { + isMetadataListErrorVisible, + isMetadataListLoading, + isMetadataValueLoading, +} from './useRefreshOnOpen' + +describe('replication metadata loading UI', () => { + it('shows a list skeleton only while pending or fetching an empty list', () => { + expect(isMetadataListLoading(true, 0)).toBe(true) + expect(isMetadataListLoading(true, 3)).toBe(false) + expect(isMetadataListLoading(false, 0)).toBe(false) + }) + + it('shows a list error only when the request failed and the list is empty', () => { + expect(isMetadataListErrorVisible(true, 0)).toBe(true) + expect(isMetadataListErrorVisible(true, 3)).toBe(false) + expect(isMetadataListErrorVisible(false, 0)).toBe(false) + }) + + it('shows a record skeleton only while fetching a missing value', () => { + expect(isMetadataValueLoading(true, undefined)).toBe(true) + expect(isMetadataValueLoading(true, { name: 'analytics' })).toBe(false) + expect(isMetadataValueLoading(false, undefined)).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.ts new file mode 100644 index 0000000000000..16394d33fa59a --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useRefreshOnOpen.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react' + +// Replication metadata (publication names, publication tables, source tables, +// columns) and similar destination-form option lists follow one rule: +// +// - Opening a picker always refetches. +// - Loading and error UI only appear when there is nothing to show, so a +// background refresh never replaces existing options with a skeleton. +// +// Pass `isPending || isFetching` into the loading helpers. `isPending` covers +// "query not started yet" (including while a parent id is still loading); +// `isFetching` covers an in-flight request. A populated list stays visible. + +export const isMetadataListLoading = (isPendingOrFetching: boolean, itemCount: number) => + isPendingOrFetching && itemCount === 0 + +export const isMetadataListErrorVisible = (isError: boolean, itemCount: number) => + isError && itemCount === 0 + +export const isMetadataValueLoading = (isPendingOrFetching: boolean, value: unknown) => + isPendingOrFetching && value == null + +interface UseRefreshOnOpenProps { + isEnabled?: boolean + refetch: () => unknown +} + +export const useRefreshOnOpen = ({ isEnabled = true, refetch }: UseRefreshOnOpenProps) => { + const handleOpenChange = useCallback( + (isOpen: boolean) => { + if (isOpen && isEnabled) void refetch() + }, + [isEnabled, refetch] + ) + + return { handleOpenChange } +} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx index 83f8d59289d2d..a5692cdce14e2 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx @@ -72,7 +72,13 @@ const mockUpdateCustomerProfile = () => { } const addRecipient = async (email: string) => { - const input = screen.getByPlaceholderText('Add additional recipients') + const input = screen + .getAllByRole('combobox') + .find((element): element is HTMLInputElement => element instanceof HTMLInputElement) + + expect(input).toBeDefined() + if (!input) return + await userEvent.click(input) await waitFor(() => expect(input).toHaveAttribute('aria-expanded', 'true')) // fireEvent.change (rather than userEvent.type) avoids racing the popover's open-state diff --git a/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx b/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx index 740ff099c64b2..2b9b91e3ce302 100644 --- a/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx +++ b/apps/studio/components/interfaces/ProjectHome/PlanUsageCard.tsx @@ -14,7 +14,6 @@ type MetricConfig = { key: PricingMetric label: string unit: MetricUnit - /** Anchor id of the matching section on the org usage page. */ anchor: string } @@ -40,26 +39,15 @@ const METRICS: MetricConfig[] = [ }, ] -const formatGigabytes = (value: number) => { - if (value === 0) return '0 GB' - if (value < 1) return `${(value * 1000).toFixed(0)} MB` - return `${value.toFixed(value < 10 ? 2 : 1)} GB` -} - -const formatGigabyteLimit = (limit: number) => { - if (limit < 1) return `${(limit * 1000).toFixed(0)} MB` - return `${limit} GB` -} - -// Show counts in full with thousands separators (e.g. `50,000`) rather than abbreviated -// (`50k`), to match the pricing page and avoid ambiguity around plan limits. const formatCount = (value: number) => value.toLocaleString() -const formatValue = (value: number, unit: MetricUnit) => - unit === 'gigabytes' ? formatGigabytes(value) : formatCount(value) - -const formatLimit = (limit: number, unit: MetricUnit) => - unit === 'gigabytes' ? formatGigabyteLimit(limit) : formatCount(limit) +const formatUsagePair = (value: number, limit: number, unit: MetricUnit) => { + if (unit === 'count') return { value: formatCount(value), limit: formatCount(limit) } + if (limit < 1) { + return { value: (value * 1000).toFixed(0), limit: `${(limit * 1000).toFixed(0)} MB` } + } + return { value: value === 0 ? '0' : value.toFixed(value < 10 ? 2 : 1), limit: `${limit} GB` } +} const RING_RADIUS = 7 const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS @@ -113,8 +101,6 @@ const ProgressRing = ({ ) } -// The upgrade CTA surface this card represents. Used as the telemetry `source` + -// `placement` value. Kept as a constant so the tracking stays explicit. const PLACEMENT = 'org_projects_list' const CompactMetricRow = ({ @@ -131,26 +117,25 @@ const CompactMetricRow = ({ const ratio = limit > 0 ? current / limit : 0 const isOver = limit > 0 && current >= limit const isApproaching = limit > 0 && ratio >= 0.8 && !isOver + const formatted = formatUsagePair(current, limit, config.unit) return ( -
+
- - {config.label} - + {config.label}
- + - {formatValue(current, config.unit)} + {formatted.value} / - {formatLimit(limit, config.unit)} + {formatted.limit} ( -
-
+
+
(
) -/** - * Renders the upgrade CTA's plan-usage card in the org project list (the - * `org_projects_list` CTA placement). The parent is responsible for gating on free plan — - * this component only renders the visual sections once usage data is available. Shaped - * like a `ProjectCard` so it reads as another tile. - */ export const PlanUsageCard = () => { const track = useTrack() const { data: organization } = useSelectedOrganizationQuery() @@ -199,21 +178,13 @@ export const PlanUsageCard = () => { }).filter((row): row is { config: MetricConfig; usageItem: OrgMetricsUsage } => row !== null) : [] - // Hide entirely on hard error or when the org has zero applicable metrics — both - // are extreme edge cases. Otherwise always render the card shell so the layout is - // reserved from first paint and the usage rows fade in once the query resolves. if (isError) return null if (isSuccess && visibleRows.length === 0) return null return (
  • -
    -
    +
    +
    Free plan usage

    Current billing cycle

    @@ -222,11 +193,12 @@ export const PlanUsageCard = () => { track('upgrade_cta_clicked', { placement: PLACEMENT })} />
    -
    +
    {isSuccess ? visibleRows.map(({ config, usageItem }) => ( { ) } +const LANGUAGES: Record = { + bash: 'bash', + html: 'html', + js: 'js', + json: 'json', + jsx: 'jsx', + sh: 'bash', + sql: 'sql', + toml: 'toml', + yaml: 'yaml', + yml: 'yaml', +} + +const languageFor = (fileName: string | undefined): CodeBlockLang => { + const normalized = fileName?.toLowerCase() ?? '' + if (normalized.startsWith('.env')) return 'bash' + if (normalized === 'deno.lock') return 'json' + + return LANGUAGES[normalized.split('.').pop() ?? ''] ?? 'ts' +} + const findFirstFile = (nodes: RegistryNode[]): RegistryNode | null => { for (const node of nodes) { if (node.type === 'file') { @@ -105,7 +126,7 @@ export function BlockItemCode({ files }: BlockItemCodeProps) { {selectedFile?.content} diff --git a/apps/ui-library/components/block-item.tsx b/apps/ui-library/components/block-item.tsx index 4285aea9e7f69..26c6d9504ec02 100644 --- a/apps/ui-library/components/block-item.tsx +++ b/apps/ui-library/components/block-item.tsx @@ -9,15 +9,16 @@ const Command = dynamic(() => import('./command').then((mod) => mod.Command), { interface BlockItemProps { name: string + showOpenInV0?: boolean } -export const BlockItem = ({ name }: BlockItemProps) => { +export const BlockItem = ({ name, showOpenInV0 = true }: BlockItemProps) => { const framework = name.includes('vue') || name.includes('nuxtjs') ? 'vue' : 'react' return (
    - + {showOpenInV0 && }
    ) } diff --git a/apps/ui-library/components/side-navigation.tsx b/apps/ui-library/components/side-navigation.tsx index b0932110c33b0..d24ee10c70ff7 100644 --- a/apps/ui-library/components/side-navigation.tsx +++ b/apps/ui-library/components/side-navigation.tsx @@ -3,7 +3,13 @@ import Link from 'next/link' import { CommandMenu } from './command-menu' import { ThemeSwitcherDropdown } from './theme-switcher-dropdown' import NavigationItem from '@/components/side-navigation-item' -import { componentPages, gettingStarted, oauthBlocks, platformBlocks } from '@/config/docs' +import { + componentPages, + gettingStarted, + mcpBlocks, + oauthBlocks, + platformBlocks, +} from '@/config/docs' function SideNavigation() { return ( @@ -96,6 +102,14 @@ function SideNavigation() { ))}
    +
    +
    + {mcpBlocks.title} +
    + {mcpBlocks.items.map((item, i) => ( + + ))} +
    {platformBlocks.title} diff --git a/apps/ui-library/config/docs.ts b/apps/ui-library/config/docs.ts index 3e496318a66fb..7d7f24bb650e3 100644 --- a/apps/ui-library/config/docs.ts +++ b/apps/ui-library/config/docs.ts @@ -50,6 +50,19 @@ export const oauthBlocks: SidebarNavGroup = { ], } +export const mcpBlocks: SidebarNavGroup = { + title: 'MCP', + items: [ + { + title: 'MCP Server', + href: '/docs/headless/mcp-server', + items: [], + new: true, + commandItemLabel: 'MCP Server', + }, + ], +} + // Component definitions with supported frameworks export const componentPages: SidebarNavGroup = { title: 'Components', @@ -151,6 +164,10 @@ export const COMMAND_ITEMS = [ label: item.commandItemLabel, href: item.href, })), + ...mcpBlocks.items.map((item) => ({ + label: item.commandItemLabel, + href: item.href, + })), ] // Framework titles for display diff --git a/apps/ui-library/content/docs/headless/mcp-server.mdx b/apps/ui-library/content/docs/headless/mcp-server.mdx new file mode 100644 index 0000000000000..fc63aaceac6be --- /dev/null +++ b/apps/ui-library/content/docs/headless/mcp-server.mdx @@ -0,0 +1,187 @@ +--- +title: MCP Server +description: Add a user-scoped MCP server to your product +--- + +Give embedded product agents and external clients such as Codex, Claude Code, +and ChatGPT secure, user-scoped access to your product through MCP tools. This +block runs as a Supabase Edge Function, verifies Supabase user access tokens, +and gives every tool an RLS-scoped client. + +## Installation + + + +Installs Deno Edge Function files into a Supabase project or empty directory. No +`components.json` is required. + +## Folder structure + + + +## Configure the project + +The function verifies access tokens itself, so disable the gateway JWT check: + +```toml +[functions.mcp-server] +verify_jwt = false +``` + +The project must sign JWTs with an asymmetric key. Projects that still use the +legacy HS256 secret do not expose signing keys from the JWKS endpoint, so the +function cannot authenticate embedded product sessions or external MCP clients. +Switch to an ES256 or RS256 key in +[JWT Keys](https://supabase.com/dashboard/project/_/settings/jwt). + +## Choose how agents authenticate + +### Embedded product agents + +A trusted product backend can forward its signed-in user's Supabase access +token as `Authorization: Bearer `. This reuses the product session, so +the user does not need to authorize their own product again. + +Keep the token inside your backend or agent orchestrator. Never place it in a +prompt or expose it directly to a model provider. + +### External MCP clients + +External clients authenticate with OAuth, so users approve and revoke each +client separately. Install the [OAuth Consent block](../nextjs/oauth-consent), +then enable OAuth in `supabase/config.toml`: + +```toml +[auth.oauth_server] +enabled = true +authorization_url_path = "/oauth/consent" +allow_dynamic_registration = true +``` + +Set the Auth **Site URL** to the origin that serves `/oauth/consent`. Use HTTPS +in production. Run `supabase config push` or restart the local stack to apply the +change. + +`allow_dynamic_registration` lets any compatible client register itself. Set it +to `false` if you register clients yourself. + +## Authentication + +`withOAuthProtectedResource` serves RFC 9728 metadata at +`/functions/v1/mcp-server/oauth-protected-resource` and adds a +`WWW-Authenticate` challenge to `401` responses so MCP clients can discover the +authorization server. + +`withSupabase({ auth: 'user' })` verifies the JWT and provides an RLS-scoped +client. It accepts both product session tokens and OAuth access tokens. OAuth +tokens include `client_id`; ordinary product sessions do not. The included +`whoami` tool exposes that difference. + +Any holder of a valid user token can call this function directly. Treat its +tools as an authenticated product API: keep RLS enabled, check authorization for +business operations, and do not add admin clients to the shared tool context. + +OAuth scopes control identity, not database or tool access. Use `client_id` for +client-specific policies when it is present, and define the intended behavior +for product sessions where it is null. Never use user-editable metadata for +authorization decisions. + +## Add tools + +Each tool module exports one registration function: + +```ts +// supabase/functions/mcp-server/tools/tasks.ts +import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0' +import { z } from 'npm:zod@4.4.3' + +import { jsonResult, runtimeErrorResult } from './result.ts' +import type { ToolContext } from './types.ts' + +export function registerTasksTools(server: McpServer, { supabase }: ToolContext): void { + server.registerTool( + 'close_task', + { + description: 'Mark a task as closed.', + inputSchema: z.object({ id: z.string().uuid() }), + annotations: { readOnlyHint: false, idempotentHint: true }, + }, + async ({ id }) => { + try { + const { data, error } = await supabase + .from('tasks') + .update({ closed: true }) + .eq('id', id) + .select() + if (error) throw error + return jsonResult(data) + } catch (error) { + return runtimeErrorResult(error) + } + } + ) +} +``` + +Then add one call in `tools/index.ts`, the server's composition point: + +```ts +import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0' + +import { registerTasksTools } from './tasks.ts' +import type { ToolContext } from './types.ts' +import { registerWhoamiTool } from './whoami.ts' + +export function registerTools(server: McpServer, context: ToolContext): void { + registerWhoamiTool(server, context) + registerTasksTools(server, context) +} +``` + +Each registration function receives: + +- `supabase`, a user-scoped client for Database, Auth, Storage, and Functions +- `userClaims`, the normalized signed-in user identity +- `jwtClaims`, including `client_id` when the caller used OAuth + +The context deliberately excludes `supabaseAdmin`. The MCP SDK rejects duplicate +tool names, and `jsonResult` returns both structured data and a text fallback for +older clients. + +For typed table and column autocomplete, generate `database.types.ts` and make +the `SupabaseClient` in `tools/types.ts` a `SupabaseClient`. + +## Environment + +| Variable | Default | Purpose | +| ------------------------ | ---------------- | -------------------------------- | +| `MCP_SERVER_NAME` | `supabase-mcp` | Server name shown to MCP clients | +| `MCP_SERVER_DESCRIPTION` | Generic sentence | Instructions shown to clients | + +## Deploy + +Check the function before serving or deploying it: + +```bash +cd supabase/functions/mcp-server +deno task check +cd ../../.. +supabase functions serve mcp-server --env-file supabase/functions/.env +``` + +Then deploy: + +```bash +supabase config push +supabase functions deploy mcp-server +``` + +## Further reading + +- [OAuth Consent block](../nextjs/oauth-consent) +- [OAuth protected resource middleware](https://supabase.com/docs/reference/server/middleware-withoauthprotectedresource) +- [MCP authentication](https://supabase.com/docs/guides/auth/oauth-server/mcp-authentication) +- [OAuth 2.1 server](https://supabase.com/docs/guides/auth/oauth-server/getting-started) +- [Token security and RLS](https://supabase.com/docs/guides/auth/oauth-server/token-security) +- [OAuth grant management](https://supabase.com/docs/guides/auth/oauth-server/oauth-flows#managing-user-grants) +- [Edge Functions](https://supabase.com/docs/guides/functions) diff --git a/apps/ui-library/public/r/mcp-server.json b/apps/ui-library/public/r/mcp-server.json new file mode 100644 index 0000000000000..d0a1b02387b22 --- /dev/null +++ b/apps/ui-library/public/r/mcp-server.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "mcp-server", + "type": "registry:item", + "title": "MCP Server", + "description": "Add a user-scoped MCP server to your product.", + "files": [ + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts", + "content": "import 'jsr:@supabase/functions-js@2.108.2/edge-runtime.d.ts'\n\nimport { createMcpHandler, McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'\nimport {\n withOAuthProtectedResource,\n withSupabase,\n type SupabaseContext,\n} from 'npm:@supabase/server@1.5.1'\n\nimport { registerTools, type ToolContext } from './tools/index.ts'\n\n// An MCP server as a single Supabase Edge Function. withSupabase accepts any\n// verified user access token and builds an RLS-scoped client, so both embedded\n// product agents and external OAuth clients can act as the signed-in user.\n//\n// withOAuthProtectedResource adds OAuth discovery for external MCP clients and\n// points authentication failures at it. Tools are composed in ./tools/index.ts.\n\nfunction readTextEnv(name: string, fallback: string): string {\n return Deno.env.get(name)?.trim() || fallback\n}\n\nconst SERVER_NAME = readTextEnv('MCP_SERVER_NAME', 'supabase-mcp')\nconst SERVER_DESCRIPTION = readTextEnv(\n 'MCP_SERVER_DESCRIPTION',\n 'MCP access to this Supabase project for the signed-in user.'\n)\n\nconst SERVER_INSTRUCTIONS =\n `${SERVER_DESCRIPTION} ` +\n 'Every tool runs as the signed-in Supabase user, so role grants and Row Level Security apply. ' +\n \"Call tools/list to discover what this project exposes, and read a tool's description and \" +\n 'annotations before calling it — some tools have side effects.'\n\nconst CORS_HEADERS: Record = {\n 'Access-Control-Allow-Origin': '*',\n 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',\n 'Access-Control-Allow-Headers':\n 'Authorization, Content-Type, Accept, Mcp-Protocol-Version, Mcp-Session-Id, Mcp-Method, Mcp-Name',\n 'Access-Control-Expose-Headers': 'WWW-Authenticate, Mcp-Session-Id',\n}\n\nfunction createServer(context: ToolContext): McpServer {\n const server = new McpServer(\n { name: SERVER_NAME, version: '1.0.0' },\n { instructions: SERVER_INSTRUCTIONS }\n )\n\n registerTools(server, context)\n return server\n}\n\nasync function handleMcp(request: Request, ctx: SupabaseContext): Promise {\n // The server and its tools are bound to this caller for exactly one request.\n const handler = createMcpHandler(\n () =>\n createServer({\n supabase: ctx.supabase,\n // auth: 'user' guarantees both claim shapes before this handler runs.\n userClaims: ctx.userClaims!,\n jwtClaims: ctx.jwtClaims!,\n }),\n { onerror: (error) => console.error('MCP request failed', error) }\n )\n\n return handler.fetch(request)\n}\n\nDeno.serve(\n withOAuthProtectedResource(\n withSupabase({ auth: 'user', cors: { headers: CORS_HEADERS } }, handleMcp)\n )\n)\n", + "type": "registry:file", + "target": "supabase/functions/mcp-server/index.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json", + "content": "{\n \"nodeModulesDir\": \"none\",\n \"compilerOptions\": {\n \"strict\": true\n },\n \"tasks\": {\n \"check\": \"deno check index.ts\"\n }\n}\n", + "type": "registry:file", + "target": "supabase/functions/mcp-server/deno.json" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example", + "content": "# Copy this file to supabase/functions/.env before serving locally:\n# cp supabase/functions/mcp-server/.env.example supabase/functions/.env\n# supabase functions serve mcp-server --env-file supabase/functions/.env\n\n# Keep the protocol-level server name short and project-specific.\nMCP_SERVER_NAME=supabase-mcp\nMCP_SERVER_DESCRIPTION=\"MCP access to this Supabase project for the signed-in user.\"\n", + "type": "registry:file", + "target": "supabase/functions/mcp-server/.env.example" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts", + "content": "import type { SupabaseContext } from 'npm:@supabase/server@1.5.1'\nimport type { SupabaseClient } from 'npm:@supabase/supabase-js@2.108.2'\n\n// Only expose the user-scoped client and verified identity to tools. Keeping\n// supabaseAdmin out of this type makes bypassing RLS an explicit design choice.\nexport type ToolContext = {\n supabase: SupabaseClient\n userClaims: NonNullable\n jwtClaims: NonNullable\n}\n", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/types.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts", + "content": "import type { CallToolResult } from 'npm:@modelcontextprotocol/server@2.0.0'\n\n// Shared helpers for building MCP tool results, so every tool returns the same\n// shape and signals failure the same way.\n\n/**\n * A successful structured result with a JSON text fallback for older clients.\n */\nexport function jsonResult(value: unknown): CallToolResult {\n return {\n content: [{ type: 'text', text: JSON.stringify(value) ?? 'null' }],\n structuredContent: value ?? null,\n }\n}\n\n/**\n * A failed result. The message goes back to the model so it can correct itself,\n * so keep it actionable — and free of credentials, claims, and stack traces.\n */\nexport function errorResult(message: string): CallToolResult {\n return {\n isError: true,\n content: [{ type: 'text', text: message }],\n }\n}\n\nfunction readString(value: unknown, key: string): string | null {\n if (!value || typeof value !== 'object' || !(key in value)) return null\n const property = (value as Record)[key]\n return typeof property === 'string' && property ? property : null\n}\n\n/**\n * Turn an unknown thrown value into a safe MCP error. Supabase API errors often\n * carry a `code` and `hint`, both of which help a model fix its next call.\n */\nexport function runtimeErrorResult(error: unknown): CallToolResult {\n const message = error instanceof Error ? error.message : String(error)\n const code = readString(error, 'code')\n const hint = readString(error, 'hint')\n\n return errorResult(\n [code ? `[${code}]` : null, message, hint ? `Hint: ${hint}` : null].filter(Boolean).join(' ')\n )\n}\n", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/result.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts", + "content": "import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'\n\nimport { jsonResult } from './result.ts'\nimport type { ToolContext } from './types.ts'\n\n// Answers from verified claims, demonstrating that every tool runs as the\n// signed-in user. client_id is present for OAuth tokens and null for ordinary\n// product sessions.\nexport function registerWhoamiTool(\n server: McpServer,\n { userClaims, jwtClaims }: ToolContext\n): void {\n const clientId =\n typeof jwtClaims?.client_id === 'string' && jwtClaims.client_id ? jwtClaims.client_id : null\n\n server.registerTool(\n 'whoami',\n {\n description: \"Return the signed-in user's identity and OAuth client id, when present.\",\n annotations: {\n readOnlyHint: true,\n destructiveHint: false,\n openWorldHint: false,\n },\n },\n () =>\n jsonResult({\n id: userClaims.id,\n email: userClaims.email ?? null,\n role: userClaims.role ?? null,\n client_id: clientId,\n })\n )\n}\n", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/whoami.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts", + "content": "import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'\n\nimport type { ToolContext } from './types.ts'\nimport { registerWhoamiTool } from './whoami.ts'\n\nexport type { ToolContext } from './types.ts'\n\n// The one composition point for this server. Add one registration call for\n// each tool module; the MCP SDK rejects duplicate protocol tool names.\nexport function registerTools(server: McpServer, context: ToolContext): void {\n registerWhoamiTool(server, context)\n}\n", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/index.ts" + } + ], + "docs": "Disable gateway JWT verification, then deploy the Edge Function. A trusted product backend can call it with the signed-in user's access token. For external clients, install the [OAuth Consent block](https://supabase.com/library/docs/nextjs/oauth-consent), enable OAuth and dynamic registration, and set the Auth Site URL to the consent app. Every call runs through the user's RLS-scoped client. OAuth tokens include `client_id`; product sessions do not, so define policies for both paths. See [MCP authentication](https://supabase.com/docs/guides/auth/oauth-server/mcp-authentication) and [token security](https://supabase.com/docs/guides/auth/oauth-server/token-security)." +} \ No newline at end of file diff --git a/apps/ui-library/public/r/registry.json b/apps/ui-library/public/r/registry.json index 4afb6f79ca526..297a50fdbdbaa 100644 --- a/apps/ui-library/public/r/registry.json +++ b/apps/ui-library/public/r/registry.json @@ -1746,6 +1746,50 @@ } ] }, + { + "name": "mcp-server", + "type": "registry:item", + "title": "MCP Server", + "description": "Add a user-scoped MCP server to your product.", + "docs": "Disable gateway JWT verification, then deploy the Edge Function. A trusted product backend can call it with the signed-in user's access token. For external clients, install the [OAuth Consent block](https://supabase.com/library/docs/nextjs/oauth-consent), enable OAuth and dynamic registration, and set the Auth Site URL to the consent app. Every call runs through the user's RLS-scoped client. OAuth tokens include `client_id`; product sessions do not, so define policies for both paths. See [MCP authentication](https://supabase.com/docs/guides/auth/oauth-server/mcp-authentication) and [token security](https://supabase.com/docs/guides/auth/oauth-server/token-security).", + "files": [ + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/index.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json", + "type": "registry:file", + "target": "supabase/functions/mcp-server/deno.json" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example", + "type": "registry:file", + "target": "supabase/functions/mcp-server/.env.example" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/types.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/result.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/whoami.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/index.ts" + } + ] + }, { "name": "oauth-consent-nextjs", "type": "registry:block", diff --git a/apps/ui-library/registry/blocks.ts b/apps/ui-library/registry/blocks.ts index f1c045df99922..73fcff5d5fc5f 100644 --- a/apps/ui-library/registry/blocks.ts +++ b/apps/ui-library/registry/blocks.ts @@ -4,6 +4,7 @@ import { clients } from './clients' import currentUserAvatar from './default/blocks/current-user-avatar/registry-item.json' with { type: 'json' } import dropzone from './default/blocks/dropzone/registry-item.json' with { type: 'json' } import infiniteQueryHook from './default/blocks/infinite-query-hook/registry-item.json' with { type: 'json' } +import mcpServer from './default/blocks/mcp-server/registry-item.json' with { type: 'json' } import oauthConsentNextjs from './default/blocks/oauth-consent-nextjs/registry-item.json' with { type: 'json' } import oauthConsentReactRouter from './default/blocks/oauth-consent-react-router/registry-item.json' with { type: 'json' } import oauthConsentReact from './default/blocks/oauth-consent-react/registry-item.json' with { type: 'json' } @@ -70,6 +71,10 @@ export const blocks = [ // infinite query hook is intentionally not combined with the clients since it depends on clients having database types. infiniteQueryHook as RegistryItem, + // Backend-only Deno Edge Function block. Every file has an explicit target, + // so it can be installed directly into a Supabase project. + mcpServer as RegistryItem, + withClientAndDocs(oauthConsentNextjs as RegistryItem, nextjsClient!), withClientAndDocs(oauthConsentReact as RegistryItem, reactClient!), withClientAndDocs(oauthConsentReactRouter as RegistryItem, reactRouterClient!), diff --git a/apps/ui-library/registry/default/blocks/mcp-server/registry-item.json b/apps/ui-library/registry/default/blocks/mcp-server/registry-item.json new file mode 100644 index 0000000000000..db05367a31b9e --- /dev/null +++ b/apps/ui-library/registry/default/blocks/mcp-server/registry-item.json @@ -0,0 +1,44 @@ +{ + "name": "mcp-server", + "type": "registry:item", + "title": "MCP Server", + "description": "Add a user-scoped MCP server to your product.", + "docs": "Disable gateway JWT verification, then deploy the Edge Function. A trusted product backend can call it with the signed-in user's access token. For external clients, install the [OAuth Consent block](https://supabase.com/library/docs/nextjs/oauth-consent), enable OAuth and dynamic registration, and set the Auth Site URL to the consent app. Every call runs through the user's RLS-scoped client. OAuth tokens include `client_id`; product sessions do not, so define policies for both paths. See [MCP authentication](https://supabase.com/docs/guides/auth/oauth-server/mcp-authentication) and [token security](https://supabase.com/docs/guides/auth/oauth-server/token-security).", + "files": [ + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/index.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json", + "type": "registry:file", + "target": "supabase/functions/mcp-server/deno.json" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example", + "type": "registry:file", + "target": "supabase/functions/mcp-server/.env.example" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/types.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/result.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/whoami.ts" + }, + { + "path": "registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/tools/index.ts" + } + ] +} diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example new file mode 100644 index 0000000000000..3e99789d9ee5a --- /dev/null +++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/.env.example @@ -0,0 +1,7 @@ +# Copy this file to supabase/functions/.env before serving locally: +# cp supabase/functions/mcp-server/.env.example supabase/functions/.env +# supabase functions serve mcp-server --env-file supabase/functions/.env + +# Keep the protocol-level server name short and project-specific. +MCP_SERVER_NAME=supabase-mcp +MCP_SERVER_DESCRIPTION="MCP access to this Supabase project for the signed-in user." diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json new file mode 100644 index 0000000000000..193d6e74a80d6 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/deno.json @@ -0,0 +1,9 @@ +{ + "nodeModulesDir": "none", + "compilerOptions": { + "strict": true + }, + "tasks": { + "check": "deno check index.ts" + } +} diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts new file mode 100644 index 0000000000000..95278a36be263 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts @@ -0,0 +1,73 @@ +import 'jsr:@supabase/functions-js@2.108.2/edge-runtime.d.ts' + +import { createMcpHandler, McpServer } from 'npm:@modelcontextprotocol/server@2.0.0' +import { + withOAuthProtectedResource, + withSupabase, + type SupabaseContext, +} from 'npm:@supabase/server@1.5.1' + +import { registerTools, type ToolContext } from './tools/index.ts' + +// An MCP server as a single Supabase Edge Function. withSupabase accepts any +// verified user access token and builds an RLS-scoped client, so both embedded +// product agents and external OAuth clients can act as the signed-in user. +// +// withOAuthProtectedResource adds OAuth discovery for external MCP clients and +// points authentication failures at it. Tools are composed in ./tools/index.ts. + +function readTextEnv(name: string, fallback: string): string { + return Deno.env.get(name)?.trim() || fallback +} + +const SERVER_NAME = readTextEnv('MCP_SERVER_NAME', 'supabase-mcp') +const SERVER_DESCRIPTION = readTextEnv( + 'MCP_SERVER_DESCRIPTION', + 'MCP access to this Supabase project for the signed-in user.' +) + +const SERVER_INSTRUCTIONS = + `${SERVER_DESCRIPTION} ` + + 'Every tool runs as the signed-in Supabase user, so role grants and Row Level Security apply. ' + + "Call tools/list to discover what this project exposes, and read a tool's description and " + + 'annotations before calling it — some tools have side effects.' + +const CORS_HEADERS: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': + 'Authorization, Content-Type, Accept, Mcp-Protocol-Version, Mcp-Session-Id, Mcp-Method, Mcp-Name', + 'Access-Control-Expose-Headers': 'WWW-Authenticate, Mcp-Session-Id', +} + +function createServer(context: ToolContext): McpServer { + const server = new McpServer( + { name: SERVER_NAME, version: '1.0.0' }, + { instructions: SERVER_INSTRUCTIONS } + ) + + registerTools(server, context) + return server +} + +async function handleMcp(request: Request, ctx: SupabaseContext): Promise { + // The server and its tools are bound to this caller for exactly one request. + const handler = createMcpHandler( + () => + createServer({ + supabase: ctx.supabase, + // auth: 'user' guarantees both claim shapes before this handler runs. + userClaims: ctx.userClaims!, + jwtClaims: ctx.jwtClaims!, + }), + { onerror: (error) => console.error('MCP request failed', error) } + ) + + return handler.fetch(request) +} + +Deno.serve( + withOAuthProtectedResource( + withSupabase({ auth: 'user', cors: { headers: CORS_HEADERS } }, handleMcp) + ) +) diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts new file mode 100644 index 0000000000000..8106f70d70f88 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/index.ts @@ -0,0 +1,12 @@ +import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0' + +import type { ToolContext } from './types.ts' +import { registerWhoamiTool } from './whoami.ts' + +export type { ToolContext } from './types.ts' + +// The one composition point for this server. Add one registration call for +// each tool module; the MCP SDK rejects duplicate protocol tool names. +export function registerTools(server: McpServer, context: ToolContext): void { + registerWhoamiTool(server, context) +} diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts new file mode 100644 index 0000000000000..33b7071810cb4 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/result.ts @@ -0,0 +1,45 @@ +import type { CallToolResult } from 'npm:@modelcontextprotocol/server@2.0.0' + +// Shared helpers for building MCP tool results, so every tool returns the same +// shape and signals failure the same way. + +/** + * A successful structured result with a JSON text fallback for older clients. + */ +export function jsonResult(value: unknown): CallToolResult { + return { + content: [{ type: 'text', text: JSON.stringify(value) ?? 'null' }], + structuredContent: value ?? null, + } +} + +/** + * A failed result. The message goes back to the model so it can correct itself, + * so keep it actionable — and free of credentials, claims, and stack traces. + */ +export function errorResult(message: string): CallToolResult { + return { + isError: true, + content: [{ type: 'text', text: message }], + } +} + +function readString(value: unknown, key: string): string | null { + if (!value || typeof value !== 'object' || !(key in value)) return null + const property = (value as Record)[key] + return typeof property === 'string' && property ? property : null +} + +/** + * Turn an unknown thrown value into a safe MCP error. Supabase API errors often + * carry a `code` and `hint`, both of which help a model fix its next call. + */ +export function runtimeErrorResult(error: unknown): CallToolResult { + const message = error instanceof Error ? error.message : String(error) + const code = readString(error, 'code') + const hint = readString(error, 'hint') + + return errorResult( + [code ? `[${code}]` : null, message, hint ? `Hint: ${hint}` : null].filter(Boolean).join(' ') + ) +} diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts new file mode 100644 index 0000000000000..e88542238b54b --- /dev/null +++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/types.ts @@ -0,0 +1,10 @@ +import type { SupabaseContext } from 'npm:@supabase/server@1.5.1' +import type { SupabaseClient } from 'npm:@supabase/supabase-js@2.108.2' + +// Only expose the user-scoped client and verified identity to tools. Keeping +// supabaseAdmin out of this type makes bypassing RLS an explicit design choice. +export type ToolContext = { + supabase: SupabaseClient + userClaims: NonNullable + jwtClaims: NonNullable +} diff --git a/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts new file mode 100644 index 0000000000000..f01872073e49a --- /dev/null +++ b/apps/ui-library/registry/default/blocks/mcp-server/supabase/functions/mcp-server/tools/whoami.ts @@ -0,0 +1,34 @@ +import type { McpServer } from 'npm:@modelcontextprotocol/server@2.0.0' + +import { jsonResult } from './result.ts' +import type { ToolContext } from './types.ts' + +// Answers from verified claims, demonstrating that every tool runs as the +// signed-in user. client_id is present for OAuth tokens and null for ordinary +// product sessions. +export function registerWhoamiTool( + server: McpServer, + { userClaims, jwtClaims }: ToolContext +): void { + const clientId = + typeof jwtClaims?.client_id === 'string' && jwtClaims.client_id ? jwtClaims.client_id : null + + server.registerTool( + 'whoami', + { + description: "Return the signed-in user's identity and OAuth client id, when present.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }, + () => + jsonResult({ + id: userClaims.id, + email: userClaims.email ?? null, + role: userClaims.role ?? null, + client_id: clientId, + }) + ) +} diff --git a/apps/ui-library/tsconfig.json b/apps/ui-library/tsconfig.json index 6d096180fd06a..ea4050eb454c3 100644 --- a/apps/ui-library/tsconfig.json +++ b/apps/ui-library/tsconfig.json @@ -17,5 +17,9 @@ ".next/types/**/*.ts", ".contentlayer/generated" ], - "exclude": ["node_modules", "./scripts/build-registry.mts"] + "exclude": [ + "node_modules", + "./scripts/build-registry.mts", + "registry/default/blocks/*/supabase/**" + ] } diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 8ecf537b45c8a..9acb4eed9a0ee 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -582,6 +582,14 @@ "import": "./src/Row/index.tsx", "types": "./src/Row/index.tsx" }, + "./SelectionListState/SelectionListState": { + "import": "./src/SelectionListState/SelectionListState.tsx", + "types": "./src/SelectionListState/SelectionListState.tsx" + }, + "./SelectionListState": { + "import": "./src/SelectionListState/index.ts", + "types": "./src/SelectionListState/index.ts" + }, "./ShimmeringLoader/index.css": { "import": "./src/ShimmeringLoader/index.css", "types": "./src/ShimmeringLoader/index.css" diff --git a/packages/ui-patterns/src/SelectionListState/SelectionListState.test.tsx b/packages/ui-patterns/src/SelectionListState/SelectionListState.test.tsx new file mode 100644 index 0000000000000..2cac72c194c9c --- /dev/null +++ b/packages/ui-patterns/src/SelectionListState/SelectionListState.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { SelectionListState } from './SelectionListState' + +describe('SelectionListState', () => { + it('keeps one persistent live region while its state changes', () => { + const { container, rerender } = render() + + const liveRegion = container.querySelector('[aria-live="polite"]') + expect(liveRegion).not.toBeNull() + if (!liveRegion) throw new Error('Expected an aria-live status region') + expect(liveRegion).toHaveAttribute('aria-live', 'polite') + expect(liveRegion).toHaveClass('sr-only') + expect(liveRegion).toHaveTextContent('Loading options') + + rerender() + + expect(screen.getByText('No columns found')).toBe(liveRegion) + expect(liveRegion).not.toHaveClass('sr-only') + }) +}) diff --git a/packages/ui-patterns/src/SelectionListState/SelectionListState.tsx b/packages/ui-patterns/src/SelectionListState/SelectionListState.tsx new file mode 100644 index 0000000000000..37eeaf58275ef --- /dev/null +++ b/packages/ui-patterns/src/SelectionListState/SelectionListState.tsx @@ -0,0 +1,49 @@ +import { cn } from 'ui' + +import { GenericSelectionSkeletonLoader } from '../ShimmeringLoader' + +interface SelectionListStateProps { + className?: string + emptyLabel?: string + errorLabel?: string + isEmpty?: boolean + isError?: boolean + isLoading?: boolean + skeletonVariant?: 'command' | 'multi-select' | 'select' +} + +export const SelectionListState = ({ + className, + emptyLabel = 'No options available', + errorLabel = 'Unable to load options', + isEmpty = false, + isError = false, + isLoading = false, + skeletonVariant = 'select', +}: SelectionListStateProps) => { + let statusLabel: string | undefined + if (isLoading) statusLabel = 'Loading options' + else if (isError) statusLabel = errorLabel + else if (isEmpty) statusLabel = emptyLabel + + return ( + <> + {isLoading && ( + + )} +
    + {statusLabel} +
    + + ) +} diff --git a/packages/ui-patterns/src/SelectionListState/index.ts b/packages/ui-patterns/src/SelectionListState/index.ts new file mode 100644 index 0000000000000..58af712bc1272 --- /dev/null +++ b/packages/ui-patterns/src/SelectionListState/index.ts @@ -0,0 +1 @@ +export * from './SelectionListState' diff --git a/packages/ui-patterns/src/ShimmeringLoader/index.css b/packages/ui-patterns/src/ShimmeringLoader/index.css index d538577cbf891..00ca24a44625f 100644 --- a/packages/ui-patterns/src/ShimmeringLoader/index.css +++ b/packages/ui-patterns/src/ShimmeringLoader/index.css @@ -29,3 +29,10 @@ background-position: 1000px 0; } } + +@media (prefers-reduced-motion: reduce) { + .shimmering-loader, + .dark .shimmering-loader { + animation: none; + } +} diff --git a/packages/ui-patterns/src/ShimmeringLoader/index.tsx b/packages/ui-patterns/src/ShimmeringLoader/index.tsx index 59d229a8f22e1..02192b946505c 100644 --- a/packages/ui-patterns/src/ShimmeringLoader/index.tsx +++ b/packages/ui-patterns/src/ShimmeringLoader/index.tsx @@ -37,6 +37,42 @@ export const GenericSkeletonLoader = ({ className }: GenericSkeletonLoaderProps)
    ) +interface GenericSelectionSkeletonLoaderProps extends GenericSkeletonLoaderProps { + // Selection primitives have different row indicators: command lists have none, Select uses + // radio circles, and MultiSelector uses checkboxes. + variant?: 'command' | 'multi-select' | 'select' +} + +export const GenericSelectionSkeletonLoader = ({ + className, + variant = 'multi-select', +}: GenericSelectionSkeletonLoaderProps) => { + const hasIndicator = variant !== 'command' + const isSelect = variant === 'select' + + return ( + + ) +} + export const GenericTableLoader = ({ headers = [], numRows = 3, diff --git a/packages/ui-patterns/src/multi-select/multi-select.test.tsx b/packages/ui-patterns/src/multi-select/multi-select.test.tsx index 6fa101b670df8..5dfed06154b94 100644 --- a/packages/ui-patterns/src/multi-select/multi-select.test.tsx +++ b/packages/ui-patterns/src/multi-select/multi-select.test.tsx @@ -61,11 +61,13 @@ describe('multi-select', () => { it('renders selected values with a custom label', () => { render( undefined}> - `public.table_${value}`} /> + `Public.MixedCase_${value}`} /> ) - expect(screen.getByRole('combobox')).toHaveTextContent('public.table_101') + const badge = screen.getByText('Public.MixedCase_101').closest('[class*=rounded]') + expect(screen.getByRole('combobox')).toHaveTextContent('Public.MixedCase_101') + expect(badge).toHaveClass('normal-case', 'tracking-normal') }) it('opens the dropdown when the MultiSelectorTrigger is clicked', () => { @@ -78,6 +80,45 @@ describe('multi-select', () => { expect(screen.getByText('Apple')).toBeInTheDocument() // Apple should be visible in the dropdown }) + it('shows loading rows only inside the open dropdown', () => { + render( + undefined}> + + + + Apple + + + + ) + + expect(document.querySelector('.shimmering-loader')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('combobox')) + + expect(document.querySelector('.shimmering-loader')).toBeInTheDocument() + expect(screen.queryByText('Apple')).not.toBeInTheDocument() + }) + + it('shows a custom loading error instead of an empty result', () => { + render( + undefined}> + + + + Apple + + + + ) + + fireEvent.click(screen.getByRole('combobox')) + + expect(screen.getByText('Unable to load fruits')).toBeInTheDocument() + expect(screen.queryByText('Apple')).not.toBeInTheDocument() + expect(screen.queryByText('No results found')).not.toBeInTheDocument() + }) + it('adds and removes value when toggling MultiSelectorItem', () => { render() diff --git a/packages/ui-patterns/src/multi-select/multi-select.tsx b/packages/ui-patterns/src/multi-select/multi-select.tsx index b74627751c21b..d19adecb19170 100644 --- a/packages/ui-patterns/src/multi-select/multi-select.tsx +++ b/packages/ui-patterns/src/multi-select/multi-select.tsx @@ -21,13 +21,15 @@ import { SIZE_VARIANTS_DEFAULT, } from 'ui' +import { SelectionListState } from '../SelectionListState' + interface MultiSelectContextProps { id: string values: string[] onValuesChange: (value: string[]) => void toggleValue: (values: string) => void open: boolean - setOpen: React.Dispatch> + setOpen: (open: boolean) => void inputValue: string setInputValue: React.Dispatch> activeIndex: number @@ -41,6 +43,7 @@ const MultiSelectContext = React.createContext(n const DROPDOWN_MAX_HEIGHT = 300 const DROPDOWN_GAP = 8 +const DROPDOWN_BORDER_HEIGHT = 2 const commandItemClass = cn( 'relative text-foreground-light text-left px-2 py-1.5 rounded-xs', @@ -73,6 +76,7 @@ type MultiSelectorProps = { mode?: MultiSelectorMode values: string[] onValuesChange: (value: string[]) => void + onOpenChange?: (open: boolean) => void disabled?: boolean } & React.ComponentPropsWithoutRef & VariantProps @@ -80,6 +84,7 @@ type MultiSelectorProps = { function MultiSelector({ values = [], onValuesChange, + onOpenChange, disabled, dir, size, @@ -89,13 +94,24 @@ function MultiSelector({ ...props }: MultiSelectorProps) { const ref = React.useRef(null) - const [open, setOpen] = React.useState(false) + const [open, setOpenState] = React.useState(false) const [inputValue, setInputValue] = React.useState('') const [activeIndex, setActiveIndex] = React.useState(-1) const [dropdownMaxHeight, setDropdownMaxHeight] = React.useState(DROPDOWN_MAX_HEIGHT) + const openRef = React.useRef(false) const generatedId = React.useId() const id = idProp ?? generatedId + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (openRef.current === nextOpen) return + openRef.current = nextOpen + setOpenState(nextOpen) + onOpenChange?.(nextOpen) + }, + [onOpenChange] + ) + const toggleValue = React.useCallback( (toggledValue: string) => { if (values.includes(toggledValue)) { @@ -104,7 +120,7 @@ function MultiSelector({ onValuesChange([...values, toggledValue]) } }, - [values] + [onValuesChange, values] ) useEffect(() => { @@ -154,18 +170,18 @@ function MultiSelector({ } break case 'Escape': - activeIndex !== -1 ? setActiveIndex(-1) : setOpen(false) + activeIndex !== -1 ? setActiveIndex(-1) : handleOpenChange(false) if (ref.current) { const button = (ref.current as HTMLDivElement).querySelector('button[role="combobox"]') button && (button as HTMLButtonElement).focus() } break case 'Enter': - setOpen(true) + handleOpenChange(true) break } }, - [values, inputValue, activeIndex] + [values, inputValue, activeIndex, handleOpenChange] ) return ( @@ -176,7 +192,7 @@ function MultiSelector({ toggleValue, onValuesChange, open, - setOpen, + setOpen: handleOpenChange, inputValue, setInputValue, activeIndex, @@ -186,7 +202,7 @@ function MultiSelector({ dropdownMaxHeight, }} > - + = React.useCallback( (event) => { @@ -374,7 +391,7 @@ const MultiSelectorTrigger = React.forwardRef, React.ComponentPropsWithoutRef & { creatable?: boolean + emptyLabel?: string + error?: boolean + errorLabel?: string + loading?: boolean } ->(({ className, children, creatable = false, ...props }, ref) => { - const { open, inputValue, setInputValue, toggleValue, dropdownMaxHeight } = useMultiSelect() +>( + ( + { + className, + children, + creatable = false, + emptyLabel = 'No results found', + error = false, + errorLabel, + loading = false, + ...props + }, + ref + ) => { + const { open, inputValue, setInputValue, toggleValue, dropdownMaxHeight } = useMultiSelect() - const options = Children.toArray(children) - const availableOptions = options - .filter((x: any) => !!x.props.value) - .map((x: any) => x.props.value.toLowerCase()) - const isOptionExists = availableOptions.some((x: string) => x === inputValue.toLowerCase()) + const options = Children.toArray(children) + const availableOptions = options + .filter((x: any) => !!x.props.value) + .map((x: any) => x.props.value.toLowerCase()) + const isOptionExists = availableOptions.some((x: string) => x === inputValue.toLowerCase()) - return ( - e.stopPropagation()} - {...props} - > - {children} - {creatable && inputValue.length > 0 && !isOptionExists ? ( - { - open && toggleValue(inputValue) - setInputValue('') - }} - className={commandItemClass} - > - Create "{inputValue}" - - ) : creatable && options.length === 0 ? ( -
    - Type to add a value -
    - ) : ( - - No results found - - )} -
    - ) -}) + return ( + e.stopPropagation()} + {...props} + > + + {!loading && !error && (options.length > 0 || creatable) && ( + <> + {children} + {creatable && inputValue.length > 0 && !isOptionExists ? ( + { + open && toggleValue(inputValue) + setInputValue('') + }} + className={commandItemClass} + > + Create "{inputValue}" + + ) : creatable && options.length === 0 ? ( +
    + Type to add a value +
    + ) : ( + + {emptyLabel} + + )} + + )} +
    + ) + } +) MultiSelectorList.displayName = 'MultiSelectorList' MultiSelector.List = MultiSelectorList