Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -8327,7 +8327,8 @@ function FilterEditor({
return;
dispatch({ type: "setFilterPattern", dim: dim.ref, pattern: debouncedPattern });
}, [debouncedPattern, mode, dim.ref, dispatch]);
const timeRef = configured?.timeDimension?.ref ?? model.timeDimension?.ref;
const primaryModel = catalog.models.find((candidate) => candidate.name === state.model) ?? model;
const timeRef = configured?.timeDimension?.ref ?? primaryModel.timeDimension?.ref;
const types = import_react5.useMemo(() => catalogDimTypes(catalog), [catalog]);
const valueFilters = import_react5.useMemo(() => {
const base = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8327,7 +8327,8 @@ function FilterEditor({
return;
dispatch({ type: "setFilterPattern", dim: dim.ref, pattern: debouncedPattern });
}, [debouncedPattern, mode, dim.ref, dispatch]);
const timeRef = configured?.timeDimension?.ref ?? model.timeDimension?.ref;
const primaryModel = catalog.models.find((candidate) => candidate.name === state.model) ?? model;
const timeRef = configured?.timeDimension?.ref ?? primaryModel.timeDimension?.ref;
const types = import_react5.useMemo(() => catalogDimTypes(catalog), [catalog]);
const valueFilters = import_react5.useMemo(() => {
const base = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1622,7 +1622,8 @@ function FilterEditor({
return;
dispatch({ type: "setFilterPattern", dim: dim.ref, pattern: debouncedPattern });
}, [debouncedPattern, mode, dim.ref, dispatch]);
const timeRef = configured?.timeDimension?.ref ?? model.timeDimension?.ref;
const primaryModel = catalog.models.find((candidate) => candidate.name === state.model) ?? model;
const timeRef = configured?.timeDimension?.ref ?? primaryModel.timeDimension?.ref;
const types = useMemo3(() => catalogDimTypes(catalog), [catalog]);
const valueFilters = useMemo3(() => {
const base = [
Expand Down
55 changes: 0 additions & 55 deletions sidemantic-rs/ui/assets/index-C0Mo4KDV.js

This file was deleted.

55 changes: 55 additions & 0 deletions sidemantic-rs/ui/assets/index-pE1zWf5r.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion sidemantic-rs/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidemantic</title>
<script type="module" crossorigin src="/assets/index-C0Mo4KDV.js"></script>
<script type="module" crossorigin src="/assets/index-pE1zWf5r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnTdRyzm.css">
</head>
<body>
Expand Down
55 changes: 0 additions & 55 deletions sidemantic/ui/static/assets/index-C0Mo4KDV.js

This file was deleted.

55 changes: 55 additions & 0 deletions sidemantic/ui/static/assets/index-pE1zWf5r.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion sidemantic/ui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidemantic</title>
<script type="module" crossorigin src="/assets/index-C0Mo4KDV.js"></script>
<script type="module" crossorigin src="/assets/index-pE1zWf5r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnTdRyzm.css">
</head>
<body>
Expand Down
14 changes: 7 additions & 7 deletions sidemantic/widget/static/widget.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion webapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ function Shell() {
);
const dirty = state.datePreset !== resetInitial.datePreset
|| (state.datePreset === undefined && state.dateRange != null)
|| Object.keys(state.filters).length > 0;
|| Object.keys(state.filters).length > 0
|| state.joinedDims.length > 0;
const activeTimeDimension = configured?.timeDimension ?? model?.timeDimension;
const hasTime = Boolean(activeTimeDimension);
const grains = grainOptions(activeTimeDimension?.supportedGranularities, state.grain);
Expand Down
5 changes: 4 additions & 1 deletion webapp/src/components/AddFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useEffect, useRef, useState } from "react";
import type { CatalogDimension, CatalogModel } from "../data/types";
import { joinedDimensionsForModel } from "../lib/catalog";
import { useExplorer } from "../state/ExplorerContext";
import { FilterEditor } from "./FilterEditor";

/** "+ Filter" affordance: pick a dimension, then edit its filter in the same popover. Non-time
* dimensions only (time is filtered via the date-range control). */
export function AddFilter({ model }: { model: CatalogModel }) {
const dims = model.dimensions.filter((dim) => dim.type !== "time");
const { catalog } = useExplorer();
const dims = [...model.dimensions, ...joinedDimensionsForModel(catalog, model.name)].filter((dim) => dim.type !== "time");
const [open, setOpen] = useState(false);
const [picked, setPicked] = useState<CatalogDimension | null>(null);
const rootRef = useRef<HTMLSpanElement>(null);
Expand Down
18 changes: 17 additions & 1 deletion webapp/src/components/Catalog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CatalogDimension, CatalogMetric } from "../data/types";
import { graphMetricsForModel } from "../lib/catalog";
import { graphMetricsForModel, joinedDimensionsForModel } from "../lib/catalog";
import { useExplorer } from "../state/ExplorerContext";

function toggle(list: string[], ref: string) {
Expand Down Expand Up @@ -48,6 +48,7 @@ export function Catalog() {
const { state, dispatch, catalog } = useExplorer();
const model = catalog.models.find((m) => m.name === state.model);
const graphMetrics = graphMetricsForModel(catalog, state.model);
const joinedDims = joinedDimensionsForModel(catalog, state.model);
const pivot = state.view === "pivot";
const pivotMetrics = state.pivotMetrics.length ? state.pivotMetrics : state.selectedMetric ? [state.selectedMetric] : [];

Expand Down Expand Up @@ -132,6 +133,21 @@ export function Catalog() {
</ul>
)}
</section>
{joinedDims.length ? (
<section>
<h2 className="mb-1 px-3 text-xs font-medium text-muted">Joined dimensions</h2>
{joinedDims.filter((dim) => pivot || dim.type !== "time").map((dimension) => (
<DimensionRow
key={dimension.ref}
dimension={dimension}
selected={(pivot ? state.pivotDims : state.joinedDims).includes(dimension.ref)}
onSelect={() => pivot
? dispatch({ type: "setPivotDims", dims: toggle(state.pivotDims, dimension.ref) })
: dispatch({ type: "setJoinedDims", dims: toggle(state.joinedDims, dimension.ref) })}
/>
))}
</section>
) : null}
</nav>
);
}
3 changes: 2 additions & 1 deletion webapp/src/components/FilterEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ export function FilterEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedPattern, mode, dim.ref, dispatch]);

const timeRef = configured?.timeDimension?.ref ?? model.timeDimension?.ref;
const primaryModel = catalog.models.find((candidate) => candidate.name === state.model) ?? model;
const timeRef = configured?.timeDimension?.ref ?? primaryModel.timeDimension?.ref;
const types = useMemo(() => catalogDimTypes(catalog), [catalog]);
// Distinct values for this dimension, honoring the surrounding crossfilter (minus this dim's own
// filter) and the active date range, then narrowed by the search text via a server-side ILIKE.
Expand Down
27 changes: 26 additions & 1 deletion webapp/src/lib/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import { buildCatalogFromDescribe } from "./catalog";
import { buildCatalogFromDescribe, joinedDimensionsForModel, withJoinablePairs } from "./catalog";
import { formatCompact, formatValue } from "./format";
import { catalogDimTypes, composeFilters, includeFilter } from "./queries";

test("describe formats reach model and graph metric displays, with explicit formats taking precedence", () => {
const catalog = buildCatalogFromDescribe({
Expand All @@ -27,3 +28,27 @@ test("describe formats reach model and graph metric displays, with explicit form
expect(formatValue(0.59, catalog.graphMetrics[1])).toBe("59.00%");
expect(formatValue(0.59, catalog.graphMetrics[2])).toBe("0.59");
});

test("joined dimensions preserve types and refs, label owners, and respect public visibility and reachability", () => {
const catalog = withJoinablePairs(buildCatalogFromDescribe({ models: [
{ name: "activity", dimensions: [{ name: "arm" }] },
{ name: "assignment", dimensions: [
{ name: "arm", label: "Test arm", type: "numeric" },
{ name: "active", type: "boolean" },
{ name: "secret", public: false },
] },
{ name: "unrelated", dimensions: [{ name: "arm" }] },
] }), { joinable_pairs: [
{ from: "assignment", to: "activity", hops: 2 },
{ from: "activity", to: "assignment" },
{ from: "activity", to: "activity" },
] });
expect(joinedDimensionsForModel(catalog, "activity").map(({ ref, label, type }) => ({ ref, label, type }))).toEqual([
{ ref: "assignment.arm", label: "Test arm · Assignment", type: "numeric" },
{ ref: "assignment.active", label: "Active · Assignment", type: "boolean" },
]);
expect(joinedDimensionsForModel(catalog, "unrelated")).toEqual([]);
expect(composeFilters({ "assignment.arm": includeFilter(["2"]), "assignment.active": includeFilter(["true"]) }, {
types: catalogDimTypes(catalog),
})).toEqual(["assignment.arm = 2", "assignment.active = true"]);
});
15 changes: 15 additions & 0 deletions webapp/src/lib/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,18 @@ export function graphMetricsForModel(catalog: Catalog, modelName: string): Catal
return metric.ownerModel === modelName || joinable.has(metric.ownerModel);
});
}

/** Public dimensions reachable from the primary model. The catalog already owns visibility. */
export function joinedDimensionsForModel(catalog: Catalog, modelName: string): CatalogDimension[] {
const joinable = new Set<string>();
for (const pair of catalog.joinablePairs ?? []) {
if (pair.from === modelName) joinable.add(pair.to);
if (pair.to === modelName) joinable.add(pair.from);
}
return catalog.models
.filter((model) => model.name !== modelName && joinable.has(model.name))
.flatMap((model) => model.dimensions.map((dimension) => ({
...dimension,
label: `${dimension.label} · ${model.label}`,
})));
}
10 changes: 10 additions & 0 deletions webapp/src/state/explorerState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const base: ExplorerState = {
contextColumn: "none",
comparison: "previous",
comparisonRange: undefined,
joinedDims: [],
pivotDims: [],
pivotMetrics: [],
};
Expand Down Expand Up @@ -163,6 +164,7 @@ describe("explorerReducer reset", () => {
contextColumn: "none",
comparison: "previous",
comparisonRange: undefined,
joinedDims: [],
pivotDims: [],
pivotMetrics: [],
};
Expand Down Expand Up @@ -257,3 +259,11 @@ describe("applyDashboardConfig", () => {
expect(hydrated.grain).toBe("month");
});
});

test("joined leaderboard opt-ins survive view changes and clear with model/tab/reset changes", () => {
const selected = explorerReducer(base, { type: "setJoinedDims", dims: ["assignment.arm"] });
expect(explorerReducer(selected, { type: "setView", view: "pivot" }).joinedDims).toEqual(["assignment.arm"]);
expect(explorerReducer(selected, { type: "setModel", model: "other", metric: "other.count", grain: "day" }).joinedDims).toEqual([]);
expect(explorerReducer(selected, { type: "setDashboardTab", tab: "other", model: "other", metric: "other.count", grain: "day" }).joinedDims).toEqual([]);
expect(explorerReducer(selected, { type: "reset", initial: base }).joinedDims).toEqual([]);
});
7 changes: 7 additions & 0 deletions webapp/src/state/explorerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type ExplorerState = {
contextColumn: ContextColumn; // leaderboard context column (E2)
comparison: ComparisonMode; // comparison window for deltas/overlay (E3)
comparisonRange?: DateRange; // explicit window when comparison === "custom"
joinedDims: string[]; // opt-in joined dimension leaderboards

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include joined selections in dirty-state detection

When a user opts into a joined leaderboard without setting a date range or filter, Shell still considers the view clean because webapp/src/App.tsx only checks dateRange and filters when deciding whether to render Reset. Although the reducer now clears joinedDims on reset, the Reset button is unavailable in this common state, so the new selection cannot be reset through the standard control; include state.joinedDims.length in the dirty calculation.

Useful? React with 👍 / 👎.

pivotDims: string[];
pivotMetrics: string[];
};
Expand All @@ -51,6 +52,7 @@ export type ExplorerAction =
| { type: "setDateRange"; range?: DateRange; preset?: number }
| { type: "setContextColumn"; column: ContextColumn }
| { type: "setComparison"; comparison: ComparisonMode; range?: DateRange }
| { type: "setJoinedDims"; dims: string[] }
| { type: "setPivotDims"; dims: string[] }
| { type: "setPivotMetrics"; metrics: string[] }
| { type: "reset"; initial: ExplorerState };
Expand Down Expand Up @@ -86,6 +88,7 @@ export function explorerReducer(state: ExplorerState, action: ExplorerAction): E
filters: {},
dateRange: undefined,
datePreset: undefined,
joinedDims: [],
pivotDims: [],
pivotMetrics: [],
};
Expand All @@ -97,6 +100,7 @@ export function explorerReducer(state: ExplorerState, action: ExplorerAction): E
selectedMetric: action.metric,
grain: action.grain,
filters: {},
joinedDims: [],
pivotDims: [],
pivotMetrics: [],
};
Expand Down Expand Up @@ -159,6 +163,8 @@ export function explorerReducer(state: ExplorerState, action: ExplorerAction): E
comparison: action.comparison,
comparisonRange: action.comparison === "custom" ? action.range : undefined,
};
case "setJoinedDims":
return { ...state, joinedDims: action.dims };
case "setPivotDims":
return { ...state, pivotDims: action.dims };
case "setPivotMetrics":
Expand Down Expand Up @@ -215,6 +221,7 @@ export function initialStateFromCatalog(
contextColumn: "none",
comparison: "previous", // preserve the pre-E3 previous-period comparison as the default
comparisonRange: undefined,
joinedDims: [],
pivotDims: [],
// Left empty so the pivot falls back to the active metric for whatever model is selected,
// rather than pinning the primary model's first metric across model switches.
Expand Down
9 changes: 9 additions & 0 deletions webapp/src/state/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const base: ExplorerState = {
contextColumn: "none",
comparison: "previous",
comparisonRange: undefined,
joinedDims: [],
pivotDims: [],
pivotMetrics: [],
};
Expand Down Expand Up @@ -178,3 +179,11 @@ describe("decodeState", () => {
expect(decodeState(encodeState(populated), base)).toEqual(populated);
});
});

