Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1fcc79c
refactor(filters): extract quick filter helpers into a shared utility
rossedfort Sep 16, 2026
0fc0fed
feat(workflows): quick filter every search attribute value in the table
rossedfort Sep 16, 2026
23329fa
refactor(filters): share one quick filter table cell across tables
rossedfort Sep 17, 2026
1e7725f
feat(tables): quick filter search attribute values in activities, nex…
rossedfort Sep 17, 2026
0ba9abf
fix(tables): keep the filter and copy buttons as distinct circles
rossedfort Sep 29, 2026
fff40a7
test(tables): cover quick filtering a value of every search attribute…
rossedfort Sep 29, 2026
0f3a69c
feat(tables): prefer a range operator for measures, and filter by dur…
rossedfort Oct 1, 2026
10cd339
refactor(filters): extract the filter chip's form into a reusable editor
rossedfort Oct 1, 2026
b95e707
feat(tables): ask for an operator when = is not the sensible default
rossedfort Oct 1, 2026
5514612
refactor(tables): resolve quick filters from one generic cell
rossedfort Oct 1, 2026
848795b
fix(tables): size the operator popup like the filter bar's
rossedfort Oct 6, 2026
4a4f687
merge: bring main into the quick filter branch
rossedfort Oct 6, 2026
6abba25
fix(tables): float the filter and copy buttons outside the cell
rossedfort Oct 6, 2026
fbe9098
feat(tables): ask for an operator on every column but Status
rossedfort Oct 7, 2026
ab74c8e
feat(tables): show the buttons over their cell and apply on Enter
rossedfort Oct 7, 2026
67c9c62
merge: bring main into the quick filter branch
rossedfort Oct 7, 2026
10aa828
Update src/lib/components/search-attribute-filter/quick-filter-table-…
rossedfort Oct 8, 2026
e017092
fix(filters): scope the popover's Enter handler to its own form and t…
rossedfort Oct 8, 2026
fe0540c
fix(filters): format the per-cell menu id and unpin it in tests
rossedfort Oct 8, 2026
8854761
fix(nexus): filter status on its readable form, not the enum key
rossedfort Oct 8, 2026
2f0e27f
fix(tables): keep a missing cell value undefined rather than empty
rossedfort Oct 8, 2026
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

Large diffs are not rendered by default.

557 changes: 557 additions & 0 deletions src/lib/components/search-attribute-filter/filter-editor.svelte

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<script lang="ts" generics="Row">
import type { Writable } from 'svelte/store';

import type { Snippet } from 'svelte';

import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters';
import type { SearchAttributes } from '$lib/types/workflows';
import {
type QuickFilterColumns,
type ResolvedQuickFilter,
resolveQuickFilter,
} from '$lib/utilities/query/quick-filter-table';

import QuickFilterTableCell from './quick-filter-table-cell.svelte';

type Props = {
columns: QuickFilterColumns<Row>;
searchAttributes: SearchAttributes;
filters: Writable<SearchAttributeFilter[]>;
filterIconTitle: string;
label: string;
row: Row;
// Withholds the filter without changing how the cell renders, for a surface
// whose query language cannot support it.
disabled?: boolean;
class?: string;
style?: string;
'data-testid'?: string;
children: Snippet<[ResolvedQuickFilter]>;
};

let {
columns,
searchAttributes,
filters,
filterIconTitle,
label,
row,
disabled = false,
class: className,
style,
'data-testid': testId,
children,
}: Props = $props();

const resolved = $derived(
resolveQuickFilter({ columns, searchAttributes, label, row }),
);
</script>

