@mars-stack/cli 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Skill: Build a Data Table
|
|
2
|
+
|
|
3
|
+
Create a data table with sorting, pagination, and actions using MARS design system components.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to display tabular data, build a list view, create a data grid, or show records in a table.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
MARS provides `Table` primitives (`Table`, `TableHead`, `TableBody`, `TableRow`, `TableHeaderCell`, `TableCell`) that use design tokens. Build feature-specific tables by composing these.
|
|
12
|
+
|
|
13
|
+
## Template: Full Data Table
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
'use client';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
Table, TableHead, TableBody, TableRow, TableHeaderCell, TableCell, Badge, Button, Spinner, Card, CardHeader, CardBody, EmptyState,
|
|
20
|
+
} from '@mars-stack/ui';
|
|
21
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
22
|
+
|
|
23
|
+
interface Item {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
status: 'active' | 'inactive' | 'pending';
|
|
27
|
+
createdAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface PaginatedResponse {
|
|
31
|
+
items: Item[];
|
|
32
|
+
pagination: { page: number; limit: number; total: number; totalPages: number };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SortField = 'name' | 'status' | 'createdAt';
|
|
36
|
+
type SortDirection = 'asc' | 'desc';
|
|
37
|
+
|
|
38
|
+
const statusVariant: Record<Item['status'], 'success' | 'neutral' | 'warning'> = {
|
|
39
|
+
active: 'success',
|
|
40
|
+
inactive: 'neutral',
|
|
41
|
+
pending: 'warning',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function ItemTable() {
|
|
45
|
+
const [data, setData] = useState<PaginatedResponse | null>(null);
|
|
46
|
+
const [loading, setLoading] = useState(true);
|
|
47
|
+
const [page, setPage] = useState(1);
|
|
48
|
+
const [sortField, setSortField] = useState<SortField>('createdAt');
|
|
49
|
+
const [sortDir, setSortDir] = useState<SortDirection>('desc');
|
|
50
|
+
|
|
51
|
+
const fetchData = useCallback(async () => {
|
|
52
|
+
setLoading(true);
|
|
53
|
+
try {
|
|
54
|
+
const params = new URLSearchParams({
|
|
55
|
+
page: String(page),
|
|
56
|
+
limit: '20',
|
|
57
|
+
sort: sortField,
|
|
58
|
+
dir: sortDir,
|
|
59
|
+
});
|
|
60
|
+
const res = await fetch(`/api/protected/items?${params}`);
|
|
61
|
+
const json = await res.json();
|
|
62
|
+
setData(json);
|
|
63
|
+
} finally {
|
|
64
|
+
setLoading(false);
|
|
65
|
+
}
|
|
66
|
+
}, [page, sortField, sortDir]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => { fetchData(); }, [fetchData]);
|
|
69
|
+
|
|
70
|
+
function handleSort(field: SortField) {
|
|
71
|
+
if (sortField === field) {
|
|
72
|
+
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
73
|
+
} else {
|
|
74
|
+
setSortField(field);
|
|
75
|
+
setSortDir('asc');
|
|
76
|
+
}
|
|
77
|
+
setPage(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function SortIndicator({ field }: { field: SortField }) {
|
|
81
|
+
if (sortField !== field) return <span className="text-text-muted ml-1">↕</span>;
|
|
82
|
+
return <span className="text-text-primary ml-1">{sortDir === 'asc' ? '↑' : '↓'}</span>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (loading && !data) {
|
|
86
|
+
return (
|
|
87
|
+
<div className="flex justify-center py-12">
|
|
88
|
+
<Spinner size="lg" />
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!data || data.items.length === 0) {
|
|
94
|
+
return (
|
|
95
|
+
<EmptyState
|
|
96
|
+
title="No items yet"
|
|
97
|
+
description="Create your first item to get started."
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { items, pagination } = data;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Card>
|
|
106
|
+
<CardHeader className="flex items-center justify-between">
|
|
107
|
+
<h2 className="text-lg font-semibold text-text-primary">
|
|
108
|
+
Items ({pagination.total})
|
|
109
|
+
</h2>
|
|
110
|
+
</CardHeader>
|
|
111
|
+
<CardBody className="p-0">
|
|
112
|
+
<Table>
|
|
113
|
+
<TableHead>
|
|
114
|
+
<TableRow>
|
|
115
|
+
<TableHeaderCell>
|
|
116
|
+
<button onClick={() => handleSort('name')} className="flex items-center">
|
|
117
|
+
Name <SortIndicator field="name" />
|
|
118
|
+
</button>
|
|
119
|
+
</TableHeaderCell>
|
|
120
|
+
<TableHeaderCell>
|
|
121
|
+
<button onClick={() => handleSort('status')} className="flex items-center">
|
|
122
|
+
Status <SortIndicator field="status" />
|
|
123
|
+
</button>
|
|
124
|
+
</TableHeaderCell>
|
|
125
|
+
<TableHeaderCell>
|
|
126
|
+
<button onClick={() => handleSort('createdAt')} className="flex items-center">
|
|
127
|
+
Created <SortIndicator field="createdAt" />
|
|
128
|
+
</button>
|
|
129
|
+
</TableHeaderCell>
|
|
130
|
+
<TableHeaderCell>
|
|
131
|
+
<span className="sr-only">Actions</span>
|
|
132
|
+
</TableHeaderCell>
|
|
133
|
+
</TableRow>
|
|
134
|
+
</TableHead>
|
|
135
|
+
<TableBody>
|
|
136
|
+
{items.map((item) => (
|
|
137
|
+
<TableRow key={item.id}>
|
|
138
|
+
<TableCell className="font-medium text-text-primary">{item.name}</TableCell>
|
|
139
|
+
<TableCell>
|
|
140
|
+
<Badge variant={statusVariant[item.status]}>{item.status}</Badge>
|
|
141
|
+
</TableCell>
|
|
142
|
+
<TableCell className="text-text-secondary">
|
|
143
|
+
{new Date(item.createdAt).toLocaleDateString()}
|
|
144
|
+
</TableCell>
|
|
145
|
+
<TableCell className="text-right">
|
|
146
|
+
<Button variant="subtle" size="sm">Edit</Button>
|
|
147
|
+
</TableCell>
|
|
148
|
+
</TableRow>
|
|
149
|
+
))}
|
|
150
|
+
</TableBody>
|
|
151
|
+
</Table>
|
|
152
|
+
</CardBody>
|
|
153
|
+
|
|
154
|
+
{pagination.totalPages > 1 && (
|
|
155
|
+
<div className="flex items-center justify-between border-t border-border-default px-4 py-3">
|
|
156
|
+
<p className="text-sm text-text-secondary">
|
|
157
|
+
Page {pagination.page} of {pagination.totalPages}
|
|
158
|
+
</p>
|
|
159
|
+
<div className="flex gap-2">
|
|
160
|
+
<Button
|
|
161
|
+
variant="subtle"
|
|
162
|
+
size="sm"
|
|
163
|
+
disabled={pagination.page <= 1}
|
|
164
|
+
onClick={() => setPage((p) => p - 1)}
|
|
165
|
+
>
|
|
166
|
+
Previous
|
|
167
|
+
</Button>
|
|
168
|
+
<Button
|
|
169
|
+
variant="subtle"
|
|
170
|
+
size="sm"
|
|
171
|
+
disabled={pagination.page >= pagination.totalPages}
|
|
172
|
+
onClick={() => setPage((p) => p + 1)}
|
|
173
|
+
>
|
|
174
|
+
Next
|
|
175
|
+
</Button>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</Card>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## API Endpoint for Sorted + Paginated Data
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
188
|
+
try {
|
|
189
|
+
const url = new URL(request.url);
|
|
190
|
+
const page = Math.max(1, Number(url.searchParams.get('page') || '1'));
|
|
191
|
+
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') || '20')));
|
|
192
|
+
const sort = url.searchParams.get('sort') || 'createdAt';
|
|
193
|
+
const dir = url.searchParams.get('dir') === 'asc' ? 'asc' : 'desc';
|
|
194
|
+
|
|
195
|
+
const allowedSortFields = ['name', 'status', 'createdAt'];
|
|
196
|
+
const orderBy = allowedSortFields.includes(sort)
|
|
197
|
+
? { [sort]: dir }
|
|
198
|
+
: { createdAt: 'desc' as const };
|
|
199
|
+
|
|
200
|
+
const where = { userId: request.session.userId };
|
|
201
|
+
const [items, total] = await Promise.all([
|
|
202
|
+
prisma.item.findMany({ where, orderBy, skip: (page - 1) * limit, take: limit }),
|
|
203
|
+
prisma.item.count({ where }),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
return NextResponse.json({
|
|
207
|
+
items,
|
|
208
|
+
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
|
209
|
+
});
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return handleApiError(error, { endpoint: '/api/protected/items' });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Patterns
|
|
217
|
+
|
|
218
|
+
### Search/Filter Bar
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
222
|
+
<h2 className="text-lg font-semibold text-text-primary">Items</h2>
|
|
223
|
+
<div className="flex gap-2">
|
|
224
|
+
<Input
|
|
225
|
+
name="search"
|
|
226
|
+
placeholder="Search..."
|
|
227
|
+
value={search}
|
|
228
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
229
|
+
className="w-48"
|
|
230
|
+
/>
|
|
231
|
+
<Select
|
|
232
|
+
name="status"
|
|
233
|
+
value={statusFilter}
|
|
234
|
+
onChange={(e) => setStatusFilter(e.target.value)}
|
|
235
|
+
options={[
|
|
236
|
+
{ value: '', label: 'All statuses' },
|
|
237
|
+
{ value: 'active', label: 'Active' },
|
|
238
|
+
{ value: 'inactive', label: 'Inactive' },
|
|
239
|
+
]}
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
</CardHeader>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Row Actions (Delete with Confirmation)
|
|
246
|
+
|
|
247
|
+
```tsx
|
|
248
|
+
<TableCell className="text-right">
|
|
249
|
+
<div className="flex justify-end gap-1">
|
|
250
|
+
<Button variant="subtle" size="sm" onClick={() => handleEdit(item.id)}>
|
|
251
|
+
Edit
|
|
252
|
+
</Button>
|
|
253
|
+
<Button variant="danger" size="sm" onClick={() => handleDelete(item.id)}>
|
|
254
|
+
Delete
|
|
255
|
+
</Button>
|
|
256
|
+
</div>
|
|
257
|
+
</TableCell>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Loading Overlay
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
<div className="relative">
|
|
264
|
+
{loading && (
|
|
265
|
+
<div className="absolute inset-0 flex items-center justify-center bg-surface-card/80 z-10">
|
|
266
|
+
<Spinner size="md" />
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
<Table>...</Table>
|
|
270
|
+
</div>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Checklist
|
|
274
|
+
|
|
275
|
+
- [ ] Table uses MARS `Table` primitives
|
|
276
|
+
- [ ] Sortable column headers with visual indicators
|
|
277
|
+
- [ ] Pagination with page controls
|
|
278
|
+
- [ ] Empty state when no data
|
|
279
|
+
- [ ] Loading spinner on initial load
|
|
280
|
+
- [ ] Status badges use `Badge` with appropriate variants
|
|
281
|
+
- [ ] All colours use semantic tokens
|
|
282
|
+
- [ ] API endpoint supports `page`, `limit`, `sort`, `dir` params
|
|
283
|
+
- [ ] Sort field allowlist prevents injection
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Skill: Build a Form
|
|
2
|
+
|
|
3
|
+
Create a validated form using MARS design system components, Zod schemas, and proper error handling.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to build a form, input form, settings form, contact form, or any data entry UI.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
MARS forms use:
|
|
12
|
+
- **Primitives** (`Input`, `Select`, `Checkbox`, `Textarea`, `Button`) for fields
|
|
13
|
+
- **Patterns** (`FormField`, `Card`) for layout
|
|
14
|
+
- **Zod schemas** for validation (shared with API routes)
|
|
15
|
+
- **`useZodForm` hook** for client-side form state (if available), or native React state
|
|
16
|
+
|
|
17
|
+
## Template: Basic Form
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
'use client';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
Button, Input, Select, Textarea, Checkbox, Card, CardHeader, CardBody, CardFooter, H2,
|
|
24
|
+
} from '@mars-stack/ui';
|
|
25
|
+
import { useState, type FormEvent } from 'react';
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
|
|
28
|
+
const formSchema = z.object({
|
|
29
|
+
name: z.string().min(1, 'Name is required').max(100),
|
|
30
|
+
email: z.string().email('Invalid email address'),
|
|
31
|
+
category: z.string().min(1, 'Please select a category'),
|
|
32
|
+
message: z.string().min(10, 'Message must be at least 10 characters').max(1000),
|
|
33
|
+
agree: z.literal(true, { errorMap: () => ({ message: 'You must agree to continue' }) }),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
type FormData = z.infer<typeof formSchema>;
|
|
37
|
+
type FormErrors = Partial<Record<keyof FormData, string>>;
|
|
38
|
+
|
|
39
|
+
export function ContactForm() {
|
|
40
|
+
const [errors, setErrors] = useState<FormErrors>({});
|
|
41
|
+
const [submitting, setSubmitting] = useState(false);
|
|
42
|
+
const [success, setSuccess] = useState(false);
|
|
43
|
+
|
|
44
|
+
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
setErrors({});
|
|
47
|
+
|
|
48
|
+
const formData = new FormData(e.currentTarget);
|
|
49
|
+
const raw = {
|
|
50
|
+
name: formData.get('name') as string,
|
|
51
|
+
email: formData.get('email') as string,
|
|
52
|
+
category: formData.get('category') as string,
|
|
53
|
+
message: formData.get('message') as string,
|
|
54
|
+
agree: formData.get('agree') === 'on' ? true : false,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const result = formSchema.safeParse(raw);
|
|
58
|
+
if (!result.success) {
|
|
59
|
+
const fieldErrors: FormErrors = {};
|
|
60
|
+
for (const issue of result.error.issues) {
|
|
61
|
+
const field = issue.path[0] as keyof FormData;
|
|
62
|
+
if (!fieldErrors[field]) fieldErrors[field] = issue.message;
|
|
63
|
+
}
|
|
64
|
+
setErrors(fieldErrors);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setSubmitting(true);
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch('/api/protected/contact', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
body: JSON.stringify(result.data),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const data = await response.json();
|
|
78
|
+
setErrors({ name: data.error || 'Something went wrong' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setSuccess(true);
|
|
83
|
+
} finally {
|
|
84
|
+
setSubmitting(false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (success) {
|
|
89
|
+
return (
|
|
90
|
+
<Card>
|
|
91
|
+
<CardBody className="text-center py-8">
|
|
92
|
+
<p className="text-text-success font-medium">Message sent successfully!</p>
|
|
93
|
+
</CardBody>
|
|
94
|
+
</Card>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<form onSubmit={handleSubmit} noValidate>
|
|
100
|
+
<Card>
|
|
101
|
+
<CardHeader>
|
|
102
|
+
<H2>Contact Us</H2>
|
|
103
|
+
</CardHeader>
|
|
104
|
+
<CardBody className="space-y-4">
|
|
105
|
+
<Input
|
|
106
|
+
name="name"
|
|
107
|
+
label="Full Name"
|
|
108
|
+
placeholder="Jane Smith"
|
|
109
|
+
error={errors.name}
|
|
110
|
+
required
|
|
111
|
+
/>
|
|
112
|
+
<Input
|
|
113
|
+
name="email"
|
|
114
|
+
label="Email"
|
|
115
|
+
type="email"
|
|
116
|
+
placeholder="jane@example.com"
|
|
117
|
+
error={errors.email}
|
|
118
|
+
required
|
|
119
|
+
/>
|
|
120
|
+
<Select
|
|
121
|
+
name="category"
|
|
122
|
+
label="Category"
|
|
123
|
+
placeholder="Select a category"
|
|
124
|
+
error={errors.category}
|
|
125
|
+
options={[
|
|
126
|
+
{ value: 'general', label: 'General Inquiry' },
|
|
127
|
+
{ value: 'support', label: 'Technical Support' },
|
|
128
|
+
{ value: 'billing', label: 'Billing' },
|
|
129
|
+
{ value: 'feedback', label: 'Feedback' },
|
|
130
|
+
]}
|
|
131
|
+
/>
|
|
132
|
+
<Textarea
|
|
133
|
+
name="message"
|
|
134
|
+
label="Message"
|
|
135
|
+
placeholder="Tell us how we can help..."
|
|
136
|
+
rows={5}
|
|
137
|
+
error={errors.message}
|
|
138
|
+
required
|
|
139
|
+
/>
|
|
140
|
+
<Checkbox
|
|
141
|
+
name="agree"
|
|
142
|
+
label="I agree to the terms and conditions"
|
|
143
|
+
error={errors.agree}
|
|
144
|
+
/>
|
|
145
|
+
</CardBody>
|
|
146
|
+
<CardFooter className="flex justify-end gap-3">
|
|
147
|
+
<Button type="button" variant="subtle">
|
|
148
|
+
Cancel
|
|
149
|
+
</Button>
|
|
150
|
+
<Button type="submit" disabled={submitting}>
|
|
151
|
+
{submitting ? 'Sending...' : 'Send Message'}
|
|
152
|
+
</Button>
|
|
153
|
+
</CardFooter>
|
|
154
|
+
</Card>
|
|
155
|
+
</form>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Validation Pattern
|
|
161
|
+
|
|
162
|
+
**Share schemas between client and API:**
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// src/features/contact/validation/schemas.ts
|
|
166
|
+
import { z } from 'zod';
|
|
167
|
+
|
|
168
|
+
export const contactSchemas = {
|
|
169
|
+
create: z.object({
|
|
170
|
+
name: z.string().min(1).max(100),
|
|
171
|
+
email: z.string().email(),
|
|
172
|
+
category: z.string().min(1),
|
|
173
|
+
message: z.string().min(10).max(1000),
|
|
174
|
+
}),
|
|
175
|
+
};
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Import in both the form component and the API route handler.
|
|
179
|
+
|
|
180
|
+
## Form Layouts
|
|
181
|
+
|
|
182
|
+
### Two-Column Layout
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
<CardBody className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
186
|
+
<Input name="firstName" label="First Name" />
|
|
187
|
+
<Input name="lastName" label="Last Name" />
|
|
188
|
+
<Input name="email" label="Email" className="md:col-span-2" />
|
|
189
|
+
</CardBody>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Sections with Dividers
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
<CardBody className="space-y-6">
|
|
196
|
+
<div className="space-y-4">
|
|
197
|
+
<h3 className="text-sm font-medium text-text-secondary uppercase tracking-wide">Personal</h3>
|
|
198
|
+
<Input name="name" label="Name" />
|
|
199
|
+
<Input name="email" label="Email" />
|
|
200
|
+
</div>
|
|
201
|
+
<Divider />
|
|
202
|
+
<div className="space-y-4">
|
|
203
|
+
<h3 className="text-sm font-medium text-text-secondary uppercase tracking-wide">Preferences</h3>
|
|
204
|
+
<Select name="timezone" label="Timezone" options={timezones} />
|
|
205
|
+
</div>
|
|
206
|
+
</CardBody>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## With Server Actions
|
|
210
|
+
|
|
211
|
+
For progressive enhancement, use Server Actions instead of `fetch`:
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
<form action={submitContact}>
|
|
215
|
+
<Input name="name" label="Name" />
|
|
216
|
+
<Button type="submit">Send</Button>
|
|
217
|
+
</form>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
See the `add-server-action` skill for the full pattern.
|
|
221
|
+
|
|
222
|
+
## Checklist
|
|
223
|
+
|
|
224
|
+
- [ ] Zod schema defined and shared with API route
|
|
225
|
+
- [ ] Client-side validation with `safeParse` before submission
|
|
226
|
+
- [ ] Error messages displayed on individual fields
|
|
227
|
+
- [ ] Loading state on submit button
|
|
228
|
+
- [ ] Success state after submission
|
|
229
|
+
- [ ] All form inputs use MARS primitives (`Input`, `Select`, `Textarea`, `Checkbox`)
|
|
230
|
+
- [ ] Form wrapped in `Card` pattern for consistent layout
|
|
231
|
+
- [ ] `noValidate` on `<form>` to use custom validation
|