Unify workspace dropdowns; Update layouts (#458)
Unified Account and Workspace drop-downs; Layout updates, now header lives within the PageBody component; Sidebars now use floating variant
This commit is contained in:
committed by
GitHub
parent
ca585e09be
commit
4bc8448a1d
@@ -55,7 +55,10 @@ create policy "projects_write" on public.projects for all
|
||||
|
||||
Use `server-action-builder` skill for detailed patterns.
|
||||
|
||||
**Rule: Services are decoupled from interfaces.** The service is pure logic that receives dependencies (database client, etc.) as arguments — it never imports framework-specific modules. The server action is a thin adapter that resolves dependencies and calls the service. This means the same service can be called from a server action, an MCP tool, a CLI command, or a unit test with zero changes.
|
||||
**Rule: Services are decoupled from interfaces.** The service is pure logic that receives dependencies (database client,
|
||||
etc.) as arguments — it never imports framework-specific modules. The server action is a thin adapter that resolves
|
||||
dependencies and calls the service. This means the same service can be called from a server action, an MCP tool, a CLI
|
||||
command, or a unit test with zero changes.
|
||||
|
||||
Create in route's `_lib/server/` directory:
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ export class AuthPageObject {
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.page.click('[data-test="account-dropdown-trigger"]');
|
||||
await this.page.click('[data-test="account-dropdown-sign-out"]');
|
||||
await this.page.click('[data-test="workspace-dropdown-trigger"]');
|
||||
await this.page.click('[data-test="workspace-sign-out"]');
|
||||
}
|
||||
|
||||
async bootstrapUser(params: { email: string; password: string; name: string }) {
|
||||
@@ -47,9 +47,19 @@ export class AuthPageObject {
|
||||
## Common Selectors
|
||||
|
||||
```typescript
|
||||
// Account dropdown
|
||||
'[data-test="account-dropdown-trigger"]'
|
||||
'[data-test="account-dropdown-sign-out"]'
|
||||
// Workspace dropdown (sidebar header - combined account switcher + user menu)
|
||||
'[data-test="workspace-dropdown-trigger"]' // Opens the dropdown
|
||||
'[data-test="workspace-switch-submenu"]' // Sub-trigger for workspace switching
|
||||
'[data-test="workspace-switch-content"]' // Sub-menu content with workspace list
|
||||
'[data-test="workspace-team-item"]' // Individual team items in switcher
|
||||
'[data-test="create-team-trigger"]' // Create team button in switcher
|
||||
'[data-test="workspace-sign-out"]' // Sign out button
|
||||
'[data-test="workspace-settings-link"]' // Settings link
|
||||
'[data-test="account-dropdown-display-name"]' // User display name (inside dropdown panel)
|
||||
|
||||
// Opening the workspace switcher (two-step: open dropdown, then submenu)
|
||||
await page.click('[data-test="workspace-dropdown-trigger"]');
|
||||
await page.click('[data-test="workspace-switch-submenu"]');
|
||||
|
||||
// Navigation
|
||||
'[data-test="sidebar-menu"]'
|
||||
|
||||
@@ -126,7 +126,7 @@ export function CreateEntityForm() {
|
||||
reValidateMode: 'onChange',
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof CreateEntitySchema>) => {
|
||||
const onSubmit = (data: z.output<typeof CreateEntitySchema>) => {
|
||||
setError(false);
|
||||
|
||||
startTransition(async () => {
|
||||
@@ -147,7 +147,7 @@ export function CreateEntityForm() {
|
||||
<If condition={error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="common:errors.generic" />
|
||||
<Trans i18nKey="common.errors.generic" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
@@ -177,9 +177,9 @@ export function CreateEntityForm() {
|
||||
data-test="submit-entity-button"
|
||||
>
|
||||
{pending ? (
|
||||
<Trans i18nKey="common:creating" />
|
||||
<Trans i18nKey="common.creating" />
|
||||
) : (
|
||||
<Trans i18nKey="common:create" />
|
||||
<Trans i18nKey="common.create" />
|
||||
)}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
@@ -145,7 +145,7 @@ import { toast } from '@kit/ui/sonner';
|
||||
<If condition={error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="common:errors.generic" />
|
||||
<Trans i18nKey="common.errors.generic" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
@@ -160,9 +160,9 @@ import { toast } from '@kit/ui/sonner';
|
||||
data-test="submit-button"
|
||||
>
|
||||
{pending ? (
|
||||
<Trans i18nKey="common:submitting" />
|
||||
<Trans i18nKey="common.submitting" />
|
||||
) : (
|
||||
<Trans i18nKey="common:submit" />
|
||||
<Trans i18nKey="common.submit" />
|
||||
)}
|
||||
</Button>
|
||||
```
|
||||
@@ -199,7 +199,7 @@ export function MyForm() {
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof MySchema>) => {
|
||||
const onSubmit = (data: z.output<typeof MySchema>) => {
|
||||
setError(false);
|
||||
|
||||
startTransition(async () => {
|
||||
@@ -220,7 +220,7 @@ export function MyForm() {
|
||||
<If condition={error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="common:errors.generic" />
|
||||
<Trans i18nKey="common.errors.generic" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
@@ -17,19 +17,21 @@ Create validation schema in `_lib/schemas/`:
|
||||
|
||||
```typescript
|
||||
// _lib/schemas/feature.schema.ts
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const CreateFeatureSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
accountId: z.string().uuid('Invalid account ID'),
|
||||
});
|
||||
|
||||
export type CreateFeatureInput = z.infer<typeof CreateFeatureSchema>;
|
||||
export type CreateFeatureInput = z.output<typeof CreateFeatureSchema>;
|
||||
```
|
||||
|
||||
### Step 2: Create Service Layer
|
||||
|
||||
**North star: services are decoupled from their interface.** The service is pure logic — it receives a database client as a dependency, never imports one. This means the same service works whether called from a server action, an MCP tool, a CLI command, or a plain unit test.
|
||||
**North star: services are decoupled from their interface.** The service is pure logic — it receives a database client
|
||||
as a dependency, never imports one. This means the same service works whether called from a server action, an MCP tool,
|
||||
a CLI command, or a plain unit test.
|
||||
|
||||
Create service in `_lib/server/`:
|
||||
|
||||
@@ -62,11 +64,13 @@ class FeatureService {
|
||||
}
|
||||
```
|
||||
|
||||
The service never calls `getSupabaseServerClient()` — the caller provides the client. This keeps the service testable (pass a mock client) and reusable (any interface can supply its own client).
|
||||
The service never calls `getSupabaseServerClient()` — the caller provides the client. This keeps the service testable (
|
||||
pass a mock client) and reusable (any interface can supply its own client).
|
||||
|
||||
### Step 3: Create Server Action (Thin Adapter)
|
||||
|
||||
The action is a **thin adapter** — it resolves dependencies (client, logger) and delegates to the service. No business logic lives here.
|
||||
The action is a **thin adapter** — it resolves dependencies (client, logger) and delegates to the service. No business
|
||||
logic lives here.
|
||||
|
||||
Create action in `_lib/server/server-actions.ts`:
|
||||
|
||||
@@ -107,13 +111,18 @@ export const createFeatureAction = enhanceAction(
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Services are pure, interfaces are thin adapters.** The service contains all business logic. The server action (or MCP tool, or CLI command) is glue code that resolves dependencies and calls the service. If an MCP tool and a server action do the same thing, they call the same service function.
|
||||
2. **Inject dependencies, don't import them in services.** Services receive their database client, logger, or any I/O capability as constructor arguments — never by importing framework-specific modules. This keeps them testable with stubs and reusable across interfaces.
|
||||
1. **Services are pure, interfaces are thin adapters.** The service contains all business logic. The server action (or
|
||||
MCP tool, or CLI command) is glue code that resolves dependencies and calls the service. If an MCP tool and a server
|
||||
action do the same thing, they call the same service function.
|
||||
2. **Inject dependencies, don't import them in services.** Services receive their database client, logger, or any I/O
|
||||
capability as constructor arguments — never by importing framework-specific modules. This keeps them testable with
|
||||
stubs and reusable across interfaces.
|
||||
3. **Schema in separate file** - Reusable between client and server
|
||||
4. **Logging** - Always log before and after operations
|
||||
5. **Revalidation** - Use `revalidatePath` after mutations
|
||||
6. **Trust RLS** - Don't add manual auth checks (RLS handles it)
|
||||
7. **Testable in isolation** - Because services accept their dependencies, you can test them with a mock client and no running infrastructure
|
||||
7. **Testable in isolation** - Because services accept their dependencies, you can test them with a mock client and no
|
||||
running infrastructure
|
||||
|
||||
## File Structure
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@ export const myAction = enhanceAction(
|
||||
|
||||
### Handler Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `data` | `z.infer<Schema>` | Validated input data |
|
||||
| `user` | `User` | Authenticated user (if auth: true) |
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------------------|------------------------------------|
|
||||
| `data` | `z.output<Schema>` | Validated input data |
|
||||
| `user` | `User` | Authenticated user (if auth: true) |
|
||||
|
||||
## enhanceRouteHandler API
|
||||
|
||||
@@ -69,7 +69,7 @@ export const GET = enhanceRouteHandler(
|
||||
## Common Zod Patterns
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
// Basic schema
|
||||
export const CreateItemSchema = z.object({
|
||||
|
||||
@@ -9,7 +9,9 @@ You are an expert at building pure, testable services that are decoupled from th
|
||||
|
||||
## North Star
|
||||
|
||||
**Every service is decoupled from its interface (I/O).** A service takes plain data in, does work, and returns plain data out. It has no knowledge of whether it was called from an MCP tool, a server action, a CLI command, a route handler, or a test. The caller is a thin adapter that resolves dependencies and delegates.
|
||||
**Every service is decoupled from its interface (I/O).** A service takes plain data in, does work, and returns plain
|
||||
data out. It has no knowledge of whether it was called from an MCP tool, a server action, a CLI command, a route
|
||||
handler, or a test. The caller is a thin adapter that resolves dependencies and delegates.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -21,7 +23,7 @@ Start with the input/output types. These are plain TypeScript — no framework t
|
||||
|
||||
```typescript
|
||||
// _lib/schemas/project.schema.ts
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const CreateProjectSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -40,7 +42,8 @@ export interface Project {
|
||||
|
||||
### Step 2: Build the Service
|
||||
|
||||
The service receives all dependencies through its constructor. It never imports framework-specific modules (`getSupabaseServerClient`, `getLogger`, `revalidatePath`, etc.).
|
||||
The service receives all dependencies through its constructor. It never imports framework-specific modules (
|
||||
`getSupabaseServerClient`, `getLogger`, `revalidatePath`, etc.).
|
||||
|
||||
```typescript
|
||||
// _lib/server/project.service.ts
|
||||
@@ -95,7 +98,8 @@ class ProjectService {
|
||||
|
||||
### Step 3: Write Thin Adapters
|
||||
|
||||
Each interface is a thin adapter — it resolves dependencies, calls the service, and handles interface-specific concerns (revalidation, redirects, MCP formatting, CLI output).
|
||||
Each interface is a thin adapter — it resolves dependencies, calls the service, and handles interface-specific
|
||||
concerns (revalidation, redirects, MCP formatting, CLI output).
|
||||
|
||||
**Server Action adapter:**
|
||||
|
||||
@@ -234,27 +238,32 @@ describe('ProjectService', () => {
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/`Response`, no MCP context, no `FormData`.
|
||||
1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/
|
||||
`Response`, no MCP context, no `FormData`.
|
||||
|
||||
2. **Inject dependencies, never import them.** The service receives its database client, storage client, or any I/O capability as a constructor argument. Never call `getSupabaseServerClient()` inside a service.
|
||||
2. **Inject dependencies, never import them.** The service receives its database client, storage client, or any I/O
|
||||
capability as a constructor argument. Never call `getSupabaseServerClient()` inside a service.
|
||||
|
||||
3. **Adapters are trivial glue.** A server action resolves the client, calls the service, and handles `revalidatePath`. An MCP tool resolves the client, calls the service, and formats the response. No business logic in adapters.
|
||||
3. **Adapters are trivial glue.** A server action resolves the client, calls the service, and handles `revalidatePath`.
|
||||
An MCP tool resolves the client, calls the service, and formats the response. No business logic in adapters.
|
||||
|
||||
4. **One service, many callers.** If two interfaces do the same thing, they call the same service function. Duplicating logic is a violation.
|
||||
4. **One service, many callers.** If two interfaces do the same thing, they call the same service function. Duplicating
|
||||
logic is a violation.
|
||||
|
||||
5. **Testable in isolation.** Pass a mock client, assert the output. If you need a running database to test a service, refactor until you don't.
|
||||
5. **Testable in isolation.** Pass a mock client, assert the output. If you need a running database to test a service,
|
||||
refactor until you don't.
|
||||
|
||||
## What Goes Where
|
||||
|
||||
| Concern | Location | Example |
|
||||
|---------|----------|---------|
|
||||
| Input validation (Zod) | `_lib/schemas/` | `CreateProjectSchema` |
|
||||
| Business logic | `_lib/server/*.service.ts` | `ProjectService.create()` |
|
||||
| Auth check | Adapter (`enhanceAction({ auth: true })`) | Server action wrapper |
|
||||
| Logging | Adapter | `logger.info()` before/after service call |
|
||||
| Cache revalidation | Adapter | `revalidatePath()` after mutation |
|
||||
| Redirect | Adapter | `redirect()` after creation |
|
||||
| MCP response format | Adapter | Return service result as MCP content |
|
||||
| Concern | Location | Example |
|
||||
|------------------------|-------------------------------------------|-------------------------------------------|
|
||||
| Input validation (Zod) | `_lib/schemas/` | `CreateProjectSchema` |
|
||||
| Business logic | `_lib/server/*.service.ts` | `ProjectService.create()` |
|
||||
| Auth check | Adapter (`enhanceAction({ auth: true })`) | Server action wrapper |
|
||||
| Logging | Adapter | `logger.info()` before/after service call |
|
||||
| Cache revalidation | Adapter | `revalidatePath()` after mutation |
|
||||
| Redirect | Adapter | `redirect()` after creation |
|
||||
| MCP response format | Adapter | Return service result as MCP content |
|
||||
|
||||
## File Structure
|
||||
|
||||
@@ -305,4 +314,5 @@ const result = await client.from('projects').insert(...).select().single();
|
||||
|
||||
## Reference
|
||||
|
||||
See `[Examples](examples.md)` for more patterns including services with multiple dependencies, services that compose other services, and testing strategies.
|
||||
See `[Examples](examples.md)` for more patterns including services with multiple dependencies, services that compose
|
||||
other services, and testing strategies.
|
||||
|
||||
Reference in New Issue
Block a user