mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
## What kind of change does this PR introduce? UI polish / design system: refreshed button styles, related token tweaks, and a shared floating-button plate. Resolves DEPR-652. ## What is the current behavior? Default, primary, and secondary buttons use older fills, borders, and hover treatments. Primary still leans on brand scale utilities. Default fills don’t always read as raised chrome across surfaces, and floating copy / expand / scroll controls can let busy content show through translucent fills. Call sites hand-roll `rounded-* bg-background` wrappers for that. ## What is the new behavior? Refreshes primary, default, and secondary buttons with medium-weight labels, subtle shadows and inset edges, and smoother transitions. Light-mode default buttons use a raised fill with an accent hover state, primary text is brighter, and inline keyboard shortcuts inherit the button’s colour. Adds `FloatingPlate`: an opaque `bg-popover` shell for floating default buttons (and small clusters). Migrates Studio, Docs-related patterns, www, and `ui-patterns` floaters onto it so busy content no longer shows through translucent fills. Positioning, z-index, and hover/focus reveal stay on the plate’s `className`. Use `rounded="full"` for pills. Also: - Moves primary onto semantic `--primary` / `--primary-hover` (with a light-theme override) instead of brand utility fills - Tokenises button shadows as `--button-shadow-drop` / `--button-shadow-raised` / `--button-shadow-default` on the Button base - Aligns hover direction: darken on light mode, lighten on dark mode for both default and primary - Default fill stays opaque `bg-card` in light (occlusion) and translucent `bg-muted` in dark (adapts to the local surface) - Documents fills and `FloatingPlate` on the design-system Button page (with a live example) - Scales shared radius tokens in Studio and www; medium+ Button sizes use a proportionally softer radius - Fixes www nav CTA centering (`lg:inline-flex` instead of `lg:block`) - Query detail Expand/Collapse wires `aria-expanded` / `aria-controls` | Before | After | | --- | --- | | <img width="1074" height="438" alt="CleanShot 2026-09-18 at 15 52 51@2x" src="https://github.com/user-attachments/assets/ef43da21-b053-4b7e-9ac4-ab8b428228ab" /> | <img width="1090" height="464" alt="CleanShot 2026-09-18 at 15 50 59@2x" src="https://github.com/user-attachments/assets/2ddc55fc-4c8d-499c-a280-f3db3d99023c" /> | | <img width="1082" height="446" alt="CleanShot 2026-09-18 at 15 52 35@2x" src="https://github.com/user-attachments/assets/3dd5452d-325a-4e4a-a79d-26c6c6950a31" /> | <img width="1078" height="446" alt="CleanShot 2026-09-18 at 15 51 13@2x" src="https://github.com/user-attachments/assets/93666385-3e6e-42e0-9891-9cd6bb935b67" /> | ## To test ### Design system - [Button page](https://design-system-git-chore-button-styles-supabase.vercel.app/design-system/docs/components/button): default / primary in light and dark; hover should darken on light, lighten on dark - Same page: [Floating over content](https://design-system-git-chore-button-styles-supabase.vercel.app/design-system/docs/components/button#floating-over-content) / [Floating plate](https://design-system-git-chore-button-styles-supabase.vercel.app/design-system/docs/components/button#floating-plate) example; Copy over SQL should stay opaque - Spot-check hover on a code preview Copy control ### Docs [Docs deploy preview](https://docs-git-chore-button-styles-supabase.vercel.app/docs): - [Docs homepage](https://docs-git-chore-button-styles-supabase.vercel.app/docs): top-right **Sign up** / **Dashboard** primary; menu icon beside it (default icon button) - Shrink below `lg` and open the hamburger drawer: bottom **Sign in** (default) + **Start your project** (primary) medium block buttons - Tab once for **Skip to content** (FloatingPlate) - [MCP guide](https://docs-git-chore-button-styles-supabase.vercel.app/docs/guides/ai-tools/mcp): project picker - [Apple login](https://docs-git-chore-button-styles-supabase.vercel.app/docs/guides/auth/social-login/auth-apple): **Generate Secret Key** button in the Apple Secret Generator - Optional opacity check: any guide code block Copy control (e.g. at the bottom of [Import data into Supabase](https://docs-git-chore-button-styles-supabase.vercel.app/docs/guides/database/import-data)) ### Studio [Studio deploy preview](https://studio-staging-git-chore-button-styles-supabase.vercel.app/): - **Observability → Query Performance**: open a query detail → Expand/Collapse pill + SQL Copy chip (dark: no bleed-through) - **Observability → Query Insights**: select a query → Clear query pill - **Table Editor → any table → Definition** → floating **Open in SQL Editor** - **Connect → Framework → Add files**: Copy on the code tabs (FloatingPlate; light hover follow-up is DEPR-694) - Tab once for **Skip to content** ### WWW - [www deploy preview](https://zone-www-dot-com-git-chore-button-styles-supabase.vercel.app/): nav Sign in / Start your project vertical centering; hero medium CTAs radius --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { useQueryClient } from '@tanstack/react-query'
|
|
import { screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import type { components } from 'api-types'
|
|
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
|
import { HttpResponse } from 'msw'
|
|
import { ReactNode, useRef, type AnchorHTMLAttributes } from 'react'
|
|
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
|
|
import { ReplicationPipelineLayout } from './ReplicationPipelineLayout'
|
|
import { ReplicationPipelineStatus } from './ReplicationPipelineStatus/ReplicationPipelineStatus'
|
|
import { replicationKeys } from '@/data/replication/keys'
|
|
import {
|
|
PipelineRequestStatusProvider,
|
|
PipelineStatusRequestStatus,
|
|
usePipelineRequestStatus,
|
|
} from '@/state/replication-pipeline-request-status'
|
|
import { customRender } from '@/tests/lib/custom-render'
|
|
import { addAPIMock } from '@/tests/lib/msw'
|
|
|
|
mockAnimationsApi()
|
|
|
|
// The global setup pins useParams to { ref: 'default' }; this layout also needs a pipeline id
|
|
vi.mock('common', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('common')>()),
|
|
useParams: () => ({ ref: 'default', pipelineId: '42' }),
|
|
}))
|
|
|
|
vi.mock('next/router', () => ({
|
|
useRouter: () => ({ push: vi.fn() }),
|
|
}))
|
|
|
|
vi.mock('next/link', () => ({
|
|
default: ({
|
|
href,
|
|
children,
|
|
...props
|
|
}: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
|
<a href={href} {...props}>
|
|
{children}
|
|
</a>
|
|
),
|
|
}))
|
|
|
|
const renderLayout = (children?: ReactNode) =>
|
|
customRender(
|
|
<PipelineRequestStatusProvider>
|
|
<ReplicationPipelineLayout>{children}</ReplicationPipelineLayout>
|
|
</PipelineRequestStatusProvider>
|
|
)
|
|
|
|
const TableResetFixture = () => {
|
|
const queryClient = useQueryClient()
|
|
const { runWithRequestStatus } = usePipelineRequestStatus()
|
|
const finishReset = useRef<() => void>(() => {})
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
tabIndex={0}
|
|
onClick={() =>
|
|
void runWithRequestStatus(
|
|
42,
|
|
PipelineStatusRequestStatus.StopRequested,
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
finishReset.current = resolve
|
|
})
|
|
)
|
|
}
|
|
>
|
|
Begin table reset
|
|
</button>
|
|
<button
|
|
tabIndex={0}
|
|
onClick={async () => {
|
|
finishReset.current()
|
|
await queryClient.invalidateQueries({
|
|
queryKey: replicationKeys.pipelinesStatus('default', 42),
|
|
})
|
|
}}
|
|
>
|
|
Finish table reset
|
|
</button>
|
|
</>
|
|
)
|
|
}
|
|
|
|
type PipelineResponse = components['schemas']['PipelineResponse_Output']
|
|
type PipelineStatusResponse = components['schemas']['PipelineStatusResponse_Output']
|
|
type PipelineReplicationStatusResponse =
|
|
components['schemas']['PipelineReplicationStatusResponse_Output']
|
|
type PipelineVersionResponse = components['schemas']['PipelineVersionResponse_Output']
|
|
type DestinationResponse = components['schemas']['DestinationResponse_Output']
|
|
type DestinationsResponse = components['schemas']['DestinationsResponse_Output']
|
|
type PipelinesResponse = components['schemas']['PipelinesResponse_Output']
|
|
type SourcesResponse = components['schemas']['SourcesResponse_Output']
|
|
|
|
const PIPELINE: PipelineResponse = {
|
|
id: 42,
|
|
config: { publication_name: 'analytics_publication' },
|
|
destination_id: 7,
|
|
destination_name: 'Analytics warehouse',
|
|
replicator_id: 1,
|
|
source_id: 2,
|
|
source_name: 'main-db',
|
|
tenant_id: 'default',
|
|
}
|
|
|
|
const DESTINATION: DestinationResponse = {
|
|
id: 7,
|
|
name: 'Analytics warehouse',
|
|
tenant_id: 'default',
|
|
config: {
|
|
big_query: {
|
|
connection_pool_size: 8,
|
|
dataset_id: 'analytics',
|
|
project_id: 'acme-analytics',
|
|
},
|
|
},
|
|
}
|
|
|
|
const mockPipeline = () => {
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/replication/:ref/pipelines/:pipeline_id',
|
|
response: () => HttpResponse.json<PipelineResponse>(PIPELINE),
|
|
})
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/replication/:ref/destinations/:destination_id',
|
|
response: () => HttpResponse.json<DestinationResponse>(DESTINATION),
|
|
})
|
|
|
|
// The header renders DestinationPanel so Edit destination can open in place
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/replication/:ref/destinations',
|
|
response: () => HttpResponse.json<DestinationsResponse>({ destinations: [DESTINATION] }),
|
|
})
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/replication/:ref/pipelines',
|
|
response: () => HttpResponse.json<PipelinesResponse>({ pipelines: [PIPELINE] }),
|
|
})
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/replication/:ref/sources',
|
|
response: () =>
|
|
HttpResponse.json<SourcesResponse>({
|
|
sources: [
|
|
{
|
|
tenant_id: 'default',
|
|
id: 2,
|
|
name: 'default',
|
|
config: { host: 'db.internal', port: 5432, name: 'main-db', username: 'etl_user' },
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
}
|
|
|
|
const mockStatus = (name: PipelineStatusResponse['status']['name']) =>
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/replication/:ref/pipelines/:pipeline_id/status',
|
|
response: () =>
|
|
HttpResponse.json<PipelineStatusResponse>({ pipeline_id: 42, status: { name } }),
|
|
})
|
|
|
|
const mockVersion = (hasUpdate: boolean) =>
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/replication/:ref/pipelines/:pipeline_id/version',
|
|
response: () =>
|
|
HttpResponse.json<PipelineVersionResponse>({
|
|
pipeline_id: 42,
|
|
version: { id: 1, name: 'v0.2.0' },
|
|
...(hasUpdate ? { new_version: { id: 2, name: 'v0.3.0' } } : {}),
|
|
}),
|
|
})
|
|
|
|
const mockReplicationStatus = () =>
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/replication/:ref/pipelines/:pipeline_id/replication-status',
|
|
response: () =>
|
|
HttpResponse.json<PipelineReplicationStatusResponse>({
|
|
pipeline_id: 42,
|
|
table_statuses: [],
|
|
}),
|
|
})
|
|
|
|
describe('ReplicationPipelineLayout', () => {
|
|
beforeEach(() => {
|
|
mockPipeline()
|
|
mockVersion(false)
|
|
})
|
|
|
|
test('renders pipeline identity, chrome actions and the lifecycle action', async () => {
|
|
mockStatus('started')
|
|
|
|
renderLayout(<div>Overview content</div>)
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Analytics warehouse' })).toBeVisible()
|
|
expect(screen.getByRole('link', { name: 'Pipelines' })).toHaveAttribute(
|
|
'href',
|
|
'/project/default/database/pipelines'
|
|
)
|
|
expect(screen.getByRole('link', { name: 'View logs' }).getAttribute('href')).toContain(
|
|
'pipeline_id'
|
|
)
|
|
expect(screen.getByText('Running')).toBeVisible()
|
|
expect(screen.getByText('Primary database')).toBeVisible()
|
|
expect(await screen.findByText('BigQuery')).toBeVisible()
|
|
expect(await screen.findByRole('button', { name: 'Stop' })).toBeVisible()
|
|
expect(screen.getByText('Overview content')).toBeVisible()
|
|
})
|
|
|
|
test('keeps the pipeline identity structure in place while it loads', async () => {
|
|
let resolvePipeline: (pipeline: PipelineResponse) => void = () => {}
|
|
const pipelineResponse = new Promise<PipelineResponse>((resolve) => {
|
|
resolvePipeline = resolve
|
|
})
|
|
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/replication/:ref/pipelines/:pipeline_id',
|
|
response: async () => HttpResponse.json<PipelineResponse>(await pipelineResponse),
|
|
})
|
|
mockStatus('started')
|
|
|
|
renderLayout()
|
|
|
|
expect(screen.getByText('Primary database')).toBeVisible()
|
|
expect(screen.getByText('Loading destination')).toBeInTheDocument()
|
|
expect(screen.getAllByText('Loading pipeline')).toHaveLength(2)
|
|
expect(screen.queryByText('Running')).not.toBeInTheDocument()
|
|
|
|
resolvePipeline(PIPELINE)
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Analytics warehouse' })).toBeVisible()
|
|
expect(await screen.findByText('BigQuery')).toBeVisible()
|
|
expect(await screen.findByText('Running')).toBeVisible()
|
|
})
|
|
|
|
test('composes the legacy overview without duplicating the detail header', async () => {
|
|
mockStatus('stopped')
|
|
mockReplicationStatus()
|
|
|
|
renderLayout(<ReplicationPipelineStatus />)
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Analytics warehouse' })).toBeVisible()
|
|
expect(screen.getAllByRole('link', { name: 'View logs' })).toHaveLength(1)
|
|
expect(screen.getAllByRole('button', { name: 'Start' })).toHaveLength(1)
|
|
expect(await screen.findByRole('heading', { name: 'Pipeline stopped' })).toBeVisible()
|
|
})
|
|
|
|
test('explains the state in a tooltip when the dot is hovered', async () => {
|
|
// The dotted underline promises a tooltip; this guards the asChild ref binding that makes it work
|
|
mockStatus('failed')
|
|
renderLayout()
|
|
|
|
await userEvent.hover(await screen.findByText('Failed'))
|
|
|
|
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
|
'Replication has encountered an error. Check the logs for more information.'
|
|
)
|
|
expect(screen.queryByRole('link', { name: 'logs' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('offers the pipeline actions the primary button does not', async () => {
|
|
mockStatus('started')
|
|
renderLayout()
|
|
|
|
await screen.findByRole('heading', { name: 'Analytics warehouse' })
|
|
await userEvent.click(screen.getByRole('button', { name: 'Pipeline options' }))
|
|
|
|
// Primary button is Stop while running, so the menu carries Restart instead
|
|
expect(await screen.findByRole('menuitem', { name: 'Restart pipeline' })).toBeVisible()
|
|
expect(screen.queryByRole('menuitem', { name: 'Stop pipeline' })).not.toBeInTheDocument()
|
|
expect(screen.getByRole('menuitem', { name: 'Edit pipeline' })).toBeVisible()
|
|
expect(screen.getByRole('menuitem', { name: 'Delete pipeline' })).toBeVisible()
|
|
})
|
|
|
|
test('blocks pipeline actions while a table reset is running', async () => {
|
|
mockStatus('started')
|
|
mockVersion(true)
|
|
renderLayout(<TableResetFixture />)
|
|
|
|
const lifecycleAction = await screen.findByRole('button', { name: 'Stop' })
|
|
const options = screen.getByRole('button', { name: 'Pipeline options' })
|
|
const update = await screen.findByRole('button', { name: 'Update available' })
|
|
expect(lifecycleAction).toBeEnabled()
|
|
expect(options).toBeEnabled()
|
|
expect(update).toBeEnabled()
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: 'Begin table reset' }))
|
|
expect(lifecycleAction).toBeDisabled()
|
|
expect(options).toBeDisabled()
|
|
expect(update).toBeDisabled()
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: 'Finish table reset' }))
|
|
expect(lifecycleAction).toBeEnabled()
|
|
expect(options).toBeEnabled()
|
|
expect(update).toBeEnabled()
|
|
})
|
|
|
|
test('offers Stop from the menu when the primary button is Restart', async () => {
|
|
mockStatus('failed')
|
|
renderLayout()
|
|
|
|
await screen.findByRole('heading', { name: 'Analytics warehouse' })
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Restart' })).toBeEnabled())
|
|
await userEvent.click(screen.getByRole('button', { name: 'Pipeline options' }))
|
|
|
|
expect(await screen.findByRole('menuitem', { name: 'Stop pipeline' })).toBeVisible()
|
|
expect(screen.queryByRole('menuitem', { name: 'Restart pipeline' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('keeps an available update visible and opens its modal', async () => {
|
|
mockStatus('started')
|
|
mockVersion(true)
|
|
|
|
renderLayout()
|
|
|
|
const updateButton = await screen.findByRole('button', { name: 'Update available' })
|
|
expect(updateButton).toHaveClass('bg-primary')
|
|
await userEvent.click(updateButton)
|
|
// The trigger button shares this name, so match the dialog's heading specifically
|
|
expect(await screen.findByRole('heading', { name: 'Update available' })).toBeVisible()
|
|
})
|
|
})
|