Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/web/src/__tests__/hooks/use-metadata.test.ts
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');
});
});
36 changes: 36 additions & 0 deletions apps/web/src/__tests__/hooks/use-records.test.ts
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');
});
});
43 changes: 43 additions & 0 deletions apps/web/src/__tests__/types/metadata.test.ts
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([]);
});
});
28 changes: 28 additions & 0 deletions apps/web/src/hooks/use-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +78 to +89
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useAppObjects wraps each per-object fetch with .catch(() => getMockObjectDefinition(name)), so none of the mapped promises should reject. Given that, Promise.allSettled(...) plus filtering status === 'fulfilled' is redundant and adds complexity; consider switching to Promise.all(...) (or remove the inner catch if you truly want allSettled semantics).

Suggested change
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);
const results = await Promise.all(
objectNames.map((name) =>
objectStackClient.meta
.getObject(name)
.then((r) => (r ? (r as ObjectDefinition) : getMockObjectDefinition(name)))
.catch(() => getMockObjectDefinition(name)),
),
);
return results.filter((v): v is ObjectDefinition => !!v);

Copilot uses AI. Check for mistakes.
},
enabled: !!appId && !!appQuery.data,
});
Comment on lines +71 to +92
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useAppObjects contains non-trivial behavior (dependency on useAppDefinition, concurrent fetch with fallback-to-mock per object, and a specific queryKey). Current tests only validate that the hook is exported; please add a behavioral test that validates the resolution/fallback logic (e.g., when one object fetch rejects, the hook still returns mock for that object).

Copilot generated this review using guidance from repository custom instructions.
}
64 changes: 63 additions & 1 deletion apps/web/src/hooks/use-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new mutation hooks add important cache-invalidation behavior (invalidate list queries; invalidate/remove detail queries), but the current tests only validate that the hooks are exported. Please add at least one behavioral test that asserts the expected query cache interactions (e.g., using a QueryClient wrapper + a mocked objectStackClient.data.* method) so regressions in query keys/invalidation are caught.

Copilot generated this review using guidance from repository custom instructions.
}

// ── 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] });
},
});
}
Loading