Skip to content

Commit bfb3c3a

Browse files
committed
feat(dashboard): add selector widget type (issue #1381)
Adds a category dropdown / chip-list widget that shows distinct values from a layer field. Supports single-select (default) and multi-select mode. This is the UI foundation for cross-filtering (issue #1381 part 2): the selection state lives in the widget card, ready to feed a filter bus. Changes: - DashboardWidgetType: add "selector" - DashboardWidget: add optional multiple flag - WidgetEditorDialog: category picker + multi-select checkbox - DashboardPanel: SelectorValues chip list component - i18n: chartType.selector + editor.multiSelect keys
1 parent 0d2c916 commit bfb3c3a

4 files changed

Lines changed: 124 additions & 8 deletions

File tree

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

Lines changed: 79 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"
378+
widget.type === "indicator" || widget.type === "selector"
379379
? null
380380
: computeChart(data.rows, widgetToSpec(widget, widget.type)),
381381
[data.rows, widget],
@@ -407,6 +407,8 @@ function WidgetCard({
407407
const aggLabel = t(`dashboard.indicatorAggregation.${agg}`);
408408
return widget.field ? `${aggLabel} · ${widget.field}` : aggLabel;
409409
}
410+
case "selector":
411+
return `${t("dashboard.chartType.selector")} · ${widget.category ?? ""}`;
410412
}
411413
};
412414
const title = widget.title?.trim() || defaultWidgetTitle();
@@ -496,6 +498,33 @@ function WidgetCard({
496498
);
497499
})()}
498500
</div>
501+
) : widget.type === "selector" ? (
502+
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-auto">
503+
{(() => {
504+
if (!widget.category || !data.hasData) {
505+
return (
506+
<p className="text-center text-xs text-muted-foreground">{t("dashboard.noData")}</p>
507+
);
508+
}
509+
// Extract distinct values from the category field, sorted.
510+
const cat = widget.category!;
511+
const values = Array.from(
512+
new Set(
513+
data.rows
514+
.map((row) => String((row as unknown as Record<string, unknown>)[cat] ?? ""))
515+
.filter((v) => v !== ""),
516+
),
517+
).sort((a, b) => a.localeCompare(b));
518+
519+
if (values.length === 0) {
520+
return (
521+
<p className="text-center text-xs text-muted-foreground">{t("dashboard.noData")}</p>
522+
);
523+
}
524+
525+
return <SelectorValues values={values} multiple={widget.multiple ?? false} />;
526+
})()}
527+
</div>
499528
) : (
500529
<div className="flex min-h-0 flex-1 flex-col [&>svg]:min-h-0 [&>svg]:flex-1">
501530
{data.hasData && result ? (
@@ -510,3 +539,52 @@ function WidgetCard({
510539
</div>
511540
);
512541
}
542+
543+
/** Renders the selector widget body: a scrollable list of clickable value
544+
* chips. In single mode clicking a value toggles it as the only selected value.
545+
* In multi mode each chip toggles independently. Cross-filtering is not yet
546+
* wired; this prepares the UI and selection state for it. */
547+
function SelectorValues({
548+
values,
549+
multiple,
550+
}: {
551+
values: string[];
552+
multiple: boolean;
553+
}) {
554+
const [selected, setSelected] = useState<Set<string>>(new Set());
555+
556+
const toggle = (value: string) => {
557+
setSelected((prev) => {
558+
const next = new Set(prev);
559+
if (next.has(value)) {
560+
next.delete(value);
561+
} else {
562+
if (!multiple) next.clear();
563+
next.add(value);
564+
}
565+
return next;
566+
});
567+
};
568+
569+
return (
570+
<div className="flex flex-wrap gap-1.5">
571+
{values.map((value) => {
572+
const isSelected = selected.has(value);
573+
return (
574+
<button
575+
key={value}
576+
type="button"
577+
onClick={() => toggle(value)}
578+
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
579+
isSelected
580+
? "border-primary bg-primary text-primary-foreground"
581+
: "border-border bg-background text-muted-foreground hover:border-primary/50"
582+
}`}
583+
>
584+
{value}
585+
</button>
586+
);
587+
})}
588+
</div>
589+
);
590+
}

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

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export function WidgetEditorDialog({
6868
const [indicatorAggregation, setIndicatorAggregation] = useState<IndicatorAggregation>("count");
6969
const [prefix, setPrefix] = useState("");
7070
const [suffix, setSuffix] = useState("");
71+
// "selector" widget fields (issue #1381).
72+
const [multiple, setMultiple] = useState(false);
7173
// "" means no custom color: fall back to the theme primary / palette.
7274
const [color, setColor] = useState("");
7375

@@ -88,6 +90,7 @@ export function WidgetEditorDialog({
8890
setIndicatorAggregation(widget?.indicatorAggregation ?? "count");
8991
setPrefix(widget?.prefix ?? "");
9092
setSuffix(widget?.suffix ?? "");
93+
setMultiple(widget?.multiple ?? false);
9194
// eslint-disable-next-line react-hooks/exhaustive-deps
9295
}, [open, widget]);
9396

@@ -104,15 +107,18 @@ export function WidgetEditorDialog({
104107
// aren't forced to the same column; the rest need one numeric field.
105108
const isCategorical = type === "bar" || type === "pie";
106109
const isIndicator = type === "indicator";
110+
const isSelector = type === "selector";
107111
const canSave =
108112
layerId !== "" &&
109113
(isIndicator
110114
? indicatorAggregation === "count" || hasNumeric
111-
: isCategorical
112-
? hasCategory && (aggregation === "count" || hasNumeric)
113-
: type === "scatter"
114-
? numericCols.length >= 2
115-
: hasNumeric);
115+
: isSelector
116+
? hasCategory
117+
: isCategorical
118+
? hasCategory && (aggregation === "count" || hasNumeric)
119+
: type === "scatter"
120+
? numericCols.length >= 2
121+
: hasNumeric);
116122
const save = () => {
117123
if (!canSave) return;
118124
const next: DashboardWidget = {
@@ -151,6 +157,10 @@ export function WidgetEditorDialog({
151157
if (prefix) next.prefix = prefix;
152158
if (suffix) next.suffix = suffix;
153159
}
160+
if (type === "selector") {
161+
next.category = pick(category, categoryCols);
162+
if (multiple) next.multiple = true;
163+
}
154164
onSave(next);
155165
onOpenChange(false);
156166
};
@@ -229,6 +239,9 @@ export function WidgetEditorDialog({
229239
{t("dashboard.chartType.pie")}
230240
</option>
231241
<option value="indicator">{t("dashboard.chartType.indicator")}</option>
242+
<option value="selector" disabled={!hasCategory}>
243+
{t("dashboard.chartType.selector")}
244+
</option>
232245
</Select>
233246
</div>
234247

@@ -335,6 +348,26 @@ export function WidgetEditorDialog({
335348
</>
336349
)}
337350

351+
{type === "selector" && (
352+
<>
353+
<FieldSelect
354+
id="widget-selector-category"
355+
label={t("dashboard.editor.category")}
356+
value={pick(category, categoryCols)}
357+
options={categoryCols}
358+
onChange={setCategory}
359+
/>
360+
<label className="flex items-center gap-2 text-sm">
361+
<input
362+
type="checkbox"
363+
checked={multiple}
364+
onChange={(event) => setMultiple(event.target.checked)}
365+
/>
366+
{t("dashboard.editor.multiSelect")}
367+
</label>
368+
</>
369+
)}
370+
338371
{type === "indicator" && (
339372
<>
340373
<div className="grid gap-1.5">

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3040,7 +3040,8 @@
30403040
"line": "Line",
30413041
"box": "Box plot",
30423042
"pie": "Pie",
3043-
"indicator": "Indicator"
3043+
"indicator": "Indicator",
3044+
"selector": "Selector"
30443045
},
30453046
"aggregate": {
30463047
"count": "Count",
@@ -3082,6 +3083,7 @@
30823083
"value": "Value",
30833084
"prefix": "Prefix",
30843085
"suffix": "Suffix",
3086+
"multiSelect": "Allow multiple selection",
30853087
"titleLabel": "Title",
30863088
"titlePlaceholder": "Optional title",
30873089
"color": "Color",

packages/core/src/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1520,7 +1520,8 @@ export type DashboardWidgetType =
15201520
| "line"
15211521
| "box"
15221522
| "pie"
1523-
| "indicator";
1523+
| "indicator"
1524+
| "selector";
15241525

15251526
/** How a bar widget reduces its category groups. */
15261527
export type DashboardWidgetAggregation = "count" | "sum" | "mean";
@@ -1567,6 +1568,8 @@ export interface DashboardWidget {
15671568
prefix?: string;
15681569
/** Indicator widget: optional suffix (e.g. " kg", " ha"). */
15691570
suffix?: string;
1571+
/** Selector widget: whether multiple values can be picked (default false). */
1572+
multiple?: boolean;
15701573
}
15711574

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

0 commit comments

Comments
 (0)