Skip to content

Commit 8809a5e

Browse files
authored
Merge pull request #221 from objectstack-ai/copilot/build-core-infrastructure
2 parents 2e0cd2b + 6bd1f04 commit 8809a5e

5 files changed

Lines changed: 201 additions & 1 deletion

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Tests for use-metadata hooks.
3+
*
4+
* Validates the exports and types of all metadata hooks,
5+
* including the newly added useAppObjects hook.
6+
*/
7+
import { describe, it, expect } from 'vitest';
8+
import {
9+
useAppDefinition,
10+
useAppList,
11+
useObjectDefinition,
12+
useAppObjects,
13+
} from '@/hooks/use-metadata';
14+
15+
describe('use-metadata exports', () => {
16+
it('exports useAppDefinition hook', () => {
17+
expect(useAppDefinition).toBeTypeOf('function');
18+
});
19+
20+
it('exports useAppList hook', () => {
21+
expect(useAppList).toBeTypeOf('function');
22+
});
23+
24+
it('exports useObjectDefinition hook', () => {
25+
expect(useObjectDefinition).toBeTypeOf('function');
26+
});
27+
28+
it('exports useAppObjects hook', () => {
29+
expect(useAppObjects).toBeTypeOf('function');
30+
});
31+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Tests for use-records hooks.
3+
*
4+
* Validates the exports and types of all record hooks, including
5+
* the newly added mutation hooks (useCreateRecord, useUpdateRecord, useDeleteRecord).
6+
*/
7+
import { describe, it, expect } from 'vitest';
8+
import {
9+
useRecords,
10+
useRecord,
11+
useCreateRecord,
12+
useUpdateRecord,
13+
useDeleteRecord,
14+
} from '@/hooks/use-records';
15+
16+
describe('use-records exports', () => {
17+
it('exports useRecords hook', () => {
18+
expect(useRecords).toBeTypeOf('function');
19+
});
20+
21+
it('exports useRecord hook', () => {
22+
expect(useRecord).toBeTypeOf('function');
23+
});
24+
25+
it('exports useCreateRecord hook', () => {
26+
expect(useCreateRecord).toBeTypeOf('function');
27+
});
28+
29+
it('exports useUpdateRecord hook', () => {
30+
expect(useUpdateRecord).toBeTypeOf('function');
31+
});
32+
33+
it('exports useDeleteRecord hook', () => {
34+
expect(useDeleteRecord).toBeTypeOf('function');
35+
});
36+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { resolveFields } from '@/types/metadata';
3+
import type { FieldDefinition } from '@/types/metadata';
4+
5+
describe('resolveFields', () => {
6+
const fields: Record<string, FieldDefinition> = {
7+
name: { type: 'text', label: 'Full Name', required: true },
8+
email: { type: 'email' },
9+
status: { name: 'status', type: 'select', label: 'Status' },
10+
};
11+
12+
it('returns all fields with guaranteed name and label', () => {
13+
const resolved = resolveFields(fields);
14+
expect(resolved).toHaveLength(3);
15+
for (const f of resolved) {
16+
expect(f.name).toBeDefined();
17+
expect(f.label).toBeDefined();
18+
}
19+
});
20+
21+
it('uses the record key as the field name when name is missing', () => {
22+
const resolved = resolveFields(fields);
23+
const emailField = resolved.find((f) => f.name === 'email');
24+
expect(emailField).toBeDefined();
25+
expect(emailField!.label).toBe('email');
26+
});
27+
28+
it('preserves explicit name and label', () => {
29+
const resolved = resolveFields(fields);
30+
const nameField = resolved.find((f) => f.name === 'name');
31+
expect(nameField!.label).toBe('Full Name');
32+
});
33+
34+
it('excludes specified fields', () => {
35+
const resolved = resolveFields(fields, ['email']);
36+
expect(resolved).toHaveLength(2);
37+
expect(resolved.find((f) => f.name === 'email')).toBeUndefined();
38+
});
39+
40+
it('returns empty array for empty fields', () => {
41+
expect(resolveFields({})).toEqual([]);
42+
});
43+
});

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,31 @@ export function useObjectDefinition(objectName: string | undefined) {
6363
enabled: !!objectName,
6464
});
6565
}
66+
67+
/**
68+
* Fetch all ObjectDefinition entries that belong to a given app.
69+
* Resolves each object name listed in `AppDefinition.objects` into its full definition.
70+
*/
71+
export function useAppObjects(appId: string | undefined) {
72+
const appQuery = useAppDefinition(appId);
73+
74+
return useQuery<ObjectDefinition[]>({
75+
queryKey: ['metadata', 'appObjects', appId],
76+
queryFn: async () => {
77+
const objectNames = appQuery.data?.objects ?? [];
78+
const settled = await Promise.allSettled(
79+
objectNames.map((name) =>
80+
objectStackClient.meta.getObject(name).then((r) =>
81+
r ? (r as ObjectDefinition) : getMockObjectDefinition(name),
82+
).catch(() => getMockObjectDefinition(name)),
83+
),
84+
);
85+
return settled
86+
.filter((r): r is PromiseFulfilledResult<ObjectDefinition | undefined> =>
87+
r.status === 'fulfilled')
88+
.map((r) => r.value)
89+
.filter((v): v is ObjectDefinition => !!v);
90+
},
91+
enabled: !!appId && !!appQuery.data,
92+
});
93+
}

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

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Falls back to mock data when the server is unreachable.
66
*/
77

8-
import { useQuery } from '@tanstack/react-query';
8+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
99
import type { RecordData, RecordListResponse } from '@/types/metadata';
1010
import { objectStackClient } from '@/lib/api';
1111
import { getMockRecords, getMockRecord } from '@/lib/mock-data';
@@ -69,3 +69,65 @@ export function useRecord({ objectName, recordId }: UseRecordOptions) {
6969
enabled: !!objectName && !!recordId,
7070
});
7171
}
72+
73+
// ── Create record ───────────────────────────────────────────────
74+
75+
interface UseCreateRecordOptions {
76+
objectName: string;
77+
}
78+
79+
export function useCreateRecord({ objectName }: UseCreateRecordOptions) {
80+
const queryClient = useQueryClient();
81+
82+
return useMutation<RecordData, Error, Partial<RecordData>>({
83+
mutationFn: async (data) => {
84+
const result = await objectStackClient.data.create(objectName, data);
85+
return (result?.record ?? data) as RecordData;
86+
},
87+
onSuccess: () => {
88+
void queryClient.invalidateQueries({ queryKey: ['records', objectName] });
89+
},
90+
});
91+
}
92+
93+
// ── Update record ───────────────────────────────────────────────
94+
95+
interface UseUpdateRecordOptions {
96+
objectName: string;
97+
recordId: string;
98+
}
99+
100+
export function useUpdateRecord({ objectName, recordId }: UseUpdateRecordOptions) {
101+
const queryClient = useQueryClient();
102+
103+
return useMutation<RecordData, Error, Partial<RecordData>>({
104+
mutationFn: async (data) => {
105+
const result = await objectStackClient.data.update(objectName, recordId, data);
106+
return (result?.record ?? data) as RecordData;
107+
},
108+
onSuccess: () => {
109+
void queryClient.invalidateQueries({ queryKey: ['records', objectName] });
110+
void queryClient.invalidateQueries({ queryKey: ['record', objectName, recordId] });
111+
},
112+
});
113+
}
114+
115+
// ── Delete record ───────────────────────────────────────────────
116+
117+
interface UseDeleteRecordOptions {
118+
objectName: string;
119+
}
120+
121+
export function useDeleteRecord({ objectName }: UseDeleteRecordOptions) {
122+
const queryClient = useQueryClient();
123+
124+
return useMutation<void, Error, string>({
125+
mutationFn: async (recordId) => {
126+
await objectStackClient.data.delete(objectName, recordId);
127+
},
128+
onSuccess: (_data, recordId) => {
129+
void queryClient.invalidateQueries({ queryKey: ['records', objectName] });
130+
void queryClient.removeQueries({ queryKey: ['record', objectName, recordId] });
131+
},
132+
});
133+
}

0 commit comments

Comments
 (0)