Skip to content

Commit 2829a09

Browse files
committed
feat(dashboard): add list widget type (issue #1381)
Adds a scrollable table widget showing top-N features from a layer with configurable columns, sort field/direction, and row limit. Completes the three widget types proposed in #1381 (after indicator #1392 and selector #1504). Like the selector, the list prepares the UI for cross-filtering but does not yet filter other widgets. Changes: - DashboardWidgetType: add "list" - DashboardWidget: add listFields, sortBy, sortDir, limit fields - project.ts: add "selector" and "list" to DASHBOARD_WIDGET_TYPES (selector was missing from the validator in the previous PR) - WidgetEditorDialog: column checkboxes + sort by/dir + limit input - DashboardPanel: ListTable component with sortable rows - i18n: chartType.list + 6 editor keys (en)
1 parent bfb3c3a commit 2829a09

5 files changed

Lines changed: 206 additions & 8 deletions

File tree

apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ function WidgetCard({
375375
const data = useLayerChartData(widget.layerId);
376376
const result = useMemo(
377377
() =>
378-
widget.type === "indicator" || widget.type === "selector"
378+
widget.type === "indicator" || widget.type === "selector" || widget.type === "list"
379379
? null
380380
: computeChart(data.rows, widgetToSpec(widget, widget.type)),
381381
[data.rows, widget],
@@ -409,6 +409,8 @@ function WidgetCard({
409409
}
410410
case "selector":
411411
return `${t("dashboard.chartType.selector")} · ${widget.category ?? ""}`;
412+
case "list":
413+
return `${t("dashboard.chartType.list")} · ${widget.layerId}`;
412414
}
413415
};
414416
const title = widget.title?.trim() || defaultWidgetTitle();
@@ -525,6 +527,46 @@ function WidgetCard({
525527
return <SelectorValues values={values} multiple={widget.multiple ?? false} />;
526528
})()}
527529
</div>
530+
) : widget.type === "list" ? (
531+
<div className="flex min-h-0 flex-1 flex-col overflow-auto">
532+
{(() => {
533+
if (!data.hasData || !widget.listFields || widget.listFields.length === 0) {
534+
return (
535+
<p className="text-center text-xs text-muted-foreground">{t("dashboard.noData")}</p>
536+
);
537+
}
538+
const rows = data.rows;
539+
const sortBy = widget.sortBy;
540+
const sortDir = widget.sortDir ?? "desc";
541+
const limit = widget.limit ?? 20;
542+
543+
// Sort rows if sortBy is set.
544+
let sorted = rows;
545+
if (sortBy) {
546+
sorted = [...rows].sort((a, b) => {
547+
const av = (a as unknown as Record<string, unknown>)[sortBy];
548+
const bv = (b as unknown as Record<string, unknown>)[sortBy];
549+
// Numeric comparison if both values are numbers.
550+
const an = Number(av);
551+
const bn = Number(bv);
552+
if (Number.isFinite(an) && Number.isFinite(bn) && an !== bn) {
553+
return sortDir === "asc" ? an - bn : bn - an;
554+
}
555+
// Fall back to string comparison.
556+
const as = String(av ?? "");
557+
const bs = String(bv ?? "");
558+
return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as);
559+
});
560+
}
561+
562+
return (
563+
<ListTable
564+
fields={widget.listFields}
565+
rows={sorted.slice(0, limit) as unknown as Record<string, unknown>[]}
566+
/>
567+
);
568+
})()}
569+
</div>
528570
) : (
529571
<div className="flex min-h-0 flex-1 flex-col [&>svg]:min-h-0 [&>svg]:flex-1">
530572
{data.hasData && result ? (
@@ -588,3 +630,45 @@ function SelectorValues({
588630
</div>
589631
);
590632
}
633+
634+
/** Renders the list widget body: a compact scrollable HTML table showing the
635+
* selected columns for the top-N features (by sortBy/sortDir, limited by limit).
636+
* Like the selector, this does not yet participate in cross-filtering. */
637+
function ListTable({
638+
fields,
639+
rows,
640+
}: {
641+
fields: string[];
642+
rows: Record<string, unknown>[];
643+
}) {
644+
return (
645+
<table className="w-full border-collapse text-xs">
646+
<thead>
647+
<tr>
648+
{fields.map((f) => (
649+
<th
650+
key={f}
651+
className="border-b border-border px-1.5 py-1 text-left font-medium text-muted-foreground"
652+
>
653+
{f}
654+
</th>
655+
))}
656+
</tr>
657+
</thead>
658+
<tbody>
659+
{rows.map((row, i) => (
660+
<tr key={i} className="hover:bg-muted/50">
661+
{fields.map((f) => {
662+
const v = row[f];
663+
return (
664+
<td key={f} className="border-b border-border/50 px-1.5 py-0.5">
665+
{v === null || v === undefined ? "" : String(v)}
666+
</td>
667+
);
668+
})}
669+
</tr>
670+
))}
671+
</tbody>
672+
</table>
673+
);
674+
}

apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ export function WidgetEditorDialog({
7070
const [suffix, setSuffix] = useState("");
7171
// "selector" widget fields (issue #1381).
7272
const [multiple, setMultiple] = useState(false);
73+
// "list" widget fields (issue #1381).
74+
const [listFields, setListFields] = useState<string[]>([]);
75+
const [sortBy, setSortBy] = useState("");
76+
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
77+
const [limit, setLimit] = useState(20);
7378
// "" means no custom color: fall back to the theme primary / palette.
7479
const [color, setColor] = useState("");
7580

@@ -91,6 +96,10 @@ export function WidgetEditorDialog({
9196
setPrefix(widget?.prefix ?? "");
9297
setSuffix(widget?.suffix ?? "");
9398
setMultiple(widget?.multiple ?? false);
99+
setListFields(widget?.listFields ?? []);
100+
setSortBy(widget?.sortBy ?? "");
101+
setSortDir(widget?.sortDir ?? "desc");
102+
setLimit(widget?.limit ?? 20);
94103
// eslint-disable-next-line react-hooks/exhaustive-deps
95104
}, [open, widget]);
96105

@@ -108,17 +117,20 @@ export function WidgetEditorDialog({
108117
const isCategorical = type === "bar" || type === "pie";
109118
const isIndicator = type === "indicator";
110119
const isSelector = type === "selector";
120+
const isList = type === "list";
111121
const canSave =
112122
layerId !== "" &&
113123
(isIndicator
114124
? indicatorAggregation === "count" || hasNumeric
115125
: isSelector
116126
? hasCategory
117-
: isCategorical
118-
? hasCategory && (aggregation === "count" || hasNumeric)
119-
: type === "scatter"
120-
? numericCols.length >= 2
121-
: hasNumeric);
127+
: isList
128+
? hasChartable
129+
: isCategorical
130+
? hasCategory && (aggregation === "count" || hasNumeric)
131+
: type === "scatter"
132+
? numericCols.length >= 2
133+
: hasNumeric);
122134
const save = () => {
123135
if (!canSave) return;
124136
const next: DashboardWidget = {
@@ -161,6 +173,15 @@ export function WidgetEditorDialog({
161173
next.category = pick(category, categoryCols);
162174
if (multiple) next.multiple = true;
163175
}
176+
if (type === "list") {
177+
// Ensure at least one column is selected; default to all available.
178+
const allCols = [...numericCols, ...categoryCols];
179+
const cols = listFields.filter((f) => allCols.includes(f));
180+
next.listFields = cols.length > 0 ? cols : allCols.slice(0, 3);
181+
if (sortBy) next.sortBy = sortBy;
182+
if (sortDir !== "desc") next.sortDir = sortDir;
183+
if (limit !== 20) next.limit = limit;
184+
}
164185
onSave(next);
165186
onOpenChange(false);
166187
};
@@ -242,6 +263,9 @@ export function WidgetEditorDialog({
242263
<option value="selector" disabled={!hasCategory}>
243264
{t("dashboard.chartType.selector")}
244265
</option>
266+
<option value="list" disabled={!hasChartable}>
267+
{t("dashboard.chartType.list")}
268+
</option>
245269
</Select>
246270
</div>
247271

@@ -368,6 +392,78 @@ export function WidgetEditorDialog({
368392
</>
369393
)}
370394

395+
{type === "list" && (
396+
<>
397+
<div className="grid gap-1.5">
398+
<Label>{t("dashboard.editor.listColumns")}</Label>
399+
<div className="flex flex-wrap gap-1.5">
400+
{[...numericCols, ...categoryCols].map((col) => {
401+
const checked = listFields.includes(col);
402+
return (
403+
<label
404+
key={col}
405+
className="flex items-center gap-1 rounded border border-border px-2 py-0.5 text-xs"
406+
>
407+
<input
408+
type="checkbox"
409+
checked={checked}
410+
onChange={(event) => {
411+
if (event.target.checked) {
412+
setListFields((prev) => [...prev, col]);
413+
} else {
414+
setListFields((prev) => prev.filter((f) => f !== col));
415+
}
416+
}}
417+
/>
418+
{col}
419+
</label>
420+
);
421+
})}
422+
</div>
423+
</div>
424+
<div className="flex flex-wrap items-end gap-3">
425+
<FieldSelect
426+
id="widget-list-sort"
427+
label={t("dashboard.editor.sortBy")}
428+
value={sortBy}
429+
options={["", ...numericCols, ...categoryCols]}
430+
onChange={setSortBy}
431+
/>
432+
<div className="grid gap-1.5">
433+
<Label htmlFor="widget-list-sortdir">
434+
{t("dashboard.editor.sortDir")}
435+
</Label>
436+
<Select
437+
id="widget-list-sortdir"
438+
className="w-28"
439+
value={sortDir}
440+
onChange={(event) =>
441+
setSortDir(event.target.value as "asc" | "desc")
442+
}
443+
>
444+
<option value="asc">{t("dashboard.editor.ascending")}</option>
445+
<option value="desc">{t("dashboard.editor.descending")}</option>
446+
</Select>
447+
</div>
448+
<div className="grid gap-1.5">
449+
<Label htmlFor="widget-list-limit">{t("dashboard.editor.limit")}</Label>
450+
<Input
451+
id="widget-list-limit"
452+
type="number"
453+
className="w-20"
454+
min={1}
455+
max={500}
456+
value={limit}
457+
onChange={(event) => {
458+
const v = Number(event.target.value);
459+
if (Number.isFinite(v)) setLimit(Math.max(1, Math.trunc(v)));
460+
}}
461+
/>
462+
</div>
463+
</div>
464+
</>
465+
)}
466+
371467
{type === "indicator" && (
372468
<>
373469
<div className="grid gap-1.5">

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3041,7 +3041,8 @@
30413041
"box": "Box plot",
30423042
"pie": "Pie",
30433043
"indicator": "Indicator",
3044-
"selector": "Selector"
3044+
"selector": "Selector",
3045+
"list": "List"
30453046
},
30463047
"aggregate": {
30473048
"count": "Count",
@@ -3084,6 +3085,12 @@
30843085
"prefix": "Prefix",
30853086
"suffix": "Suffix",
30863087
"multiSelect": "Allow multiple selection",
3088+
"listColumns": "Columns",
3089+
"sortBy": "Sort by",
3090+
"sortDir": "Direction",
3091+
"ascending": "Ascending",
3092+
"descending": "Descending",
3093+
"limit": "Row limit",
30873094
"titleLabel": "Title",
30883095
"titlePlaceholder": "Optional title",
30893096
"color": "Color",

packages/core/src/project.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,8 @@ const DASHBOARD_WIDGET_TYPES: readonly DashboardWidgetType[] = [
743743
"box",
744744
"pie",
745745
"indicator",
746+
"selector",
747+
"list",
746748
];
747749
const DASHBOARD_WIDGET_AGGREGATIONS: readonly DashboardWidgetAggregation[] = [
748750
"count",

packages/core/src/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1521,7 +1521,8 @@ export type DashboardWidgetType =
15211521
| "box"
15221522
| "pie"
15231523
| "indicator"
1524-
| "selector";
1524+
| "selector"
1525+
| "list";
15251526

15261527
/** How a bar widget reduces its category groups. */
15271528
export type DashboardWidgetAggregation = "count" | "sum" | "mean";
@@ -1570,6 +1571,14 @@ export interface DashboardWidget {
15701571
suffix?: string;
15711572
/** Selector widget: whether multiple values can be picked (default false). */
15721573
multiple?: boolean;
1574+
/** List widget: columns to display. */
1575+
listFields?: string[];
1576+
/** List widget: field to sort by. */
1577+
sortBy?: string;
1578+
/** List widget: sort direction (default "desc"). */
1579+
sortDir?: "asc" | "desc";
1580+
/** List widget: max rows to show (default 20). */
1581+
limit?: number;
15731582
}
15741583

15751584
/** Aggregation functions for indicator widgets (issue #1381). Extends the bar

0 commit comments

Comments
 (0)