Skip to content

Commit 97e5535

Browse files
authored
Merge pull request #1032 from objectstack-ai/copilot/fix-record-detail-not-found
2 parents 0dd7272 + 2265e0d commit 97e5535

File tree

9 files changed

+91
-17
lines changed

9 files changed

+91
-17
lines changed

ROADMAP.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,6 +1284,17 @@ Plugin architecture refactoring to support true modular development, plugin isol
12841284

12851285
## 🐛 Bug Fixes
12861286

1287+
### Record Detail "Record Not Found" in External Metadata Environments (March 2026)
1288+
1289+
**Root Cause:** Three compounding issues caused "Record not found" when navigating from a list to a record detail page in external metadata environments:
1290+
1. `DetailView.tsx`'s alt-ID fallback only triggered when `findOne` returned null. If it threw an error (server 500, network failure, etc.), the error propagated to the outer catch handler and the fallback was never tried.
1291+
2. `ObjectStackAdapter.findOne` with `$expand` used `rawFindWithPopulate` with `$filter: { _id: id }`, which some external servers don't support. On failure it threw, instead of falling back to the simpler `data.get()` call.
1292+
3. Record IDs in navigation URLs were not URL-encoded, which could cause routing issues with IDs containing special characters.
1293+
1294+
**Fix:** Made `DetailView` catch all errors from the first `findOne` (converting to null) so the alt-ID fallback always runs. Made `ObjectStackAdapter.findOne` fall through to direct `data.get()` when the `$expand` raw request fails with a non-404 error. Added `encodeURIComponent` for record IDs in all navigation URL construction points.
1295+
1296+
**Tests:** 32 DetailView tests, 12 expand tests, 33 useNavigationOverlay tests, 6 RecordDetailEdit tests — all pass.
1297+
12871298
### Auth Registration and Login Unavailable in MSW/Server Modes (March 2026)
12881299

12891300
**Root Cause:** `createKernel.ts` (MSW mode) and `objectstack.config.ts` (Server mode) did not load `AuthPlugin`, so the kernel had no 'auth' service. All `/api/v1/auth/*` endpoints (sign-up, sign-in, get-session, sign-out) returned 404.

apps/console/src/components/ObjectView.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -436,17 +436,17 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
436436
if (action === 'new_window') {
437437
// Open record detail in a new browser tab with Console-correct URL
438438
const basePath = window.location.pathname.replace(/\/view\/.*$/, '');
439-
window.open(`${basePath}/record/${String(recordId)}`, '_blank');
439+
window.open(`${basePath}/record/${encodeURIComponent(String(recordId))}`, '_blank');
440440
return;
441441
}
442442
// page / view mode — navigate to record detail page
443443
// Handles action === 'view' (from useNavigationOverlay page mode) and
444444
// default fallthrough for any unrecognised action
445445
if (action === 'view' || !action || action === 'page') {
446446
if (viewId) {
447-
navigate(`../../record/${String(recordId)}`, { relative: 'path' });
447+
navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path' });
448448
} else {
449-
navigate(`record/${String(recordId)}`);
449+
navigate(`record/${encodeURIComponent(String(recordId))}`);
450450
}
451451
}
452452
},
@@ -669,9 +669,9 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
669669
onEdit?.({ _id: recordId, id: recordId });
670670
} else if (mode === 'view') {
671671
if (viewId) {
672-
navigate(`../../record/${String(recordId)}`, { relative: 'path' });
672+
navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path' });
673673
} else {
674-
navigate(`record/${String(recordId)}`);
674+
navigate(`record/${encodeURIComponent(String(recordId))}`);
675675
}
676676
}
677677
},

apps/console/src/hooks/useObjectActions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export function useObjectActions({
127127

128128
const navigateToRecord = useCallback(
129129
(recordId: string) => {
130-
navigate(`${baseUrl}/${objectName}/record/${recordId}`);
130+
navigate(`${baseUrl}/${objectName}/record/${encodeURIComponent(recordId)}`);
131131
},
132132
[navigate, baseUrl, objectName],
133133
);

packages/data-objectstack/src/expand.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,28 @@ describe('ObjectStackAdapter $expand support', () => {
177177

178178
expect(result).toBeNull();
179179
});
180+
181+
it('should fall through to data.get() when $expand raw request fails with non-404 error', async () => {
182+
// The raw populate request fails (e.g., server doesn't support the filter+populate API)
183+
mockFetch.mockResolvedValue({
184+
ok: false,
185+
status: 500,
186+
statusText: 'Internal Server Error',
187+
json: () => Promise.resolve({ message: 'unsupported' }),
188+
});
189+
// But the direct data.get() call succeeds
190+
mockClient.data.get.mockResolvedValue({ record: { _id: 'order-1', name: 'Order 1' } });
191+
192+
const result = await adapter.findOne('order', 'order-1', {
193+
$expand: ['customer'],
194+
});
195+
196+
// Should have tried raw request first
197+
expect(mockFetch).toHaveBeenCalled();
198+
// Then fell through to data.get()
199+
expect(mockClient.data.get).toHaveBeenCalledWith('order', 'order-1');
200+
expect(result).toEqual({ _id: 'order-1', name: 'Order 1' });
201+
});
180202
});
181203

