Files
supabase/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.test.ts
Danny White 5edcaef74c 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>
2026-07-31 06:23:24 +10:00

127 lines
3.9 KiB
TypeScript

import { describe, expect, test } from 'vitest'
import {
getOrganizationInviteContent,
getOrganizationInviteStatus,
type OrganizationInviteStatus,
} from './OrganizationInvite.utils'
import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query'
import type { ResponseError } from '@/types'
const READY_INVITE: OrganizationInviteByToken = {
authorized_user: true,
email_match: true,
expired_token: false,
invite_id: 42,
organization_name: 'Acme Corp',
sso_mismatch: false,
token_does_not_exist: false,
}
const responseError = (message: string, code = 500) => ({ message, code }) as ResponseError
type StatusOverrides = Partial<Parameters<typeof getOrganizationInviteStatus>[0]>
const getStatus = (overrides: StatusOverrides = {}) =>
getOrganizationInviteStatus({
data: READY_INVITE,
error: null,
isErrorInvitation: false,
isLoadingInvitation: false,
isLoadingProfile: false,
isLoggedIn: true,
isRouterReady: true,
isSuccessInvitation: true,
profileExists: true,
...overrides,
})
describe('OrganizationInvite utils', () => {
test.each<[string, OrganizationInviteStatus, StatusOverrides]>([
['signed out when there is no current user', 'signed-out', { isLoggedIn: false }],
['loading while the profile is loading', 'loading', { isLoadingProfile: true }],
['loading while the invite is loading', 'loading', { isLoadingInvitation: true }],
[
'no longer valid for accepted or declined invites',
'no-longer-valid',
{
data: undefined,
error: responseError('Failed to retrieve organization', 401),
isErrorInvitation: true,
isSuccessInvitation: false,
},
],
[
'invalid when the API returns a missing token response',
'invalid',
{
data: { ...READY_INVITE, token_does_not_exist: true },
},
],
[
'invalid when the invite lookup 404s',
'invalid',
{
data: undefined,
error: responseError('Not Found', 404),
isErrorInvitation: true,
isSuccessInvitation: false,
},
],
[
'error for other API failures',
'error',
{
data: undefined,
error: responseError('Failed to retrieve token', 500),
isErrorInvitation: true,
isSuccessInvitation: false,
},
],
[
'expired when the token has expired',
'expired',
{
data: { ...READY_INVITE, expired_token: true },
},
],
[
'wrong account when the invite email does not match',
'wrong-account',
{
data: { ...READY_INVITE, email_match: false },
},
],
['ready when the invite can be accepted', 'ready', {}],
])('returns %s', (_name, expected, overrides) => {
expect(getStatus(overrides)).toBe(expected)
})
test.each<[OrganizationInviteStatus, string, string | undefined]>([
['signed-out', 'View invitation', 'Sign in or create an account to view this invitation'],
['ready', 'Join Acme Corp', 'You have been invited to join this Supabase organization'],
['wrong-account', 'Wrong account', undefined],
['expired', 'Invite expired', 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) => {
expect(
getOrganizationInviteContent({
data: READY_INVITE,
isSignUpEnabled: true,
status,
})
).toEqual({ title, ...(description ? { description } : {}) })
})
test('omits sign-up copy when sign-up is disabled', () => {
expect(
getOrganizationInviteContent({
data: READY_INVITE,
isSignUpEnabled: false,
status: 'signed-out',
}).description
).toBe('Sign in to view this invitation')
})
})