Skip to content

Commit 2fd68be

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(plugin-list): gate speculative $select fields by the object's real schema (#1710)
A list view auto-includes view-binding fields (kanban groupBy, calendar/ gantt/timeline dates, gallery image, timeline status/priority) in $select so alternate view modes render populated. These were added unconditionally on the assumption that "the projection ignores unknown names" — but some backends (notably the cloud multi-tenant runtime) reject an unknown $select column with an EMPTY result set, so a single phantom field zeroes the whole list. An AI-built `product` view requesting status/due_date/image then showed "no data" even though rows existed. Gate the speculative additions through addSpeculative(), which only keeps a field present in the object schema (objectDef.fields, already loaded before the fetch). User-declared columns and expand roots are untouched (already valid). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 56571d6 commit 2fd68be

2 files changed

Lines changed: 120 additions & 8 deletions

File tree

packages/plugin-list/src/ListView.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -983,12 +983,42 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
983983
for (const c of cols) required.add(c);
984984
for (const e of expandFields) required.add(e);
985985

986+
// Real fields of the object, used to gate the SPECULATIVE
987+
// view-binding fields below. The comment above is the tell: "some
988+
// backends reject unknown select keys with an empty result set
989+
// rather than ignoring them" — the cloud multi-tenant runtime does
990+
// exactly that, so a single unknown column in $select silently
991+
// zeroes the whole list (an AI-built `product` view auto-requesting
992+
// `status`/`due_date`/`image` then looks like "no data exists").
993+
// The user-declared `cols` and `expandFields` are already
994+
// known-valid (perms.checkField / buildExpandFields derived them
995+
// from the schema); only the auto-included view-binding fields are
996+
// unsafe. When the object schema isn't loaded yet we can't
997+
// validate, so we keep the prior permissive behavior (the data
998+
// fetch waits for objectDefLoaded, so this is virtually never hit).
999+
const knownObjectFields = (() => {
1000+
const f = objectDef?.fields;
1001+
if (!f) return null;
1002+
const names = Array.isArray(f)
1003+
? (f as any[]).map(x => x?.name).filter((n): n is string => typeof n === 'string')
1004+
: Object.keys(f);
1005+
const s = new Set<string>(names);
1006+
s.add('id'); s.add('created_at'); s.add('updated_at');
1007+
return s;
1008+
})();
1009+
const addSpeculative = (f: unknown) => {
1010+
if (typeof f !== 'string' || !f) return;
1011+
if (!knownObjectFields || knownObjectFields.has(f)) required.add(f);
1012+
};
1013+
9861014
// View-specific runtime fields. Each non-grid view binds to one
9871015
// or more record fields (groupBy for kanban, dates for calendar/
9881016
// timeline/gantt, image/title for gallery). Without these in the
9891017
// projection the view renders correctly-shaped records but with
9901018
// blank values — e.g. a kanban grouped by `industry` puts every
991-
// card into the implicit "no value" column.
1019+
// card into the implicit "no value" column. Added via
1020+
// addSpeculative so a binding naming a field this object lacks is
1021+
// dropped instead of poisoning the query.
9921022
const collectViewFields = (v: any) => {
9931023
if (!v) return;
9941024
const candidates = [
@@ -1002,9 +1032,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
10021032
...(Array.isArray(v.visibleFields) ? v.visibleFields : []),
10031033
...(Array.isArray(v.metaFields) ? v.metaFields : []),
10041034
];
1005-
for (const f of candidates) {
1006-
if (typeof f === 'string' && f) required.add(f);
1007-
}
1035+
for (const f of candidates) addSpeculative(f);
10081036
};
10091037
collectViewFields(schema.kanban);
10101038
collectViewFields(schema.options?.kanban);
@@ -1017,13 +1045,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
10171045
// Timeline plugin shows status / priority chips inline. Auto-include
10181046
// them when no explicit metaFields was configured so views like
10191047
// `task_timeline` ({ columns: ['subject', 'status'] }) still get
1020-
// priority badges out of the box. Inclusion is harmless when the
1021-
// object lacks these fields — the projection ignores unknown names.
1048+
// priority badges out of the box. Gated through addSpeculative: only
1049+
// added when the object actually has these fields (a `product` with
1050+
// no status/priority must not get them, or the list goes empty).
10221051
{
10231052
const tCfg: any = schema.timeline ?? schema.options?.timeline;
10241053
if (tCfg && !Array.isArray(tCfg.metaFields)) {
1025-
required.add('status');
1026-
required.add('priority');
1054+
addSpeculative('status');
1055+
addSpeculative('priority');
10271056
}
10281057
}
10291058
collectViewFields(schema.gantt);