{#if resolved.filterable && !disabled}
<QuickFilterTableCell
class={className}
{style}
data-testid={testId}
{filters}
{filterIconTitle}
attribute={resolved.attribute}
type={resolved.type}
value={resolved.value}
copyValue={resolved.displayValue}
>
{@render children(resolved)}
</QuickFilterTableCell>
{:else}
<td class={className} {style} data-testid={testId}>
{@render children(resolved)}
</td>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<script lang="ts">
import { writable, type Writable } from 'svelte/store';

import { type ComponentProps, setContext, type Snippet } from 'svelte';

import { page } from '$app/state';

import { Menu } from '$lib/holocene/menu';
import {
MENU_CONTEXT,
type MenuContext,
} from '$lib/holocene/menu/menu-container.svelte';
import TableCellWithFilterOrCopyButtons from '$lib/holocene/table/table-cell-with-filter-or-copy-buttons.svelte';
import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters';
import {
SEARCH_ATTRIBUTE_TYPE,
type SearchAttributeType,
} from '$lib/types/workflows';
import {
createQuickFilter,
getQuickFilterConditional,
isQuickFilterActive,
type QuickFilterValue,
requiresOperatorChoice,
toggleQuickFilter,
toQuickFilterValue,
} from '$lib/utilities/query/quick-filter';
import { updateQueryParamsFromFilter } from '$lib/utilities/query/to-list-workflow-filters';
import { MAX_QUERY_LENGTH } from '$lib/utilities/request-from-api';

import FilterEditor from './filter-editor.svelte';

interface Props extends Omit<
ComponentProps<typeof TableCellWithFilterOrCopyButtons>,
| 'children'
| 'copyIconTitle'
| 'copySuccessIconTitle'
| 'onFilter'
| 'isFiltered'
| 'menuId'
| 'menuOpen'
> {
attribute: string;
value: QuickFilterValue;
type?: SearchAttributeType;
filters: Writable<SearchAttributeFilter[]>;
children: Snippet;
}
let {
attribute,
value,
type = SEARCH_ATTRIBUTE_TYPE.KEYWORD,
filters,
children,
...cellProps
}: Props = $props();

const open = writable(false);
const menuElement = writable<HTMLUListElement | null>(null);

// Menu reads its open state from context, which MenuContainer would normally
// provide β€” but that renders a div, and a div cannot wrap a table cell.
setContext<MenuContext>(MENU_CONTEXT, {
open,
keepOpen: writable(false),
menuElement,
});

const filterValue = $derived(toQuickFilterValue({ attribute, type, value }));
const conditional = $derived(getQuickFilterConditional({ attribute, type }));
const isFiltered = $derived(
isQuickFilterActive($filters, {
attribute,
value: filterValue ?? '',
conditional,
}),
);

// `=` is what anyone means for a Keyword, Text or Bool, so those filter on a
// single click. Anywhere else the operator is a guess, so ask instead.
const needsOperator = $derived(requiresOperatorChoice({ attribute, type }));
const hasPopup = $derived(needsOperator && filterValue !== null);
const quickFilterCellId = $props.id();
const menuId = $derived(`quick-filter-${quickFilterCellId}-${attribute}`);

const atQueryLimit = $derived(
(page.url.searchParams.get('query') ?? '').length >= MAX_QUERY_LENGTH,
);

const replaceFilter = (filter: SearchAttributeFilter) => {
// Replace rather than append, so a multi-status group collapses and
// combineFilters can recompute the parentheses from scratch.
$filters = [
...$filters.filter((f) => f.attribute !== filter.attribute),
filter,
];
updateQueryParamsFromFilter(page.url, $filters);
};

const onFilter = () => {
if (hasPopup) {
$open = !$open;
return;
}

const quickFilter = createQuickFilter({ attribute, type, value });
if (!quickFilter) return;
if (!isFiltered && atQueryLimit) return;

$filters = toggleQuickFilter($filters, quickFilter);
updateQueryParamsFromFilter(page.url, $filters);
};

// Checked by containment rather than propagation: Svelte delegates events at
// the app root, so the menu's stopPropagation runs on the same node as this
// listener and would not suppress it.
$effect(() => {
if (!$open) return;

const dismiss = (e: Event) => {
const target = e.target;
if (!(target instanceof Node)) return;
if ($menuElement?.contains(target)) return;
if (
target instanceof Element &&
target.closest(`[data-menu-anchor="${menuId}"]`)
)
return;

$open = false;
};
const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') $open = false;
};

document.addEventListener('click', dismiss);
document.addEventListener('keydown', onKeydown);

return () => {
document.removeEventListener('click', dismiss);
document.removeEventListener('keydown', onKeydown);
};
});
</script>

