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
2 changes: 1 addition & 1 deletion src/components/PlaygroundFilterGroup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const FilterGroup: FC<FilterGroupProps> = ({
size="large"
suffixIcon={<DoubleArrowIcon />}
>
{m.operators!.map((operator) => (
{(m.operators || []).map((operator) => (
<Select.Option key={operator.name} value={operator.name}>
{t(operator.title)}
</Select.Option>
Expand Down
49 changes: 35 additions & 14 deletions src/hooks/useDataSourcesMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ class Meta {
}

resolveMember(memberName: string, memberType: string | string[]) {
if (!memberName || typeof memberName !== "string") {
return {
title: String(memberName ?? ""),
error: `Path not found '${memberName}'`,
};
}

const [cube] = memberName.split(".");

if (!this.cubesMap[cube]) {
Expand Down Expand Up @@ -119,6 +126,13 @@ class Meta {
}
}

const filterMemberName = (filter: any): string | undefined => {
if (typeof filter?.dimension === "string") return filter.dimension;
if (typeof filter?.member === "string") return filter.member;
if (typeof filter?.dimension?.name === "string") return filter.dimension.name;
return undefined;
};

const enrichPlaygroundMembers = (cubesMetaCls: Meta, playgroundState: any) => {
const resolveWithIndex = (key: string) =>
getOr([], key, playgroundState).map((value: string, index: number) => ({
Expand All @@ -135,20 +149,24 @@ const enrichPlaygroundMembers = (cubesMetaCls: Meta, playgroundState: any) => {
})
);

const filters = getOr([], "filters", playgroundState).map(
(m: any, index: number) => ({
...m,
dimension: cubesMetaCls.resolveMember(m.dimension, [
"dimensions",
"measures",
]),
operators: cubesMetaCls.filterOperatorsForMember(m.dimension, [
"dimensions",
"measures",
]),
index,
const filters = getOr([], "filters", playgroundState)
.map((m: any, index: number) => {
const memberName = filterMemberName(m);
if (!memberName) return null;
return {
...m,
dimension: cubesMetaCls.resolveMember(memberName, [
"dimensions",
"measures",
]),
operators: cubesMetaCls.filterOperatorsForMember(memberName, [
"dimensions",
"measures",
]),
index,
};
})
);
.filter(Boolean);

const enrichedMembers = {
measures: resolveWithIndex("measures"),
Expand All @@ -175,7 +193,10 @@ const updatePlaygroundState = (playgroundState: any, cubesMeta: Meta) => {
const filteredArray = playgroundState[curKey].filter((m: any) => {
let resolved: any = false;
if (curKey === "filters") {
resolved = cubesMeta.resolveMember(m.dimension, [
if (Array.isArray(m?.or) || Array.isArray(m?.and)) {
return true;
}
resolved = cubesMeta.resolveMember(filterMemberName(m), [
"dimensions",
"measures",
]);
Expand Down
125 changes: 125 additions & 0 deletions src/utils/helpers/parseRestBodyToPlaygroundState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,129 @@ describe("parseRestBodyToPlaygroundState", () => {
test("throws on invalid json", () => {
expect(() => parseRestBodyToPlaygroundState("{")).toThrow("Invalid JSON");
});

test("maps member filters and dateRange-only timeDimensions", () => {
const state = parseRestBodyToPlaygroundState(
JSON.stringify({
query: {
measures: [
"semantic_events.count",
"semantic_events.count_share_pct",
],
dimensions: ["semantic_events.dimensions_website"],
segments: ["semantic_events.document_analysed"],
filters: [
{
member: "semantic_events.dimensions_website",
operator: "set",
},
],
timeDimensions: [
{
dimension: "semantic_events.timestamp",
dateRange: [
"2026-07-01T00:00:00.000",
"2026-07-01T23:59:59.999",
],
},
],
order: { "semantic_events.count": "desc" },
timezone: "UTC",
limit: 100,
offset: 0,
},
})
);

expect(state.measures).toEqual([
"semantic_events.count",
"semantic_events.count_share_pct",
]);
expect(state.dimensions).toEqual(["semantic_events.dimensions_website"]);
expect(state.segments).toEqual(["semantic_events.document_analysed"]);
expect(state.filters).toEqual([
{
dimension: "semantic_events.dimensions_website",
operator: "set",
values: [],
},
{
dimension: "semantic_events.timestamp",
operator: "inDateRange",
values: ["2026-07-01T00:00:00.000", "2026-07-01T23:59:59.999"],
},
]);
expect(state.timeDimensions).toEqual([]);
expect(state.order).toEqual([{ id: "semantic_events.count", desc: true }]);
expect(state.limit).toBe(100);
expect(state.offset).toBe(0);
});

test("keeps granularity and converts dateRange to a filter", () => {
const state = parseRestBodyToPlaygroundState(
JSON.stringify({
measures: ["Orders.count"],
timeDimensions: [
{
dimension: "Orders.createdAt",
granularity: "day",
dateRange: ["2026-07-01", "2026-07-31"],
},
],
})
);

expect(state.timeDimensions).toEqual([
{ dimension: "Orders.createdAt", granularity: "day" },
]);
expect(state.filters).toEqual([
{
dimension: "Orders.createdAt",
operator: "inDateRange",
values: ["2026-07-01", "2026-07-31"],
},
]);
});

test("flattens and-groups and keeps or-groups", () => {
const state = parseRestBodyToPlaygroundState(
JSON.stringify({
filters: [
{
and: [
{ member: "Orders.status", operator: "equals", values: ["open"] },
{ dimension: "Orders.city", operator: "set" },
],
},
{
or: [
{ member: "Orders.country", operator: "equals", values: ["IS"] },
],
},
],
})
);

expect(state.filters).toEqual([
{
dimension: "Orders.status",
operator: "equals",
values: ["open"],
},
{
dimension: "Orders.city",
operator: "set",
values: [],
},
{
or: [
{
dimension: "Orders.country",
operator: "equals",
values: ["IS"],
},
],
},
]);
});
});
110 changes: 106 additions & 4 deletions src/utils/helpers/parseRestBodyToPlaygroundState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const QUERY_KEYS = [
"page",
] as const;

const UNARY_FILTER_OPERATORS = new Set(["set", "notSet"]);

const isPlainObject = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);

Expand Down Expand Up @@ -64,6 +66,102 @@ const extractQuery = (parsed: unknown): Record<string, unknown> => {
);
};

const filterMemberName = (filter: Record<string, unknown>): string | undefined => {
if (typeof filter.dimension === "string") return filter.dimension;
if (typeof filter.member === "string") return filter.member;
return undefined;
};

const normalizeFilterValues = (
operator: string,
values: unknown
): unknown[] => {
if (Array.isArray(values)) return values;
if (UNARY_FILTER_OPERATORS.has(operator)) return [];
if (values == null) return [];
return [values];
};

const flattenFilters = (filters: unknown[]): Record<string, unknown>[] => {
const out: Record<string, unknown>[] = [];

for (const filter of filters) {
if (!isPlainObject(filter)) continue;

if (Array.isArray(filter.and)) {
out.push(...flattenFilters(filter.and));
continue;
}

// Keep OR groups intact so Cube.js still ORs them at runtime.
if (Array.isArray(filter.or)) {
const children = flattenFilters(filter.or);
if (children.length > 0) {
out.push({ or: children });
}
continue;
}

const member = filterMemberName(filter);
if (!member || typeof filter.operator !== "string") continue;

out.push({
dimension: member,
operator: filter.operator,
values: normalizeFilterValues(filter.operator, filter.values),
});
}

return out;
};

const normalizeTimeDimensions = (
timeDimensions: unknown[],
filters: Record<string, unknown>[]
): { dimension: string; granularity?: string; dateRange?: unknown }[] => {
const next: { dimension: string; granularity?: string; dateRange?: unknown }[] =
[];

for (const td of timeDimensions) {
if (!isPlainObject(td) || typeof td.dimension !== "string") continue;

const granularity =
typeof td.granularity === "string" ? td.granularity : undefined;
const dateRange = td.dateRange;

if (Array.isArray(dateRange) && dateRange.length > 0) {
filters.push({
dimension: td.dimension,
operator: "inDateRange",
values: dateRange.map((value) => String(value)),
});
}

if (granularity) {
const item: {
dimension: string;
granularity: string;
dateRange?: unknown;
} = {
dimension: td.dimension,
granularity,
};
if (typeof dateRange === "string") {
item.dateRange = dateRange;
}
next.push(item);
continue;
}

// Named ranges ("Last 7 days") only exist on timeDimensions in Cube.js.
if (typeof dateRange === "string") {
next.push({ dimension: td.dimension, dateRange });
}
}

return next;
};

/**
* Parse a Cube.js REST /v1/load body (or a bare query) into Explore playground state.
*/
Expand All @@ -79,14 +177,18 @@ export const parseRestBodyToPlaygroundState = (
}

const query = extractQuery(parsed);
const filters = flattenFilters(Array.isArray(query.filters) ? query.filters : []);
const timeDimensions = normalizeTimeDimensions(
Array.isArray(query.timeDimensions) ? query.timeDimensions : [],
filters
);

const next: PlaygroundState = {
...initialState,
measures: Array.isArray(query.measures) ? query.measures : [],
dimensions: Array.isArray(query.dimensions) ? query.dimensions : [],
filters: Array.isArray(query.filters) ? (query.filters as any) : [],
timeDimensions: Array.isArray(query.timeDimensions)
? (query.timeDimensions as PlaygroundState["timeDimensions"])
: [],
filters: filters as PlaygroundState["filters"],
timeDimensions: timeDimensions as PlaygroundState["timeDimensions"],
segments: Array.isArray(query.segments) ? (query.segments as any) : [],
order: normalizeOrder(query.order),
timezone:
Expand Down