Skip to content

Commit 14bda31

Browse files
committed
feat(webapp): support .length in smart-column JSON paths
A dot-accessed .length now resolves consistently to an array or string length, or an object's key count, so a column can show e.g. the number of items. A bracket-quoted ['length'] still reads a real property named length.
1 parent 7059969 commit 14bda31

3 files changed

Lines changed: 43 additions & 7 deletions

File tree

apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,8 @@ export function AddSmartColumnDialog({
177177
/>
178178
<Paragraph variant="extra-small" className="text-text-dimmed">
179179
Dot and bracket notation, e.g. <code>$.order.total</code> or{" "}
180-
<code>$.items[0].sku</code>.
180+
<code>$.items[0].sku</code>. Use <code>.length</code> for an array, string, or
181+
key count.
181182
</Paragraph>
182183
</div>
183184
<div className="flex flex-col gap-1.5">

apps/webapp/app/components/runs/v3/smartColumnData.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ describe("getAtPath", () => {
9393
expect(getAtPath(obj, "$.a..b")).toBeUndefined();
9494
expect(getAtPath(obj, "$.a[b]")).toBeUndefined();
9595
});
96+
97+
it("computes a dot-accessed .length for arrays, strings, and objects", () => {
98+
const data = { tags: ["a", "b", "c"], name: "hello", info: { x: 1, y: 2 }, count: 5 };
99+
expect(getAtPath(data, "$.tags.length")).toBe(3);
100+
expect(getAtPath(data, "$.name.length")).toBe(5);
101+
expect(getAtPath(data, "$.info.length")).toBe(2);
102+
expect(getAtPath(data, "$.count.length")).toBeUndefined();
103+
});
104+
105+
it("treats a bracket-quoted ['length'] as a literal key, not the computed length", () => {
106+
expect(getAtPath({ length: 42 }, "$['length']")).toBe(42);
107+
expect(getAtPath({ length: 42 }, "$.length")).toBe(1);
108+
});
96109
});
97110

98111
describe("extractSmartValue", () => {

apps/webapp/app/components/runs/v3/smartColumnData.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,20 @@ export function extractSmartValue(parsed: ParsedSource, path: string): SmartCell
5757

5858
const PATH_TOKEN_RE = /\.([^.[\]]+)|\[(\d+)\]|\['([^']*)'\]|\["([^"]*)"\]/g;
5959

60+
type PathToken =
61+
| { kind: "dot"; key: string }
62+
| { kind: "key"; key: string }
63+
| { kind: "index"; index: number };
64+
6065
/**
6166
* Read a value out of a parsed object with dot/bracket notation. Accepts a
6267
* leading `$`, dotted keys, and numeric or quoted bracket indices, e.g.
6368
* `$.failed`, `suites[0].name`, `$['a.b'].c`. Returns undefined when any
6469
* segment is missing.
70+
*
71+
* A dot-accessed `.length` is computed: array/string length, or an object's
72+
* key count. To read a real property literally named `length`, use a bracket
73+
* key (`['length']`).
6574
*/
6675
export function getAtPath(root: unknown, path: string): unknown {
6776
let normalized = path.trim();
@@ -71,26 +80,39 @@ export function getAtPath(root: unknown, path: string): unknown {
7180
normalized = `.${normalized}`;
7281
}
7382

74-
const tokens: (string | number)[] = [];
83+
const tokens: PathToken[] = [];
7584
let lastIndex = 0;
7685
PATH_TOKEN_RE.lastIndex = 0;
7786
let match: RegExpExecArray | null;
7887
while ((match = PATH_TOKEN_RE.exec(normalized)) !== null) {
7988
if (match.index !== lastIndex) return undefined;
8089
lastIndex = PATH_TOKEN_RE.lastIndex;
8190

82-
if (match[1] !== undefined) tokens.push(match[1]);
83-
else if (match[2] !== undefined) tokens.push(Number(match[2]));
84-
else if (match[3] !== undefined) tokens.push(match[3]);
85-
else if (match[4] !== undefined) tokens.push(match[4]);
91+
if (match[1] !== undefined) tokens.push({ kind: "dot", key: match[1] });
92+
else if (match[2] !== undefined) tokens.push({ kind: "index", index: Number(match[2]) });
93+
else if (match[3] !== undefined) tokens.push({ kind: "key", key: match[3] });
94+
else if (match[4] !== undefined) tokens.push({ kind: "key", key: match[4] });
8695
}
8796
if (lastIndex !== normalized.length) return undefined;
8897

8998
let current: unknown = root;
9099
for (const token of tokens) {
91100
if (current === null || current === undefined) return undefined;
101+
102+
if (token.kind === "dot" && token.key === "length") {
103+
if (Array.isArray(current) || typeof current === "string") {
104+
current = current.length;
105+
} else if (typeof current === "object") {
106+
current = Object.keys(current).length;
107+
} else {
108+
return undefined;
109+
}
110+
continue;
111+
}
112+
92113
if (typeof current !== "object") return undefined;
93-
current = (current as Record<string | number, unknown>)[token];
114+
const key = token.kind === "index" ? token.index : token.key;
115+
current = (current as Record<string | number, unknown>)[key];
94116
}
95117
return current;
96118
}

0 commit comments

Comments
 (0)