Skip to content

Commit 4b93db4

Browse files
committed
Support filter operator aliases and improve gallery/view rendering
1 parent 64fad8c commit 4b93db4

8 files changed

Lines changed: 150 additions & 25 deletions

File tree

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1098,7 +1098,13 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
10981098
center: viewDef.map?.center,
10991099
},
11001100
gallery: {
1101-
imageField: viewDef.gallery?.imageField || 'image',
1101+
// Spread the full view-defined gallery first so spec
1102+
// fields (cardSize, visibleFields, coverField, coverFit)
1103+
// make it through; then layer legacy field aliases that
1104+
// ObjectGallery still consults.
1105+
...(viewDef.gallery || {}),
1106+
imageField: viewDef.gallery?.imageField || viewDef.gallery?.coverField || 'image',
1107+
coverField: viewDef.gallery?.coverField || viewDef.gallery?.imageField,
11021108
titleField: viewDef.gallery?.titleField || objectDef.titleField || 'name',
11031109
subtitleField: viewDef.gallery?.subtitleField,
11041110
},

packages/data-objectstack/src/index.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,66 @@ import {
1818
createErrorFromResponse,
1919
} from './errors';
2020

21+
/**
22+
* Map human-readable filter operator names produced by SDUI view configs
23+
* (e.g. `lead.view.ts`) to the canonical operator symbols expected by the
24+
* ObjectStack server's filter AST. Unknown operators fall through unchanged
25+
* so existing AST-style entries keep working.
26+
*/
27+
const FILTER_OPERATOR_ALIASES: Record<string, string> = {
28+
equals: '=',
29+
eq: '=',
30+
'==': '=',
31+
not_equals: '!=',
32+
notequals: '!=',
33+
ne: '!=',
34+
greater_than: '>',
35+
greaterthan: '>',
36+
gt: '>',
37+
greater_than_or_equal: '>=',
38+
greater_than_or_equals: '>=',
39+
greaterthanorequal: '>=',
40+
gte: '>=',
41+
less_than: '<',
42+
lessthan: '<',
43+
lt: '<',
44+
less_than_or_equal: '<=',
45+
less_than_or_equals: '<=',
46+
lessthanorequal: '<=',
47+
lte: '<=',
48+
in: 'in',
49+
not_in: 'nin',
50+
notin: 'nin',
51+
nin: 'nin',
52+
contains: 'contains',
53+
not_contains: 'notcontains',
54+
notcontains: 'notcontains',
55+
starts_with: 'startswith',
56+
startswith: 'startswith',
57+
ends_with: 'endswith',
58+
endswith: 'endswith',
59+
between: 'between',
60+
is_null: 'isnull',
61+
isnull: 'isnull',
62+
is_not_null: 'isnotnull',
63+
isnotnull: 'isnotnull',
64+
};
65+
66+
function normalizeFilterOperator(op: unknown): string | null {
67+
if (typeof op !== 'string') return null;
68+
const lower = op.toLowerCase();
69+
return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
70+
}
71+
72+
function objectFilterEntryToAST(entry: any): [string, string, any] | null {
73+
if (!entry || typeof entry !== 'object') return null;
74+
const field = entry.field ?? entry.name;
75+
const rawOp = entry.operator ?? entry.op ?? '=';
76+
const op = normalizeFilterOperator(rawOp);
77+
if (!field || !op) return null;
78+
return [String(field), op, entry.value];
79+
}
80+
2181
// Module-level discovery cache. Multiple ObjectStackAdapter instances pointed
2282
// at the same baseUrl (e.g. ConditionalAuthWrapper's throwaway adapter +
2383
// AdapterProvider's main adapter) would otherwise each fire `/discovery`. By
@@ -749,8 +809,33 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
749809
: typeof params.$filter === 'object' && Object.keys(params.$filter).length === 0;
750810
if (!isEmpty) {
751811
if (Array.isArray(params.$filter)) {
752-
// Assume active AST format if it's already an array
753-
options.filters = params.$filter;
812+
// Two array shapes are accepted from upstream:
813+
// 1. AST tuples: [field, op, value] — pass through.
814+
// 2. Object form: [{ field, operator, value }, ...] — server-driven
815+
// view configs (lead.view.ts etc.) use this. Translate each
816+
// entry into the AST tuple shape and map human-readable
817+
// operator names (`greater_than_or_equal`, `in`, `contains`,
818+
// …) to the canonical symbols the server understands.
819+
const isObjectForm = params.$filter.length > 0
820+
&& typeof params.$filter[0] === 'object'
821+
&& !Array.isArray(params.$filter[0])
822+
&& (params.$filter[0] as any).field !== undefined;
823+
if (isObjectForm) {
824+
const tuples = (params.$filter as any[])
825+
.map(entry => objectFilterEntryToAST(entry))
826+
.filter((t): t is [string, string, any] => t !== null);
827+
if (tuples.length === 0) {
828+
// All entries were unrecognized — drop the filter rather than
829+
// sending a malformed array.
830+
} else if (tuples.length === 1) {
831+
options.filters = tuples[0];
832+
} else {
833+
options.filters = ['and', ...tuples];
834+
}
835+
} else {
836+
// Already in AST format
837+
options.filters = params.$filter;
838+
}
754839
} else {
755840
options.filters = convertFiltersToAST(params.$filter);
756841
}

packages/plugin-detail/src/DetailTabs.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,17 @@ export const DetailTabs: React.FC<DetailTabsProps> = ({
5757

5858
{visibleTabs.map((tab) => (
5959
<TabsContent key={tab.key} value={tab.key} className="mt-4">
60-
{Array.isArray(tab.content) ? (
61-
<div className="space-y-4">
62-
{tab.content.map((schema, index) => (
63-
<SchemaRenderer key={index} schema={schema} data={data} />
64-
))}
65-
</div>
66-
) : (
67-
<SchemaRenderer schema={tab.content} data={data} />
68-
)}
60+
<React.Suspense fallback={null}>
61+
{Array.isArray(tab.content) ? (
62+
<div className="space-y-4">
63+
{tab.content.map((schema, index) => (
64+
<SchemaRenderer key={index} schema={schema} data={data} />
65+
))}
66+
</div>
67+
) : (
68+
<SchemaRenderer schema={tab.content} data={data} />
69+
)}
70+
</React.Suspense>
6971
</TabsContent>
7072
))}
7173
</Tabs>

packages/plugin-detail/src/DetailView.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
150150
inlineEdit = false,
151151
onFieldSave,
152152
discussionSlot,
153-
rightRail,
153+
rightRail: _rightRail,
154154
objectLabel,
155155
onDataLoaded,
156156
}) => {
@@ -493,7 +493,11 @@ export const DetailView: React.FC<DetailViewProps> = ({
493493
* server-driven action protocol.
494494
*/
495495
const systemActions = React.useMemo<ActionSchema[]>(() => {
496-
const items: ActionSchema[] = [];
496+
// System action items use a UI-local shape (`type: 'script'`,
497+
// `variant: 'destructive'`, `onClick`) that doesn't perfectly conform
498+
// to the canonical ActionSchema discriminated union. Cast at the
499+
// boundary so call sites can keep treating them as ActionSchema[].
500+
const items: any[] = [];
497501

498502
// Share lives in the unified overflow on every breakpoint — keeps the
499503
// header focused on the primary Edit CTA. (Was sm:hidden previously.)
@@ -563,7 +567,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
563567
});
564568
}
565569

566-
return items;
570+
return items as ActionSchema[];
567571
}, [
568572
t,
569573
schema.showEdit,
@@ -609,7 +613,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
609613
return {
610614
...record,
611615
systemActions: [...existingSystem, ...systemActions],
612-
} as SchemaNode;
616+
} as unknown as SchemaNode;
613617
}
614618
return node;
615619
});

