-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add useAppObjects hook and record mutation hooks #221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| /** | ||
| * Tests for use-metadata hooks. | ||
| * | ||
| * Validates the exports and types of all metadata hooks, | ||
| * including the newly added useAppObjects hook. | ||
| */ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { | ||
| useAppDefinition, | ||
| useAppList, | ||
| useObjectDefinition, | ||
| useAppObjects, | ||
| } from '@/hooks/use-metadata'; | ||
|
|
||
| describe('use-metadata exports', () => { | ||
| it('exports useAppDefinition hook', () => { | ||
| expect(useAppDefinition).toBeTypeOf('function'); | ||
| }); | ||
|
|
||
| it('exports useAppList hook', () => { | ||
| expect(useAppList).toBeTypeOf('function'); | ||
| }); | ||
|
|
||
| it('exports useObjectDefinition hook', () => { | ||
| expect(useObjectDefinition).toBeTypeOf('function'); | ||
| }); | ||
|
|
||
| it('exports useAppObjects hook', () => { | ||
| expect(useAppObjects).toBeTypeOf('function'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| /** | ||
| * Tests for use-records hooks. | ||
| * | ||
| * Validates the exports and types of all record hooks, including | ||
| * the newly added mutation hooks (useCreateRecord, useUpdateRecord, useDeleteRecord). | ||
| */ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { | ||
| useRecords, | ||
| useRecord, | ||
| useCreateRecord, | ||
| useUpdateRecord, | ||
| useDeleteRecord, | ||
| } from '@/hooks/use-records'; | ||
|
|
||
| describe('use-records exports', () => { | ||
| it('exports useRecords hook', () => { | ||
| expect(useRecords).toBeTypeOf('function'); | ||
| }); | ||
|
|
||
| it('exports useRecord hook', () => { | ||
| expect(useRecord).toBeTypeOf('function'); | ||
| }); | ||
|
|
||
| it('exports useCreateRecord hook', () => { | ||
| expect(useCreateRecord).toBeTypeOf('function'); | ||
| }); | ||
|
|
||
| it('exports useUpdateRecord hook', () => { | ||
| expect(useUpdateRecord).toBeTypeOf('function'); | ||
| }); | ||
|
|
||
| it('exports useDeleteRecord hook', () => { | ||
| expect(useDeleteRecord).toBeTypeOf('function'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { resolveFields } from '@/types/metadata'; | ||
| import type { FieldDefinition } from '@/types/metadata'; | ||
|
|
||
| describe('resolveFields', () => { | ||
| const fields: Record<string, FieldDefinition> = { | ||
| name: { type: 'text', label: 'Full Name', required: true }, | ||
| email: { type: 'email' }, | ||
| status: { name: 'status', type: 'select', label: 'Status' }, | ||
| }; | ||
|
|
||
| it('returns all fields with guaranteed name and label', () => { | ||
| const resolved = resolveFields(fields); | ||
| expect(resolved).toHaveLength(3); | ||
| for (const f of resolved) { | ||
| expect(f.name).toBeDefined(); | ||
| expect(f.label).toBeDefined(); | ||
| } | ||
| }); | ||
|
|
||
| it('uses the record key as the field name when name is missing', () => { | ||
| const resolved = resolveFields(fields); | ||
| const emailField = resolved.find((f) => f.name === 'email'); | ||
| expect(emailField).toBeDefined(); | ||
| expect(emailField!.label).toBe('email'); | ||
| }); | ||
|
|
||
| it('preserves explicit name and label', () => { | ||
| const resolved = resolveFields(fields); | ||
| const nameField = resolved.find((f) => f.name === 'name'); | ||
| expect(nameField!.label).toBe('Full Name'); | ||
| }); | ||
|
|
||
| it('excludes specified fields', () => { | ||
| const resolved = resolveFields(fields, ['email']); | ||
| expect(resolved).toHaveLength(2); | ||
| expect(resolved.find((f) => f.name === 'email')).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns empty array for empty fields', () => { | ||
| expect(resolveFields({})).toEqual([]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,3 +63,31 @@ export function useObjectDefinition(objectName: string | undefined) { | |
| enabled: !!objectName, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Fetch all ObjectDefinition entries that belong to a given app. | ||
| * Resolves each object name listed in `AppDefinition.objects` into its full definition. | ||
| */ | ||
| export function useAppObjects(appId: string | undefined) { | ||
| const appQuery = useAppDefinition(appId); | ||
|
|
||
| return useQuery<ObjectDefinition[]>({ | ||
| queryKey: ['metadata', 'appObjects', appId], | ||
| queryFn: async () => { | ||
| const objectNames = appQuery.data?.objects ?? []; | ||
| const settled = await Promise.allSettled( | ||
| objectNames.map((name) => | ||
| objectStackClient.meta.getObject(name).then((r) => | ||
| r ? (r as ObjectDefinition) : getMockObjectDefinition(name), | ||
| ).catch(() => getMockObjectDefinition(name)), | ||
| ), | ||
| ); | ||
| return settled | ||
| .filter((r): r is PromiseFulfilledResult<ObjectDefinition | undefined> => | ||
| r.status === 'fulfilled') | ||
| .map((r) => r.value) | ||
| .filter((v): v is ObjectDefinition => !!v); | ||
| }, | ||
| enabled: !!appId && !!appQuery.data, | ||
| }); | ||
|
Comment on lines
+71
to
+92
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |
| * Falls back to mock data when the server is unreachable. | ||
| */ | ||
|
|
||
| import { useQuery } from '@tanstack/react-query'; | ||
| import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; | ||
| import type { RecordData, RecordListResponse } from '@/types/metadata'; | ||
| import { objectStackClient } from '@/lib/api'; | ||
| import { getMockRecords, getMockRecord } from '@/lib/mock-data'; | ||
|
|
@@ -69,3 +69,65 @@ export function useRecord({ objectName, recordId }: UseRecordOptions) { | |
| enabled: !!objectName && !!recordId, | ||
| }); | ||
| } | ||
|
|
||
| // ── Create record ─────────────────────────────────────────────── | ||
|
|
||
| interface UseCreateRecordOptions { | ||
| objectName: string; | ||
| } | ||
|
|
||
| export function useCreateRecord({ objectName }: UseCreateRecordOptions) { | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| return useMutation<RecordData, Error, Partial<RecordData>>({ | ||
| mutationFn: async (data) => { | ||
| const result = await objectStackClient.data.create(objectName, data); | ||
| return (result?.record ?? data) as RecordData; | ||
| }, | ||
| onSuccess: () => { | ||
| void queryClient.invalidateQueries({ queryKey: ['records', objectName] }); | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| // ── Update record ─────────────────────────────────────────────── | ||
|
|
||
| interface UseUpdateRecordOptions { | ||
| objectName: string; | ||
| recordId: string; | ||
| } | ||
|
|
||
| export function useUpdateRecord({ objectName, recordId }: UseUpdateRecordOptions) { | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| return useMutation<RecordData, Error, Partial<RecordData>>({ | ||
| mutationFn: async (data) => { | ||
| const result = await objectStackClient.data.update(objectName, recordId, data); | ||
| return (result?.record ?? data) as RecordData; | ||
| }, | ||
| onSuccess: () => { | ||
| void queryClient.invalidateQueries({ queryKey: ['records', objectName] }); | ||
| void queryClient.invalidateQueries({ queryKey: ['record', objectName, recordId] }); | ||
| }, | ||
| }); | ||
|
Comment on lines
+79
to
+112
|
||
| } | ||
|
|
||
| // ── Delete record ─────────────────────────────────────────────── | ||
|
|
||
| interface UseDeleteRecordOptions { | ||
| objectName: string; | ||
| } | ||
|
|
||
| export function useDeleteRecord({ objectName }: UseDeleteRecordOptions) { | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| return useMutation<void, Error, string>({ | ||
| mutationFn: async (recordId) => { | ||
| await objectStackClient.data.delete(objectName, recordId); | ||
| }, | ||
| onSuccess: (_data, recordId) => { | ||
| void queryClient.invalidateQueries({ queryKey: ['records', objectName] }); | ||
| void queryClient.removeQueries({ queryKey: ['record', objectName, recordId] }); | ||
| }, | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useAppObjectswraps each per-object fetch with.catch(() => getMockObjectDefinition(name)), so none of the mapped promises should reject. Given that,Promise.allSettled(...)plus filteringstatus === 'fulfilled'is redundant and adds complexity; consider switching toPromise.all(...)(or remove the inner catch if you truly want allSettled semantics).