test("joined leaderboard opt-ins round-trip and malformed lists are ignored", () => {
const selected = { ...base, joinedDims: ["assignment.arm"] };
expect(decodeState(encodeState(selected), base)).toEqual(selected);
expect(new URLSearchParams(encodeState(base)).has("jdims")).toBe(false);
expect(decodeState('jdims=%5B42%5D', base).joinedDims).toEqual([]);
expect(decodeState(`jdims=${encodeURIComponent(JSON.stringify(["assignment.arm", "assignment.arm"]))}`, base).joinedDims).toEqual(["assignment.arm"]);
});
3 changes: 3 additions & 0 deletions webapp/src/state/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export function encodeState(state: ExplorerState): string {
params.set("cto", state.comparisonRange.to);
}
if (Object.keys(state.filters).length) params.set("filters", JSON.stringify(serializeFilters(state.filters)));
if (state.joinedDims.length) params.set("jdims", JSON.stringify(state.joinedDims));
if (state.pivotDims.length) params.set("pdims", JSON.stringify(state.pivotDims));
if (state.pivotMetrics.length) params.set("pmetrics", JSON.stringify(state.pivotMetrics));
return params.toString();
Expand Down Expand Up @@ -162,6 +163,8 @@ export function decodeState(search: string, base: ExplorerState): ExplorerState