<TableCellWithFilterOrCopyButtons
{...cellProps}
onFilter={filterValue === null ? undefined : onFilter}
{isFiltered}
menuId={hasPopup ? menuId : undefined}
menuOpen={$open}
>
{@render children()}
<!-- Mounted only while open: Menu resolves its anchor once, so the filter
button it anchors to has to already be on the page. It portals out of the
cell, so nothing invalid is left inside the row. -->
{#if hasPopup && $open}
{@const seed = createQuickFilter({ attribute, type, value })}
{#if seed}
<Menu
id={menuId}
usePortal
class="max-h-fit w-min min-w-0 max-w-[calc(100dvw-1rem)] p-4"
>
<FilterEditor
filter={seed}
idPrefix={menuId}
defaultTimeMode="absolute"
applyOnEnter
onApply={(filter) => {
if (!isFiltered && atQueryLimit) return;
replaceFilter(filter);
$open = false;
}}
/>
</Menu>
{/if}
{/if}
</TableCellWithFilterOrCopyButtons>
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,8 @@
import { IconFilter, IconSearch } from '$lib/io/icon';
import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters';
import type { SearchAttributeOption } from '$lib/stores/search-attributes';
import {
SEARCH_ATTRIBUTE_TYPE,
type SearchAttributeType,
} from '$lib/types/workflows';
import type { SearchAttributeType } from '$lib/types/workflows';
import { getDefaultConditional } from '$lib/utilities/query/quick-filter';
import {
createFilter,
updateQueryParamsFromFilter,
Expand Down Expand Up @@ -54,27 +52,6 @@
document.getElementById(searchId)?.focus();
};

const getDefaultConditional = (type: SearchAttributeType) => {
switch (type) {
case SEARCH_ATTRIBUTE_TYPE.BOOL:
return '=';
case SEARCH_ATTRIBUTE_TYPE.DATETIME:
return '>=';
case SEARCH_ATTRIBUTE_TYPE.INT:
return '=';
case SEARCH_ATTRIBUTE_TYPE.DOUBLE:
return '=';
case SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST:
return 'in';
case SEARCH_ATTRIBUTE_TYPE.KEYWORD:
return '=';
case SEARCH_ATTRIBUTE_TYPE.TEXT:
return '=';
default:
return '=';
}
};

function handleNewQuery(value: string, type: SearchAttributeType) {
searchAttributeValue = '';
filter.set(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';

import type { ActivityExecutionListInfo } from '$lib/types/activity-execution';

import { getActivityColumnValue } from './column-search-attributes';

const activity = {
activityId: 'my-activity',
runId: 'my-run',
activityType: { name: 'MyActivityType' },
taskQueue: 'my-task-queue',
status: 'ACTIVITY_EXECUTION_STATUS_COMPLETED',
scheduleTime: '2024-01-02T03:04:05Z',
closeTime: '2024-01-02T03:09:15.5Z',
executionDuration: '310.5s',
stateTransitionCount: '4',
searchAttributes: {
indexedFields: {
CustomKeywordField: 'my-keyword',
CustomIntField: 42,
},
},
} as unknown as ActivityExecutionListInfo;

describe('getActivityColumnValue', () => {
it('reads the activity fields a mapped column filters on', () => {
expect(getActivityColumnValue('Activity ID', activity)).toBe('my-activity');
expect(getActivityColumnValue('Type', activity)).toBe('MyActivityType');
expect(getActivityColumnValue('Start', activity)).toBe(
'2024-01-02T03:04:05Z',
);
});

it('filters status on the readable form, not the enum key', () => {
expect(getActivityColumnValue('Status', activity)).toBe('Completed');
});

it('builds a query duration from the proto seconds string', () => {
expect(getActivityColumnValue('Execution Duration', activity)).toBe(
'5m10s500ms',
);
});

it('falls through to a custom search attribute of the same name', () => {
expect(getActivityColumnValue('CustomKeywordField', activity)).toBe(
'my-keyword',
);
expect(getActivityColumnValue('CustomIntField', activity)).toBe(42);
});

it('has no value for a column that is neither mapped nor indexed', () => {
expect(getActivityColumnValue('Not A Column', activity)).toBeUndefined();
});
});
Loading
Loading