diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index fade8897fb5..7e9ec446ef2 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -2623,6 +2623,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "connect-interstitial-action-error": { + name: "connect-interstitial-action-error", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/connect-interstitial-action-error")), + source: "", + files: ["registry/default/example/connect-interstitial-action-error.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "connect-interstitial-logo-pair": { name: "connect-interstitial-logo-pair", type: "components:example", diff --git a/apps/design-system/content/docs/components/sonner.mdx b/apps/design-system/content/docs/components/sonner.mdx index 3ae4ce8ee0a..bfcae2524a2 100644 --- a/apps/design-system/content/docs/components/sonner.mdx +++ b/apps/design-system/content/docs/components/sonner.mdx @@ -58,6 +58,21 @@ import { toast } from 'sonner' toast('Event has been created.') ``` +## When to use + +Use a toast for short-lived, non-blocking feedback or to confirm an operation +after its originating surface has closed or navigated away. + +Do not use a toast as the only feedback for: + +- Field validation. Use `FormMessage` or `FieldError` beside the field. +- A failed submission that leaves the form or interstitial visible. Show a + `role="alert"` message in destructive text near the actions. +- A blocking or materially changed page state. Use an inline state component + such as `Admonition` or `ErrorDisplay`. + +Keep error copy specific and include the next step when it is not obvious. + ## Expand You can change the amount of toasts visible through the visibleToasts prop. diff --git a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx index ceb6d36b189..99c0671ea69 100644 --- a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx +++ b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx @@ -200,6 +200,48 @@ for new states instead of inventing a parallel card style. Prefer one full-width primary action. A full-width text button is fine for a secondary action that still belongs in the flow. +### Action feedback + +Match feedback to its scope: + +- Use `FormMessage` or `FieldError` beside a field when that field needs to + change. +- Show a submission or action failure as simple destructive text below the + actions. Separate it with a subtle divider when needed for composition. Keep + the current account, selections, and actions visible so the user can retry. +- Use `Admonition` when the whole interstitial is blocked or has materially + changed state, such as an invalid link, wrong account, or partially completed + setup. +- Use a toast only for non-blocking feedback or a completed action whose + originating surface is no longer visible. A toast must not be the only + feedback for a failure the user needs to resolve on the current card. + +```tsx +{ + actionError && ( +
+

+ {actionError} +

+
+ ) +} +``` + +Clear stale action feedback when the user retries or changes a relevant +selection. Error copy should say what failed and, when it is not obvious, what +the user can do next. + + + ## States Keep loading, invalid, error, and success states inside the same card when the diff --git a/apps/design-system/content/docs/ui-patterns/forms.mdx b/apps/design-system/content/docs/ui-patterns/forms.mdx index 305231b796b..add92626642 100644 --- a/apps/design-system/content/docs/ui-patterns/forms.mdx +++ b/apps/design-system/content/docs/ui-patterns/forms.mdx @@ -42,7 +42,7 @@ Keep repeated-row validation in the form schema or shared validation helper, not Build a custom row when the cells are mixed controls, such as an input paired with a `Select`. -## Best Practices +## Best practices 1. **Always use FormItemLayout**: Use `FormItemLayout` instead of manually composing `FormItem`, `FormLabel`, `FormMessage`, and `FormDescription`. @@ -57,7 +57,7 @@ Build a custom row when the cells are mixed controls, such as an input paired wi 5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`. Make sure you destructure `isDirty` from `form.formState` (see https://react-hook-form.com/docs/useform/formstate) -6. **Error handling**: Always use mutations with `onSuccess` and `onError` callbacks that show toast notifications. +6. **Error handling**: Match feedback to its scope. Use `FormMessage` or `FieldError` for field validation. Show submission failures inline near the form actions when the user needs to retry or change something. Reserve toasts for non-blocking feedback or completed operations whose originating surface is no longer visible. 7. **Loading states**: Show loading states on submit buttons using the `loading` prop. diff --git a/apps/design-system/registry/default/example/connect-interstitial-action-error.tsx b/apps/design-system/registry/default/example/connect-interstitial-action-error.tsx new file mode 100644 index 00000000000..085b3a48678 --- /dev/null +++ b/apps/design-system/registry/default/example/connect-interstitial-action-error.tsx @@ -0,0 +1,36 @@ +import { Button } from 'ui' + +import { + AccountRow, + InterstitialShell, + LogoPair, + StripeLogo, + SupabaseLogo, +} from './connect-interstitial-shared' + +export default function ConnectInterstitialActionError() { + return ( + } right={} />} + title="Authorize Stripe Projects" + description="This will create an organization on your behalf in Supabase" + > +
+ +
+ + +
+

+ Failed to authorize Stripe Projects. Please try again. +

+
+
+
+
+ ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 2697a4f176e..04b6a3ec5b6 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1408,6 +1408,11 @@ export const examples: Registry = [ type: 'components:example', files: ['example/connect-interstitial-demo.tsx'], }, + { + name: 'connect-interstitial-action-error', + type: 'components:example', + files: ['example/connect-interstitial-action-error.tsx'], + }, { name: 'connect-interstitial-logo-pair', type: 'components:example', diff --git a/apps/studio/tests/components/OrganizationInvite.test.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.test.tsx similarity index 92% rename from apps/studio/tests/components/OrganizationInvite.test.tsx rename to apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.test.tsx index baad458e0e9..f6f4201b7cc 100644 --- a/apps/studio/tests/components/OrganizationInvite.test.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.test.tsx @@ -2,7 +2,7 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, test, vi } from 'vitest' -import { OrganizationInvite } from '@/components/interfaces/OrganizationInvite/OrganizationInvite' +import { OrganizationInvite } from './OrganizationInvite' import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' import type { ProfileContextType } from '@/lib/profile' import { render } from '@/tests/helpers' @@ -172,6 +172,20 @@ describe('OrganizationInvite', () => { }) }) + test('renders an inline error when joining the organization fails', () => { + mocks.useAcceptInvitationMutation.mockReturnValue({ + mutate: mocks.acceptInvitation, + isPending: false, + error: responseError('You are already a member of this organization'), + }) + + render() + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Failed to join organization: You are already a member of this organization' + ) + }) + test('renders a wrong-account warning and signs out', async () => { const user = userEvent.setup() mocks.useInvitationQuery.mockReturnValue({ @@ -225,7 +239,7 @@ describe('OrganizationInvite', () => { rerender() - expect(screen.getByText('Invite invalid')).toBeInTheDocument() + expect(screen.getByText('Invalid invitation')).toBeInTheDocument() expect( screen.getByText( 'Open the full invite link again, or ask the organization owner for a new invite.' diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx index 6b150756a5d..7e62eb43785 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx @@ -1,8 +1,7 @@ import { useIsLoggedIn, useParams } from 'common' import Link from 'next/link' import { useRouter } from 'next/router' -import type { ReactNode } from 'react' -import { toast } from 'sonner' +import { type ReactNode } from 'react' import { Button, Card, CardContent } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' @@ -25,9 +24,9 @@ import { useProfile, useProfileNameAndPicture } from '@/lib/profile' export const OrganizationInvite = () => { const router = useRouter() const isLoggedIn = useIsLoggedIn() + const { slug, token } = useParams() const { profile, isLoading: isLoadingProfile } = useProfile() const { username, avatarUrl, primaryEmail } = useProfileNameAndPicture() - const { slug, token } = useParams() const isSignUpEnabled = useIsFeatureEnabled('dashboard_auth:sign_up') @@ -70,15 +69,17 @@ export const OrganizationInvite = () => { const mfaRequiredError = error?.message.includes('MFA required') - const { mutate: joinOrganization, isPending: isJoining } = - useOrganizationAcceptInvitationMutation({ - onSuccess: () => { - router.push('/organizations') - }, - onError: (error) => { - toast.error(`Failed to join organization: ${error.message}`) - }, - }) + const { + mutate: joinOrganization, + isPending: isJoining, + error: joinError, + } = useOrganizationAcceptInvitationMutation({ + onSuccess: () => { + router.push('/organizations') + }, + // [Joshen] Silence the default toast handler + onError: () => {}, + }) async function handleJoinOrganization() { if (!slug) return console.error('Slug is required') @@ -196,6 +197,13 @@ export const OrganizationInvite = () => { + {joinError && ( +
+

+ Failed to join organization: {joinError.message} +

+
+ )} ) diff --git a/apps/studio/tests/components/OrganizationInvite.utils.test.ts b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.test.ts similarity index 96% rename from apps/studio/tests/components/OrganizationInvite.utils.test.ts rename to apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.test.ts index c119fb46531..6d6c7cc7c8d 100644 --- a/apps/studio/tests/components/OrganizationInvite.utils.test.ts +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.test.ts @@ -4,7 +4,7 @@ import { getOrganizationInviteContent, getOrganizationInviteStatus, type OrganizationInviteStatus, -} from '@/components/interfaces/OrganizationInvite/OrganizationInvite.utils' +} from './OrganizationInvite.utils' import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' import type { ResponseError } from '@/types' @@ -101,7 +101,7 @@ describe('OrganizationInvite utils', () => { ['ready', 'Join Acme Corp', 'You have been invited to join this Supabase organization'], ['wrong-account', 'Wrong account', undefined], ['expired', 'Invite expired', undefined], - ['invalid', 'Invite invalid', undefined], + ['invalid', 'Invalid invitation', undefined], ['no-longer-valid', 'Invite no longer available', undefined], ['error', 'Unable to load invitation', undefined], ])('returns content for %s', (status, title, description) => { diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts index 8738739589b..53526db5ccf 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts @@ -88,7 +88,7 @@ export function getOrganizationInviteContent({ if (status === 'wrong-account') return { title: 'Wrong account' } if (status === 'expired') return { title: 'Invite expired' } - if (status === 'invalid') return { title: 'Invite invalid' } + if (status === 'invalid') return { title: 'Invalid invitation' } if (status === 'no-longer-valid') return { title: 'Invite no longer available' } if (status === 'error') { if (error?.message.includes('MFA required')) {