const filters = parseFilterState(parseJson(params.get("filters")));
if (filters) next.filters = filters;
const jdims = parseJson(params.get("jdims"));
if (isStringArray(jdims)) next.joinedDims = [...new Set(jdims)];
const pdims = parseJson(params.get("pdims"));
if (isStringArray(pdims)) next.pivotDims = pdims;
const pmetrics = parseJson(params.get("pmetrics"));
Expand Down
9 changes: 7 additions & 2 deletions webapp/src/views/ExplorerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { QueryDebugPanel } from "../components/QueryDebugPanel";
import { EmptyState, ErrorState, LoadingState } from "../components/States";
import type { BrushRange } from "../components/TimeSeriesChart";
import { formatDelta, formatValue, labelize } from "../lib/format";
import { graphMetricsForModel } from "../lib/catalog";
import { graphMetricsForModel, joinedDimensionsForModel } from "../lib/catalog";
import { dashboardTabConfig } from "../lib/dashboard";
import { catalogDimTypes, composeFilters, metricSeries, metricTotals } from "../lib/queries";
import {
Expand Down Expand Up @@ -190,7 +190,12 @@ export function ExplorerView() {

if (!model) return <div className="p-4"><EmptyState message="No model available in this semantic layer." /></div>;

const leaderboardDims = (configured?.dimensions ?? model.dimensions).filter((dim) => dim.type !== "time");
const baseDims = configured?.dimensions ?? model.dimensions;
const baseRefs = new Set(baseDims.map((dim) => dim.ref));
const selectedJoinedDims = joinedDimensionsForModel(catalog, model.name).filter(
(dim) => state.joinedDims.includes(dim.ref) && !baseRefs.has(dim.ref),
);
const leaderboardDims = [...baseDims, ...selectedJoinedDims].filter((dim) => dim.type !== "time");
const activeExpandedLeaderboard = resolveExpandedLeaderboard(expandedLeaderboard, leaderboardDims);
const comparisonLabel = state.comparison === "year" ? "Prev year" : state.comparison === "custom" ? "Comparison" : "Prev period";

Expand Down
20 changes: 10 additions & 10 deletions webapp/src/views/PivotView.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
import { useMemo, useState } from "react";
import { aliasOf, type CatalogMetric } from "../data/types";
import { queryAlias, type CatalogMetric } from "../data/types";
import { DataTable, type Column } from "../components/DataTable";
import { QueryDebugPanel } from "../components/QueryDebugPanel";
import { EmptyState, ErrorState } from "../components/States";
import { formatValue, labelize } from "../lib/format";
import { graphMetricsForModel } from "../lib/catalog";
import { graphMetricsForModel, joinedDimensionsForModel } from "../lib/catalog";
import { composeFilters, dimTypes, pivotGroup } from "../lib/queries";
import { useExplorer } from "../state/ExplorerContext";
import { useQueryResult } from "../state/useQueryResult";

export function PivotView() {
const { state, catalog, backend } = useExplorer();
const model = catalog.models.find((m) => m.name === state.model);
const dims = model?.dimensions ?? [];
const dims = [...(model?.dimensions ?? []), ...joinedDimensionsForModel(catalog, state.model)];
const graphMetrics = graphMetricsForModel(catalog, state.model);
const allMetrics: CatalogMetric[] = useMemo(
() => [...(model?.metrics ?? []), ...graphMetrics],
[model, graphMetrics],
);
const timeRef = model?.timeDimension?.ref;

// Sanitize selections against the current model so a deep-link from another model (or a stale
// default) never pins fields this model doesn't have.
// Retain reachable joined dimensions in deep links, but discard stale/unreachable fields.
const validDim = (ref: string) => dims.some((d) => d.ref === ref);
const validMetric = (ref: string) => allMetrics.some((m) => m.ref === ref);
const pivotDims = state.pivotDims.filter(validDim);
Expand All @@ -45,17 +44,18 @@ export function PivotView() {
const tzQuery = query ? { ...query, timezone: state.timezone } : null;
const { result, loading, error } = useQueryResult(backend, tzQuery);

const queryFields = [...pivotDims, ...pivotMetrics];
const metricByRef = useMemo(() => new Map(allMetrics.map((m) => [m.ref, m])), [allMetrics]);
const refByAlias = useMemo(() => {
const map = new Map<string, string>();
for (const ref of [...pivotDims, ...pivotMetrics]) map.set(aliasOf(ref), ref);
for (const ref of queryFields) map.set(queryAlias(ref, queryFields), ref);
return map;
}, [pivotDims, pivotMetrics]);
}, [queryFields]);

const columns: Column[] = [
...pivotDims.map((ref) => ({ key: aliasOf(ref), label: dims.find((d) => d.ref === ref)?.label ?? labelize(ref), numeric: false })),
...pivotDims.map((ref) => ({ key: queryAlias(ref, queryFields), label: dims.find((d) => d.ref === ref)?.label ?? labelize(ref), numeric: false })),
...pivotMetrics.map((ref) => ({
key: aliasOf(ref),
key: queryAlias(ref, queryFields),
label: metricByRef.get(ref)?.label ?? labelize(ref),
numeric: true,
sortable: true,
Expand Down Expand Up @@ -89,7 +89,7 @@ export function PivotView() {
columns={columns}
rows={result?.rows ?? []}
loading={loading}
sortKey={effectiveSort ? aliasOf(effectiveSort.ref) : undefined}
sortKey={effectiveSort ? queryAlias(effectiveSort.ref, queryFields) : undefined}
sortDir={effectiveSort?.dir}
onSort={onSort}
renderCell={renderCell}
Expand Down
Loading
Loading