Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { numericValues, type ChartRow, type ChartType } from "../../lib/attribute-charts";
import {
distinctCategoryValues,
numericValues,
type ChartRow,
type ChartType,
} from "../../lib/attribute-charts";
import { isChartableLayer, useLayerChartData } from "../../hooks/useLayerChartData";
import { ChartView, computeChart, type ChartSpec } from "./charts/chart-view";
import { WidgetEditorDialog } from "./WidgetEditorDialog";
Expand Down Expand Up @@ -375,7 +380,7 @@ function WidgetCard({
const data = useLayerChartData(widget.layerId);
const result = useMemo(
() =>
widget.type === "indicator"
widget.type === "indicator" || widget.type === "selector"
? null
: computeChart(data.rows, widgetToSpec(widget, widget.type)),
[data.rows, widget],
Expand Down Expand Up @@ -407,6 +412,8 @@ function WidgetCard({
const aggLabel = t(`dashboard.indicatorAggregation.${agg}`);
return widget.field ? `${aggLabel} · ${widget.field}` : aggLabel;
}
case "selector":
return `${t("dashboard.chartType.selector")} · ${widget.category ?? ""}`;
}
};
const title = widget.title?.trim() || defaultWidgetTitle();
Expand Down Expand Up @@ -496,6 +503,36 @@ function WidgetCard({
);
})()}
</div>
) : widget.type === "selector" ? (
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-auto">
{(() => {
if (!widget.category || !data.hasData) {
return (
<p className="text-center text-xs text-muted-foreground">{t("dashboard.noData")}</p>
);
}
// Extract distinct values from the category field, sorted.
const cat = widget.category!;
const values = distinctCategoryValues(data.rows, cat);

if (values.length === 0) {
return (
<p className="text-center text-xs text-muted-foreground">{t("dashboard.noData")}</p>
);
}

// Keyed on the selector config so switching field or single/multi
// mode remounts with an empty selection instead of carrying over
// values that no longer apply.
return (
<SelectorValues
key={`${cat}:${widget.multiple ?? false}`}
values={values}
multiple={widget.multiple ?? false}
/>
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
})()}
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col [&>svg]:min-h-0 [&>svg]:flex-1">
{data.hasData && result ? (
Expand All @@ -510,3 +547,47 @@ function WidgetCard({
</div>
);
}

/** Renders the selector widget body: a scrollable list of clickable value
* chips. In single mode clicking a value toggles it as the only selected value.
* In multi mode each chip toggles independently. Cross-filtering is not yet
* wired; this prepares the UI and selection state for it. */
function SelectorValues({ values, multiple }: { values: string[]; multiple: boolean }) {
const [selected, setSelected] = useState<Set<string>>(new Set());

const toggle = (value: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(value)) {
next.delete(value);
} else {
if (!multiple) next.clear();
next.add(value);
}
return next;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};

return (
<div className="flex flex-wrap gap-1.5">
{values.map((value) => {
const isSelected = selected.has(value);
return (
<button
key={value}
type="button"
aria-pressed={isSelected}
onClick={() => toggle(value)}
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-muted-foreground hover:border-primary/50"
}`}
>
{value}
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
})}
</div>
);
}
47 changes: 42 additions & 5 deletions apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export function WidgetEditorDialog({
const [indicatorAggregation, setIndicatorAggregation] = useState<IndicatorAggregation>("count");
const [prefix, setPrefix] = useState("");
const [suffix, setSuffix] = useState("");
// "selector" widget fields (issue #1381).
const [multiple, setMultiple] = useState(false);
// "" means no custom color: fall back to the theme primary / palette.
const [color, setColor] = useState("");

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

Expand All @@ -104,15 +107,18 @@ export function WidgetEditorDialog({
// aren't forced to the same column; the rest need one numeric field.
const isCategorical = type === "bar" || type === "pie";
const isIndicator = type === "indicator";
const isSelector = type === "selector";
const canSave =
layerId !== "" &&
(isIndicator
? indicatorAggregation === "count" || hasNumeric
: isCategorical
? hasCategory && (aggregation === "count" || hasNumeric)
: type === "scatter"
? numericCols.length >= 2
: hasNumeric);
: isSelector
? hasCategory
: isCategorical
? hasCategory && (aggregation === "count" || hasNumeric)
: type === "scatter"
? numericCols.length >= 2
: hasNumeric);
const save = () => {
if (!canSave) return;
const next: DashboardWidget = {
Expand Down Expand Up @@ -151,6 +157,14 @@ export function WidgetEditorDialog({
if (prefix) next.prefix = prefix;
if (suffix) next.suffix = suffix;
}
if (type === "selector") {
next.category = pick(category, categoryCols);
// Always write the flag rather than omitting it when false: editing an
// existing widget merges this patch onto the stored record, so a missing
// key would leave an earlier `true` in place and unchecking the box in
// the editor would never take effect.
next.multiple = multiple;
}
onSave(next);
onOpenChange(false);
};
Expand Down Expand Up @@ -229,6 +243,9 @@ export function WidgetEditorDialog({
{t("dashboard.chartType.pie")}
</option>
<option value="indicator">{t("dashboard.chartType.indicator")}</option>
<option value="selector" disabled={!hasCategory}>
{t("dashboard.chartType.selector")}
</option>
</Select>
</div>

Expand Down Expand Up @@ -335,6 +352,26 @@ export function WidgetEditorDialog({
</>
)}

{type === "selector" && (
<>
<FieldSelect
id="widget-selector-category"
label={t("dashboard.editor.category")}
value={pick(category, categoryCols)}
options={categoryCols}
onChange={setCategory}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={multiple}
onChange={(event) => setMultiple(event.target.checked)}
/>
{t("dashboard.editor.multiSelect")}
</label>
</>
)}

{type === "indicator" && (
<>
<div className="grid gap-1.5">
Expand Down
4 changes: 3 additions & 1 deletion apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3040,7 +3040,8 @@
"line": "Line",
"box": "Box plot",
"pie": "Pie",
"indicator": "Indicator"
"indicator": "Indicator",
"selector": "Selector"
},
"aggregate": {
"count": "Count",
Expand Down Expand Up @@ -3082,6 +3083,7 @@
"value": "Value",
"prefix": "Prefix",
"suffix": "Suffix",
"multiSelect": "Allow multiple selection",
"titleLabel": "Title",
"titlePlaceholder": "Optional title",
"color": "Color",
Expand Down
17 changes: 17 additions & 0 deletions apps/geolibre-desktop/src/lib/attribute-charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ export function toFiniteNumber(value: unknown): number | null {
return null;
}

/**
* The distinct non-empty values of a category field, sorted for display. Used
* by the selector widget to build its list of value chips.
*
* @param rows The rows to read, each carrying its own property bag.
* @param key The category field to collect values from.
* @returns The sorted distinct values, with blanks and nullish entries dropped.
*/
export function distinctCategoryValues(rows: ChartRow[], key: string): string[] {
const values = new Set<string>();
for (const row of rows) {
const value = String(row.properties[key] ?? "");
if (value !== "") values.add(value);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
return Array.from(values).sort((a, b) => a.localeCompare(b));
}

/**
* Columns suitable for charting: a key counts as numeric when it has at least
* two finite-number values and those make up at least half of its non-null
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1520,7 +1520,8 @@ export type DashboardWidgetType =
| "line"
| "box"
| "pie"
| "indicator";
| "indicator"
| "selector";

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

/** Aggregation functions for indicator widgets (issue #1381). Extends the bar
Expand Down
62 changes: 61 additions & 1 deletion tests/dashboard-widgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ import {
isHexColor,
shadeRamp,
} from "../apps/geolibre-desktop/src/components/panels/charts/chart-colors";
import type { ChartRow } from "../apps/geolibre-desktop/src/lib/attribute-charts";
import {
distinctCategoryValues,
type ChartRow,
} from "../apps/geolibre-desktop/src/lib/attribute-charts";

function widget(patch: Partial<DashboardWidget> = {}): DashboardWidget {
return {
Expand Down Expand Up @@ -409,3 +412,60 @@ describe("computeChart", () => {
assert.equal(chartResultHasData(result), false);
});
});

describe("selector widget values", () => {
// Rows carry their attributes in a `properties` bag, not on the row itself.
// Reading the row directly yields undefined for every feature, which left the
// selector widget rendering its "no data" fallback instead of any chips.
const rows: ChartRow[] = [
{ properties: { CONTINENT: "Africa", NAME: "Kenya" } },
{ properties: { CONTINENT: "Asia", NAME: "Nepal" } },
{ properties: { CONTINENT: "Africa", NAME: "Chad" } },
];

it("reads distinct values out of each row's property bag", () => {
assert.deepEqual(distinctCategoryValues(rows, "CONTINENT"), ["Africa", "Asia"]);
});

it("sorts values and drops blank and nullish ones", () => {
const sparse: ChartRow[] = [
{ properties: { region: "Oceania" } },
{ properties: { region: "" } },
{ properties: { region: null } },
{ properties: {} },
{ properties: { region: "Americas" } },
];
assert.deepEqual(distinctCategoryValues(sparse, "region"), ["Americas", "Oceania"]);
});

it("returns nothing for a field no row carries", () => {
assert.deepEqual(distinctCategoryValues(rows, "missing"), []);
});
});

describe("selector widget multi-select persistence", () => {
beforeEach(() => {
useAppStore.getState().newProject({ name: "Test Project" });
});

// updateWidget merges its patch onto the stored widget, so the editor has to
// write `multiple` explicitly. Omitting the key when the box is unchecked
// left an earlier `true` in place and the widget stayed in multi-select mode.
it("clears multi-select when the patch carries an explicit false", () => {
useAppStore
.getState()
.addWidget(widget({ id: "s", type: "selector", category: "CONTINENT", multiple: true }));
useAppStore.getState().updateWidget("s", { multiple: false });
const saved = useAppStore.getState().widgets.find((w) => w.id === "s");
assert.equal(saved?.multiple, false);
});

it("retains the previous value when the patch omits the key", () => {
useAppStore
.getState()
.addWidget(widget({ id: "s", type: "selector", category: "CONTINENT", multiple: true }));
useAppStore.getState().updateWidget("s", { category: "REGION_UN" });
const saved = useAppStore.getState().widgets.find((w) => w.id === "s");
assert.equal(saved?.multiple, true);
});
});
Loading