mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore: show organization invite errors inline (#48470)
## What kind of change does this PR introduce? Bug fix and design-system documentation update. ## What is the current behavior? Invite acceptance failures only appear in a transient toast. ## What is the new behavior? Invite failures remain visible beside the actions. The design-system guidance now distinguishes field, action, state, and toast feedback. | Before | After | | --- | --- | | <img width="759" height="619" alt="Join Organization Supabase" src="https://github.com/user-attachments/assets/ed8e974c-5da3-477a-81da-628d3f847131" /> | <img width="741" height="768" alt="Join Organization Supabase" src="https://github.com/user-attachments/assets/4c3f6bcd-4ed9-40b2-8280-e8c8a44ecbd6" /> | ## To test With local Studio running at `http://localhost:8082`: 1. Open `apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts`. 2. At line 37, immediately inside `getOrganizationInviteStatus`, add: ```tsx return 'ready' ``` This deliberately bypasses invite lookup and account checks for the visual test. 3. Open `apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx`. 4. At line 30, change: ```tsx const [joinError, setJoinError] = useState<string>() ``` to: ```tsx const [joinError, setJoinError] = useState<string>('Invite token can only be accepted via an SSO account') ``` 5. Open `http://localhost:8082/join?token=test&slug=test` while signed in. 6. Confirm the card says **Join an organization** and shows the error below **Decline**, separated from the actions by a divider. 7. Revert both temporary edits before committing anything. ## Additional context First PR in a five-PR stack. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added a new connect interstitial example showcasing an inline action-error state with clear retry guidance. - **Bug Fixes** - Invitation acceptance failures now show inline destructive feedback under “Accept invite,” keeping the button enabled for retry (and removing prior toast-based failure behavior). - Updated the invalid-invitation title to “Invalid invitation.” - Changed the “Decline” link destination to `/organizations`. - **Documentation** - Expanded Sonner toast “When to use” guidance. - Refined form and connect interstitial action-feedback patterns (inline vs toast usage). - **Tests** - Updated and added coverage for the inline error rendering and “Invalid invitation” text. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
205527c716
commit
5edcaef74c
10 files changed
+150
-19
No files matched your search
@@ -2623,6 +2623,17 @@ export const Index: Record<string, any> = {
|
||||
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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 && (
|
||||
<div className="mt-3 border-t border-muted pt-5">
|
||||
<p role="alert" className="text-center text-xs text-destructive text-balance">
|
||||
{actionError}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
<ComponentPreview
|
||||
name="connect-interstitial-action-error"
|
||||
description="Retryable action error shown beside the actions"
|
||||
align="start"
|
||||
className="p-0"
|
||||
padded={false}
|
||||
peekCode
|
||||
wide
|
||||
/>
|
||||
|
||||
## States
|
||||
|
||||
Keep loading, invalid, error, and success states inside the same card when the
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Button } from 'ui'
|
||||
|
||||
import {
|
||||
AccountRow,
|
||||
InterstitialShell,
|
||||
LogoPair,
|
||||
StripeLogo,
|
||||
SupabaseLogo,
|
||||
} from './connect-interstitial-shared'
|
||||
|
||||
export default function ConnectInterstitialActionError() {
|
||||
return (
|
||||
<InterstitialShell
|
||||
logo={<LogoPair left={<StripeLogo />} right={<SupabaseLogo />} />}
|
||||
title="Authorize Stripe Projects"
|
||||
description="This will create an organization on your behalf in Supabase"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<AccountRow displayName="alex@example.com" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button variant="primary" block>
|
||||
Authorize Stripe Projects
|
||||
</Button>
|
||||
<Button variant="text" block>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="mt-3 border-t border-muted pt-5">
|
||||
<p role="alert" className="text-center text-xs text-destructive text-balance">
|
||||
Failed to authorize Stripe Projects. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InterstitialShell>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+16
-2
@@ -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(<OrganizationInvite />)
|
||||
|
||||
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(<OrganizationInvite />)
|
||||
|
||||
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.'
|
||||
@@ -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 = () => {
|
||||
<Button asChild variant="text" block>
|
||||
<Link href="/organizations">Decline</Link>
|
||||
</Button>
|
||||
{joinError && (
|
||||
<div className="mt-3 border-t border-muted pt-5">
|
||||
<p role="alert" className="text-center text-xs text-destructive text-balance">
|
||||
Failed to join organization: {joinError.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+2
-2
@@ -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) => {
|
||||
@@ -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')) {
|
||||
|
||||
Reference in new issue
Block a user