Skip to content

Commit 7948fd9

Browse files
Copilothotlong
andcommitted
refactor(web): integrate @objectstack/client SDK for data and metadata API
- Replace custom api.ts fetch wrapper with @objectstack/client singleton - Align types/metadata.ts FieldType with @objectstack/spec (42 field types) - Align AppDefinition with spec: name/label/active instead of id/name/status - Refactor use-metadata.ts to use client.meta.* with mock fallback - Refactor use-records.ts to use client.data.* with mock fallback - Update FieldRenderer to support lookup, master_detail, multiselect, tags, etc. - Update all pages/components for spec-aligned AppDefinition properties - Update mock-data.ts to use spec-aligned types - Update tests for new AppDefinition shape - Update APPS_WEB_ROADMAP.md to reflect @objectstack/client usage Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent e6a3849 commit 7948fd9

File tree

15 files changed

+256
-163
lines changed

15 files changed

+256
-163
lines changed

APPS_WEB_ROADMAP.md

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ interface AppDefinition {
130130

131131
| Deliverable | File(s) | Description |
132132
|---|---|---|
133-
| API client | `src/lib/api.ts` | Typed fetch wrapper for `/api/v1/*` |
133+
| API client | `src/lib/api.ts` | `@objectstack/client` SDK with mock fallback |
134134
| Metadata types | `src/types/metadata.ts` | TypeScript interfaces for objects, fields, apps |
135135
| Metadata hooks | `src/hooks/use-metadata.ts` | TanStack Query hooks: `useAppObjects`, `useObjectDefinition` |
136136
| Record hooks | `src/hooks/use-records.ts` | TanStack Query hooks: `useRecords`, `useRecord`, mutations |
@@ -197,32 +197,31 @@ interface AppDefinition {
197197

198198
## 5. API Contract
199199

200-
### Metadata Endpoints
200+
The frontend uses the official `@objectstack/client` SDK to interact with the server.
201201

202-
```
203-
GET /api/v1/metadata/objects → { objects: ObjectDefinition[] }
204-
GET /api/v1/metadata/objects/:name → { object: ObjectDefinition }
205-
GET /api/v1/metadata/apps → { apps: AppDefinition[] }
206-
GET /api/v1/metadata/apps/:appId → { app: AppDefinition }
207-
```
208-
209-
### Data Endpoints
202+
### Client SDK Usage
210203

211-
```
212-
GET /api/v1/data/:objectName → { records: Record[], total: number, page: number }
213-
GET /api/v1/data/:objectName/:id → { record: Record }
214-
POST /api/v1/data/:objectName → { record: Record }
215-
PATCH /api/v1/data/:objectName/:id → { record: Record }
216-
DELETE /api/v1/data/:objectName/:id → { success: true }
217-
```
218-
219-
### Query Parameters (List)
220-
221-
```
222-
?page=1&pageSize=20&sort=name&order=asc&search=keyword&filter[status]=active
204+
```typescript
205+
import { ObjectStackClient } from '@objectstack/client';
206+
207+
const client = new ObjectStackClient({ baseUrl: '/api/v1' });
208+
209+
// Metadata
210+
client.meta.getObject('lead') // → ObjectDefinition
211+
client.meta.getItems('object') // → { type: 'object', items: ObjectDefinition[] }
212+
client.meta.getItems('app') // → { type: 'app', items: AppDefinition[] }
213+
client.meta.getItem('app', 'crm') // → AppDefinition
214+
215+
// Data
216+
client.data.find('lead', { top: 20 }) // → { records: T[], total: number }
217+
client.data.get('lead', 'lead-001') // → { record: T }
218+
client.data.create('lead', { ... }) // → { record: T }
219+
client.data.update('lead', 'id', { }) // → { record: T }
220+
client.data.delete('lead', 'id') // → { deleted: true }
223221
```
224222

225-
> **Note**: Until the server implements these endpoints, the frontend uses mock data with the same interface. The API layer (`src/lib/api.ts`) abstracts this so switching to real endpoints requires zero page-level changes.
223+
> When the server is unreachable, hooks fall back to mock data from `lib/mock-data.ts`.
224+
> This allows the UI to be developed independently of the backend.
226225
227226
---
228227

@@ -238,7 +237,7 @@ apps/web/src/
238237
│ └── metadata.ts # Object, Field, App type definitions
239238
240239
├── lib/
241-
│ ├── api.ts # Fetch wrapper for /api/v1/*
240+
│ ├── api.ts # @objectstack/client SDK singleton
242241
│ ├── auth-client.ts # Better-Auth client
243242
│ ├── app-registry.ts # App registry (mock → API)
244243
│ ├── mock-data.ts # Mock metadata & records for dev

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"test:watch": "vitest"
1313
},
1414
"dependencies": {
15+
"@objectstack/client": "1.1.0",
1516
"@radix-ui/react-dialog": "^1.1.15",
1617
"@radix-ui/react-dropdown-menu": "^2.1.16",
1718
"@radix-ui/react-select": "^2.2.6",

apps/web/src/__tests__/lib/mock-data.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ describe('mock-data', () => {
1313
it('returns the CRM app definition', () => {
1414
const app = getMockAppDefinition('crm');
1515
expect(app).toBeDefined();
16-
expect(app?.name).toBe('CRM');
16+
expect(app?.label).toBe('CRM');
1717
expect(app?.objects).toContain('lead');
18-
expect(app?.category).toBe('business');
18+
expect(app?.active).toBe(true);
1919
});
2020

2121
it('returns undefined for unknown app', () => {
@@ -73,10 +73,10 @@ describe('mock-data', () => {
7373
describe('data consistency', () => {
7474
it('all app objects reference existing object definitions', () => {
7575
for (const app of mockAppDefinitions) {
76-
for (const objName of app.objects) {
76+
for (const objName of (app.objects ?? [])) {
7777
expect(
7878
mockObjectDefinitions[objName],
79-
`App "${app.id}" references undefined object "${objName}"`,
79+
`App "${app.name}" references undefined object "${objName}"`,
8080
).toBeDefined();
8181
}
8282
}

apps/web/src/components/layouts/AppLayout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@ import { useAppDefinition, useObjectDefinition } from '@/hooks/use-metadata';
2424
/** Helper: resolve an object name to its plural label for the sidebar. */
2525
function ObjectNavLabel({ objectName }: { objectName: string }) {
2626
const { data: objectDef } = useObjectDefinition(objectName);
27-
return <span>{objectDef?.pluralLabel ?? objectName}</span>;
27+
return <span>{objectDef?.pluralLabel ?? objectDef?.label ?? objectName}</span>;
2828
}
2929

3030
export function AppLayout() {
3131
const { pathname } = useLocation();
3232
const { appId } = useParams();
3333

3434
const { data: appDef } = useAppDefinition(appId);
35-
const appName = appDef?.name ?? appId ?? 'App';
35+
const appName = appDef?.label ?? appId ?? 'App';
3636
const objectNames = appDef?.objects ?? [];
3737

3838
return (

apps/web/src/components/records/FieldRenderer.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ export function FieldRenderer({ field, value }: FieldRendererProps) {
2020

2121
switch (field.type) {
2222
case 'boolean':
23+
case 'toggle':
2324
return <span>{value ? 'Yes' : 'No'}</span>;
2425

26+
case 'date':
2527
case 'datetime': {
2628
const date = new Date(String(value));
2729
if (isNaN(date.getTime())) return <span>{String(value)}</span>;
@@ -52,7 +54,8 @@ export function FieldRenderer({ field, value }: FieldRendererProps) {
5254
case 'number':
5355
return <span>{new Intl.NumberFormat().format(Number(value))}</span>;
5456

55-
case 'select': {
57+
case 'select':
58+
case 'radio': {
5659
const option = field.options?.find((o) => o.value === value);
5760
return (
5861
<Badge variant="outline" className="font-normal">
@@ -61,6 +64,24 @@ export function FieldRenderer({ field, value }: FieldRendererProps) {
6164
);
6265
}
6366

67+
case 'multiselect':
68+
case 'checkboxes':
69+
case 'tags': {
70+
const values = Array.isArray(value) ? value : [value];
71+
return (
72+
<div className="flex flex-wrap gap-1">
73+
{values.map((v) => {
74+
const opt = field.options?.find((o) => o.value === v);
75+
return (
76+
<Badge key={String(v)} variant="outline" className="font-normal">
77+
{opt?.label ?? String(v)}
78+
</Badge>
79+
);
80+
})}
81+
</div>
82+
);
83+
}
84+
6485
case 'email':
6586
return (
6687
<a
@@ -93,10 +114,14 @@ export function FieldRenderer({ field, value }: FieldRendererProps) {
93114
</a>
94115
);
95116

96-
case 'reference':
117+
case 'lookup':
118+
case 'master_detail':
97119
return <span className="text-muted-foreground">{String(value)}</span>;
98120

99121
case 'textarea':
122+
case 'markdown':
123+
case 'richtext':
124+
case 'html':
100125
return (
101126
<span className="line-clamp-2" title={String(value)}>
102127
{String(value)}

apps/web/src/components/records/RecordTable.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ export function RecordTable({ objectDef, records, basePath }: RecordTableProps)
2929
Object.keys(objectDef.fields).filter((k) => k !== 'id' && !objectDef.fields[k].readonly);
3030

3131
const columns = columnNames
32-
.map((name) => objectDef.fields[name])
33-
.filter(Boolean);
32+
.map((name) => ({ ...objectDef.fields[name], name }))
33+
.filter((f) => f.type);
3434

3535
if (records.length === 0) {
3636
return (
3737
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
38-
<p className="text-lg font-medium">No {objectDef.pluralLabel.toLowerCase()} yet</p>
38+
<p className="text-lg font-medium">No {(objectDef.pluralLabel ?? objectDef.label ?? 'records').toLowerCase()} yet</p>
3939
<p className="text-sm text-muted-foreground">
4040
Records will appear here once they are created.
4141
</p>
@@ -49,7 +49,7 @@ export function RecordTable({ objectDef, records, basePath }: RecordTableProps)
4949
<TableHeader>
5050
<TableRow>
5151
{columns.map((col) => (
52-
<TableHead key={col.name}>{col.label}</TableHead>
52+
<TableHead key={col.name}>{col.label ?? col.name}</TableHead>
5353
))}
5454
</TableRow>
5555
</TableHeader>

apps/web/src/hooks/use-metadata.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
/**
22
* TanStack Query hooks for fetching object and app metadata.
33
*
4-
* Uses mock data in development. When the server metadata endpoints
5-
* are available, swap the queryFn to call `apiFetch` instead.
4+
* Uses the official @objectstack/client SDK to fetch from the server.
5+
* Falls back to mock data when the server is unreachable so the UI
6+
* can be developed without a running backend.
67
*/
78

89
import { useQuery } from '@tanstack/react-query';
910
import type { AppDefinition, ObjectDefinition } from '@/types/metadata';
11+
import { objectStackClient } from '@/lib/api';
1012
import { getMockAppDefinition, getMockObjectDefinition, mockAppDefinitions } from '@/lib/mock-data';
1113

1214
// ── App metadata ────────────────────────────────────────────────
1315

1416
export function useAppDefinition(appId: string | undefined) {
1517
return useQuery<AppDefinition | undefined>({
1618
queryKey: ['metadata', 'app', appId],
17-
queryFn: () => {
18-
// TODO: replace with apiFetch<AppDefinition>(`/metadata/apps/${appId}`)
19-
return Promise.resolve(appId ? getMockAppDefinition(appId) : undefined);
19+
queryFn: async () => {
20+
if (!appId) return undefined;
21+
try {
22+
const result = await objectStackClient.meta.getItem('app', appId);
23+
if (result) return result as AppDefinition;
24+
} catch {
25+
// Server unreachable — use mock data
26+
}
27+
return getMockAppDefinition(appId);
2028
},
2129
enabled: !!appId,
2230
});
@@ -25,9 +33,14 @@ export function useAppDefinition(appId: string | undefined) {
2533
export function useAppList() {
2634
return useQuery<AppDefinition[]>({
2735
queryKey: ['metadata', 'apps'],
28-
queryFn: () => {
29-
// TODO: replace with apiFetch<AppDefinition[]>('/metadata/apps')
30-
return Promise.resolve(mockAppDefinitions);
36+
queryFn: async () => {
37+
try {
38+
const result = await objectStackClient.meta.getItems('app');
39+
if (result?.items?.length) return result.items as AppDefinition[];
40+
} catch {
41+
// Server unreachable — use mock data
42+
}
43+
return mockAppDefinitions;
3144
},
3245
});
3346
}
@@ -37,9 +50,15 @@ export function useAppList() {
3750
export function useObjectDefinition(objectName: string | undefined) {
3851
return useQuery<ObjectDefinition | undefined>({
3952
queryKey: ['metadata', 'object', objectName],
40-
queryFn: () => {
41-
// TODO: replace with apiFetch<ObjectDefinition>(`/metadata/objects/${objectName}`)
42-
return Promise.resolve(objectName ? getMockObjectDefinition(objectName) : undefined);
53+
queryFn: async () => {
54+
if (!objectName) return undefined;
55+
try {
56+
const result = await objectStackClient.meta.getObject(objectName);
57+
if (result) return result as ObjectDefinition;
58+
} catch {
59+
// Server unreachable — use mock data
60+
}
61+
return getMockObjectDefinition(objectName);
4362
},
4463
enabled: !!objectName,
4564
});

apps/web/src/hooks/use-records.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
/**
22
* TanStack Query hooks for CRUD record operations.
33
*
4-
* Uses mock data in development. When the server data endpoints
5-
* are available, swap the queryFn to call `apiFetch` instead.
4+
* Uses the official @objectstack/client SDK to fetch from the server.
5+
* Falls back to mock data when the server is unreachable.
66
*/
77

88
import { useQuery } from '@tanstack/react-query';
99
import type { RecordData, RecordListResponse } from '@/types/metadata';
10+
import { objectStackClient } from '@/lib/api';
1011
import { getMockRecords, getMockRecord } from '@/lib/mock-data';
1112

1213
// ── Record list ─────────────────────────────────────────────────
@@ -20,17 +21,26 @@ interface UseRecordsOptions {
2021
export function useRecords({ objectName, page = 1, pageSize = 20 }: UseRecordsOptions) {
2122
return useQuery<RecordListResponse>({
2223
queryKey: ['records', objectName, page, pageSize],
23-
queryFn: () => {
24-
// TODO: replace with apiFetch<RecordListResponse>(`/data/${objectName}?page=${page}&pageSize=${pageSize}`)
25-
const all = objectName ? getMockRecords(objectName) : [];
24+
queryFn: async () => {
25+
if (!objectName) return { records: [], total: 0, page, pageSize };
26+
try {
27+
const result = await objectStackClient.data.find(objectName, {
28+
top: pageSize,
29+
skip: (page - 1) * pageSize,
30+
});
31+
return {
32+
records: result.records ?? [],
33+
total: result.total ?? result.records?.length ?? 0,
34+
page,
35+
pageSize,
36+
};
37+
} catch {
38+
// Server unreachable — use mock data
39+
}
40+
const all = getMockRecords(objectName);
2641
const start = (page - 1) * pageSize;
2742
const records = all.slice(start, start + pageSize);
28-
return Promise.resolve({
29-
records,
30-
total: all.length,
31-
page,
32-
pageSize,
33-
});
43+
return { records, total: all.length, page, pageSize };
3444
},
3545
enabled: !!objectName,
3646
});
@@ -46,10 +56,15 @@ interface UseRecordOptions {
4656
export function useRecord({ objectName, recordId }: UseRecordOptions) {
4757
return useQuery<RecordData | undefined>({
4858
queryKey: ['record', objectName, recordId],
49-
queryFn: () => {
50-
// TODO: replace with apiFetch<RecordData>(`/data/${objectName}/${recordId}`)
51-
if (!objectName || !recordId) return Promise.resolve(undefined);
52-
return Promise.resolve(getMockRecord(objectName, recordId));
59+
queryFn: async () => {
60+
if (!objectName || !recordId) return undefined;
61+
try {
62+
const result = await objectStackClient.data.get(objectName, recordId);
63+
if (result?.record) return result.record as RecordData;
64+
} catch {
65+
// Server unreachable — use mock data
66+
}
67+
return getMockRecord(objectName, recordId);
5368
},
5469
enabled: !!objectName && !!recordId,
5570
});

0 commit comments

Comments
 (0)