packages/plugin-kanban/src/ObjectKanban.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
3636
onCardClick,
3737
..._props
3838
}) => {
39+
void _props;
3940
// When a parent (e.g. ListView) pre-fetches data and passes it via the `data` prop,
4041
// we must not trigger a second fetch. Detect external data by checking if externalData
4142
// is an array (undefined when not provided by parent).

packages/plugin-list/src/ListView.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -740,8 +740,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
740740
v.startDateField, v.endDateField, v.dateField, v.endField,
741741
v.colorField, v.allDayField,
742742
v.coverField, v.imageField, v.subtitleField,
743-
v.swimlaneField,
743+
v.swimlaneField, v.valueField,
744744
...(Array.isArray(v.cardFields) ? v.cardFields : []),
745+
...(Array.isArray(v.visibleFields) ? v.visibleFields : []),
745746
];
746747
for (const f of candidates) {
747748
if (typeof f === 'string' && f) required.add(f);
@@ -930,7 +931,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
930931

931932
// Apply field order
932933
if (schema.fieldOrder && schema.fieldOrder.length > 0) {
933-
const orderMap = new Map(schema.fieldOrder.map((f: any, i: number) => [f, i]));
934+
const orderMap = new Map<string, number>(schema.fieldOrder.map((f: any, i: number) => [f as string, i]));
934935
fields = [...fields].sort((a: any, b: any) => {
935936
const nameA = typeof a === 'string' ? a : (a?.name || a?.fieldName || a?.field);
936937
const nameB = typeof b === 'string' ? b : (b?.name || b?.fieldName || b?.field);

packages/plugin-list/src/ObjectGallery.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,17 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
140140

141141
const items: Record<string, unknown>[] = props.data || boundData || schema.data || fetchedData || [];
142142

143+
// Hide the placeholder cover area when no item actually has an image.
144+
// Without this, gallery cards on data sets that have no `coverField` (or
145+
// an explicit one but no populated values) render with a giant empty
146+
// letter-placeholder block that dwarfs the actual content. By collapsing
147+
// it when there's nothing to show, the cards become information-dense.
148+
const hasAnyCover = useMemo(
149+
() => items.some((it) => typeof (it as any)[coverField] === 'string' && !!(it as any)[coverField]),
150+
[items, coverField],
151+
);
152+
const showCoverArea = !!gallery?.coverField || hasAnyCover;
153+
143154
// --- Grouping support ---
144155
const groupingFields = schema.grouping?.fields;
145156
const isGrouped = !!(groupingFields && groupingFields.length > 0);
@@ -207,7 +218,7 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
207218
)}
208219
onClick={() => navigation.handleClick(item)}
209220
>
210-
<div className={cn('w-full overflow-hidden bg-muted relative', ASPECT_CLASSES[cardSize])}>
221+
<div className={cn('w-full overflow-hidden bg-muted relative', ASPECT_CLASSES[cardSize])} hidden={!showCoverArea}>
211222
{imageUrl ? (
212223
<img
213224
src={imageUrl}
@@ -226,18 +237,21 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
226237
</div>
227238
)}
228239
</div>
229-
<CardContent className="p-3 border-t">
240+
<CardContent className={cn('p-3', showCoverArea && 'border-t')}>
230241
<h3 className="font-medium truncate text-sm" title={title}>
231242
{title}
232243
</h3>
233244
{visibleFields && visibleFields.length > 0 && (
234245
<div className="mt-1 space-y-0.5">
235246
{visibleFields.map((field) => {
236-
const value = item[field];
237-
if (value == null) return null;
247+
const value = (item as any)[field];
248+
if (value == null || value === '') return null;
249+
const display = typeof value === 'object'
250+
? ((value as any).label ?? (value as any).name ?? (value as any).id ?? JSON.stringify(value))
251+
: String(value);
238252
return (
239253
<p key={field} className="text-xs text-muted-foreground truncate">
240-
{String(value)}
254+
{display}
241255
</p>
242256
);
243257
})}
@@ -250,7 +264,7 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
250264

251265
const renderGrid = (gridItems: Record<string, unknown>[]) => (
252266
<div
253-
className={cn('grid gap-4 p-4', GRID_CLASSES[cardSize], schema.className)}
267+
className={cn('grid gap-4 p-4 auto-rows-min content-start', GRID_CLASSES[cardSize], schema.className)}
254268
role="list"
255269
>
256270
{gridItems.map((item, i) => renderCard(item, i))}

packages/plugin-view/src/ViewTabBar.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,18 @@ export const ViewTabBar: React.FC<ViewTabBarProps> = ({
283283
setRenameValue('');
284284
}, []);
285285

286+
/**
287+
* Suppress the per-tab readonly lock indicator when *every* view is
288+
* readonly — in that case the badge is pure noise (no actionable contrast
289+
* with editable views) and clutters the tab bar. The lock still appears
290+
* the moment the user creates a saved/editable view alongside the system
291+
* ones, so the affordance returns when it actually distinguishes things.
292+
*/
293+
const allReadonly = useMemo(
294+
() => views.length > 0 && views.every(v => !!v.readonly),
295+
[views],
296+
);
297+
286298
// --- Sort views: pinned first → personal → shared ---
287299
const sortedViews = useMemo(() => {
288300
const sorted = [...views];
@@ -438,7 +450,7 @@ export const ViewTabBar: React.FC<ViewTabBarProps> = ({
438450
) : (
439451
<span>{view.label}</span>
440452
)}
441-
{isReadonly && (
453+
{isReadonly && !allReadonly && (
442454
<Tooltip>
443455
<TooltipTrigger asChild>
444456
<Lock

0 commit comments

Comments
 (0)