From 510206c752936e60b915e3dc6ade01b478696057 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:58:35 +0200 Subject: [PATCH] feat(ui): add attribute-filter URL and facet parsing helpers --- ui/src/utils/attribute-filter.test.ts | 69 +++++++++++++++++++++++++++ ui/src/utils/attribute-filter.ts | 37 ++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 ui/src/utils/attribute-filter.test.ts create mode 100644 ui/src/utils/attribute-filter.ts diff --git a/ui/src/utils/attribute-filter.test.ts b/ui/src/utils/attribute-filter.test.ts new file mode 100644 index 00000000..fbb8b23f --- /dev/null +++ b/ui/src/utils/attribute-filter.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { parseAttrParam, formatAttrParam, parseFacetQuery } from './attribute-filter'; + +describe('parseAttrParam', () => { + it('returns key-only for input without colon', () => { + expect(parseAttrParam('order')).toEqual({ key: 'order' }); + }); + + it('splits on first colon, trims key, preserves value as-is', () => { + expect(parseAttrParam('order:47')).toEqual({ key: 'order', value: '47' }); + }); + + it('treats a value containing colons as a single value', () => { + expect(parseAttrParam('trace-id:abc:123')).toEqual({ key: 'trace-id', value: 'abc:123' }); + }); + + it('returns null for blank input', () => { + expect(parseAttrParam('')).toBeNull(); + expect(parseAttrParam(' ')).toBeNull(); + }); + + it('returns null for missing key', () => { + expect(parseAttrParam(':x')).toBeNull(); + }); + + it('returns null when the key contains invalid characters', () => { + expect(parseAttrParam('bad key:1')).toBeNull(); + }); +}); + +describe('formatAttrParam', () => { + it('returns bare key for key-only filter', () => { + expect(formatAttrParam({ key: 'order' })).toBe('order'); + }); + + it('joins with colon when value is present', () => { + expect(formatAttrParam({ key: 'order', value: '47' })).toBe('order:47'); + }); + + it('joins with colon when value is empty string', () => { + expect(formatAttrParam({ key: 'order', value: '' })).toBe('order:'); + }); +}); + +describe('parseFacetQuery', () => { + it('matches `key: value`', () => { + expect(parseFacetQuery('order: 47')).toEqual({ key: 'order', value: '47' }); + }); + + it('matches `key:value` without spaces', () => { + expect(parseFacetQuery('order:47')).toEqual({ key: 'order', value: '47' }); + }); + + it('matches wildcard values', () => { + expect(parseFacetQuery('order: 4*')).toEqual({ key: 'order', value: '4*' }); + }); + + it('returns null when the key contains invalid characters', () => { + expect(parseFacetQuery('bad key: 1')).toBeNull(); + }); + + it('returns null without a colon', () => { + expect(parseFacetQuery('order')).toBeNull(); + }); + + it('returns null with an empty value side', () => { + expect(parseFacetQuery('order: ')).toBeNull(); + }); +}); diff --git a/ui/src/utils/attribute-filter.ts b/ui/src/utils/attribute-filter.ts new file mode 100644 index 00000000..b291df11 --- /dev/null +++ b/ui/src/utils/attribute-filter.ts @@ -0,0 +1,37 @@ +export interface AttributeFilter { + key: string; + value?: string; +} + +const KEY_REGEX = /^[a-zA-Z0-9._-]+$/; + +/** Parses a single `?attr=` URL value. Returns null for invalid / blank input. */ +export function parseAttrParam(raw: string): AttributeFilter | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + + const colon = trimmed.indexOf(':'); + if (colon < 0) { + return KEY_REGEX.test(trimmed) ? { key: trimmed } : null; + } + + const key = trimmed.substring(0, colon).trim(); + const value = raw.substring(raw.indexOf(':') + 1); + if (!KEY_REGEX.test(key)) return null; + return { key, value }; +} + +/** Serialises an AttributeFilter back to a URL `?attr=` value. */ +export function formatAttrParam(f: AttributeFilter): string { + return f.value === undefined ? f.key : `${f.key}:${f.value}`; +} + +const FACET_REGEX = /^\s*([a-zA-Z0-9._-]+)\s*:\s*(\S(?:.*\S)?)\s*$/; + +/** Parses a cmd-k query like `order: 47` into a facet descriptor. */ +export function parseFacetQuery(query: string): AttributeFilter | null { + const m = FACET_REGEX.exec(query); + if (!m) return null; + return { key: m[1], value: m[2] }; +}