182204
describe('raw request format', () => {

packages/data-objectstack/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,9 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
300300
if ((error as Record<string, unknown>)?.status === 404) {
301301
return null;
302302
}
303-
throw error;
303+
// Fall through to direct GET without $expand — some servers don't
304+
// support the filter+populate API, so gracefully degrade to a
305+
// simple data.get() call below rather than failing with "Record not found".
304306
}
305307
}
306308

packages/plugin-detail/src/DetailView.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,8 @@ export const DetailView: React.FC<DetailViewProps> = ({
130130
? dataSource.findOne(objectName, resourceId, params)
131131
: dataSource.findOne(objectName, resourceId);
132132

133-
return findOnePromise.then((result) => {
134-
if (!isMounted) return;
135-
if (result) {
136-
setData(result);
137-
setLoading(false);
138-
return;
139-
}
140-
// Fallback: try alternate ID format for backward compatibility
133+
// Helper: try alternate ID format (strip or prepend objectName prefix)
134+
const tryAltId = () => {
141135
const resIdStr = String(resourceId);
142136
const altId = resIdStr.startsWith(prefix)
143137
? resIdStr.slice(prefix.length) // strip prefix
@@ -156,6 +150,19 @@ export const DetailView: React.FC<DetailViewProps> = ({
156150
setLoading(false);
157151
}
158152
});
153+
};
154+
155+
return findOnePromise
156+
.catch(() => null) // Convert any error to null to trigger alternate ID fallback
157+
.then((result) => {
158+
if (!isMounted) return;
159+
if (result) {
160+
setData(result);
161+
setLoading(false);
162+
return;
163+
}
164+
// Fallback: try alternate ID format for backward compatibility
165+
return tryAltId();
159166
});
160167
}).catch((err) => {
161168
if (isMounted) {

packages/plugin-detail/src/__tests__/DetailView.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,37 @@ describe('DetailView', () => {
539539
expect(onBack).toHaveBeenCalled();
540540
});
541541

542+
it('should try fallback with alternate ID when first findOne throws an error', async () => {
543+
let callCount = 0;
544+
const mockDataSource = {
545+
findOne: vi.fn().mockImplementation((_obj: string, id: string) => {
546+
callCount++;
547+
if (callCount === 1) {
548+
// First call throws (simulate server error)
549+
return Promise.reject(new Error('Server error'));
550+
}
551+
// Second call (fallback) succeeds
552+
return Promise.resolve({ name: 'Alice' });
553+
}),
554+
} as any;
555+
556+
const schema: DetailViewSchema = {
557+
type: 'detail-view',
558+
title: 'Contact Details',
559+
objectName: 'contact',
560+
resourceId: 'contact-123',
561+
fields: [{ name: 'name', label: 'Name' }],
562+
};
563+
564+
const { findByText } = render(<DetailView schema={schema} dataSource={mockDataSource} />);
565+
// The fallback should find the record using the stripped ID
566+
expect(await findByText('Alice')).toBeInTheDocument();
567+
// findOne should be called twice: first with original ID, then with stripped prefix
568+
expect(mockDataSource.findOne).toHaveBeenCalledTimes(2);
569+
expect(mockDataSource.findOne).toHaveBeenNthCalledWith(1, 'contact', 'contact-123');
570+
expect(mockDataSource.findOne).toHaveBeenNthCalledWith(2, 'contact', '123');
571+
});
572+
542573
it('should call findOne with $expand when objectSchema has lookup fields', async () => {
543574
const mockDataSource = {
544575
getObjectSchema: vi.fn().mockResolvedValue({

packages/plugin-view/src/ObjectView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
419419
}
420420
if (navigationConfig.mode === 'new_window' || navigationConfig.openNewTab) {
421421
const recordId = record._id || record.id;
422-
const url = `/${schema.objectName}/${recordId}`;
422+
const url = `/${schema.objectName}/${encodeURIComponent(String(recordId))}`;
423423
window.open(url, '_blank');
424424
return;
425425
}

packages/react/src/hooks/useNavigationOverlay.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ export function useNavigationOverlay(
147147
return;
148148
}
149149
const viewPath = view ? `/${view}` : '';
150-
const url = objectName ? `/${objectName}/${recordId}${viewPath}` : `/${recordId}${viewPath}`;
150+
const encodedId = encodeURIComponent(String(recordId));
151+
const url = objectName ? `/${objectName}/${encodedId}${viewPath}` : `/${encodedId}${viewPath}`;
151152
window.open(url, '_blank');
152153
return;
153154
}

0 commit comments

Comments
 (0)