packages/plugin-list/src/__tests__/ListView.test.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,89 @@ describe('ListView', () => {
645645
});
646646
});
647647

648+
describe('$select projection — speculative view-binding fields', () => {
649+
const lastSelect = (find: ReturnType<typeof vi.fn>): string[] | undefined => {
650+
const call = find.mock.calls.at(-1);
651+
return call?.[1]?.$select as string[] | undefined;
652+
};
653+
654+
it('drops speculative fields the object does not have (so they cannot zero the list)', async () => {
655+
// Reproduces the "published app shows no data" bug: a timeline config
656+
// auto-adds status/priority, but `product` has neither. Backends that
657+
// reject unknown $select keys with an empty result then return 0 rows.
658+
const mockDs = {
659+
find: vi.fn().mockResolvedValue([]),
660+
findOne: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(),
661+
getObjectSchema: vi.fn().mockResolvedValue({
662+
name: 'product',
663+
fields: {
664+
product_name: { type: 'text' },
665+
sku: { type: 'text' },
666+
is_active: { type: 'boolean' },
667+
},
668+
}),
669+
};
670+
671+
const schema = {
672+
type: 'list-view',
673+
objectName: 'product',
674+
viewType: 'grid',
675+
fields: ['product_name', 'sku', 'is_active'],
676+
// Present so the status/priority + date auto-include paths fire.
677+
timeline: {},
678+
calendar: {},
679+
} as unknown as ListViewSchema;
680+
681+
render(
682+
<SchemaRendererProvider dataSource={mockDs}>
683+
<ListView schema={schema} dataSource={mockDs} />
684+
</SchemaRendererProvider>
685+
);
686+
687+
await vi.waitFor(() => expect(mockDs.find).toHaveBeenCalled());
688+
const select = lastSelect(mockDs.find)!;
689+
// Real fields survive…
690+
expect(select).toEqual(expect.arrayContaining(['product_name', 'sku', 'is_active']));
691+
// …phantom view-binding fields are gone.
692+
for (const phantom of ['status', 'priority', 'start_date', 'end_date', 'due_date']) {
693+
expect(select).not.toContain(phantom);
694+
}
695+
});
696+
697+
it('keeps a speculative field when the object actually has it', async () => {
698+
const mockDs = {
699+
find: vi.fn().mockResolvedValue([]),
700+
findOne: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(),
701+
getObjectSchema: vi.fn().mockResolvedValue({
702+
name: 'task',
703+
fields: {
704+
subject: { type: 'text' },
705+
status: { type: 'select' },
706+
priority: { type: 'select' },
707+
},
708+
}),
709+
};
710+
711+
const schema = {
712+
type: 'list-view',
713+
objectName: 'task',
714+
viewType: 'grid',
715+
fields: ['subject'],
716+
timeline: {},
717+
} as unknown as ListViewSchema;
718+
719+
render(
720+
<SchemaRendererProvider dataSource={mockDs}>
721+
<ListView schema={schema} dataSource={mockDs} />
722+
</SchemaRendererProvider>
723+
);
724+
725+
await vi.waitFor(() => expect(mockDs.find).toHaveBeenCalled());
726+
const select = lastSelect(mockDs.find)!;
727+
expect(select).toEqual(expect.arrayContaining(['subject', 'status', 'priority']));
728+
});
729+
});
730+
648731
// ============================================
649732
// Merged Toolbar Layout
650733
// ============================================

0 commit comments

Comments
 (0)