diff --git a/plugins/taskboard/README.md b/plugins/taskboard/README.md index 93a600e..f1f7897 100644 --- a/plugins/taskboard/README.md +++ b/plugins/taskboard/README.md @@ -34,6 +34,15 @@ task to an agent without rebuilding context by hand. the default List or Kanban layout, and the exact provider status order. Provider-native workflow groups, drag-and-drop, and keyboard status moves remain available without repetitive provider chips on every task. +- **Filters that stay put** — each project remembers its own filter chips, + search text, and List or Kanban choice. They come back on reload and follow + you between the board and a thread's Taskboard panel, because they are saved + per project rather than per surface. "Clear filters" resets them. +- **Filter presets** — save the current filters under a name and reapply them + from the Presets menu in one click. Rename, reorder, and delete them in + **Manage → Board preferences**. Presets are per project, and none is ever + applied automatically: sticky filters already decide what the board opens + with, so a "default preset" would only compete with them. - **Live task details** — cached summaries keep browsing fast; opening a task fetches its current description, labels, assignee, and comments. - **Pinned beside every chat** — open Taskboard from the thread-header button, @@ -145,10 +154,14 @@ bb taskboard status [--project ] [--json] bb taskboard config [--project ] [--source linear|github|jira] [provider fields] [--json] bb taskboard credentials [--project ] [--json] bb taskboard refresh [linear|github|jira] [--project ] [--json] -bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--cached] [--json] +bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--preset ] [--cached] [--json] bb taskboard show [--project ] [--json] bb taskboard transitions [--project ] [--json] bb taskboard move --status [--project ] [--json] +bb taskboard presets list [--project ] [--json] +bb taskboard presets save --from-state [--project ] [--json] +bb taskboard presets rename [--project ] [--json] +bb taskboard presets delete [--project ] [--json] ``` An explicit source must match the tracker selected for that project. Taskboard diff --git a/plugins/taskboard/app.tsx b/plugins/taskboard/app.tsx index 71118c6..1180c95 100644 --- a/plugins/taskboard/app.tsx +++ b/plugins/taskboard/app.tsx @@ -41,6 +41,7 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Icon, type IconName } from '@/components/ui/icon'; @@ -61,20 +62,27 @@ import { TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import type { - ProjectConfigMutation, - ProjectConfigView, - ProjectCredentialsInteractionResponse, - CreateIssueContext, - IssueDraftRecord, - SecretMutation, - TrackerProject, - WorkItem, - WorkItemDetail, - WorkSource, - WorkStateCategory, - WorkStatusOption, - TaskboardRpcContract +import { + ACROSS_PROJECTS_SCOPE_ID, + ALL_SOURCES_FILTER, + boardFilterStateFingerprint, + filterStateScopeId, + type BoardFilterState, + PRESET_NAME_MAX_LENGTH, + type FilterPreset, + type ProjectConfigMutation, + type ProjectConfigView, + type ProjectCredentialsInteractionResponse, + type CreateIssueContext, + type IssueDraftRecord, + type SecretMutation, + type TrackerProject, + type WorkItem, + type WorkItemDetail, + type WorkSource, + type WorkStateCategory, + type WorkStatusOption, + type TaskboardRpcContract } from './contract.js'; import { defaultProjectBoardSettings, @@ -115,7 +123,7 @@ import './app.css'; const PANEL_PATH = 'tasks'; const THREAD_PANEL_ACTION_ID = 'taskboard-panel'; -const ALL_SOURCES = 'all'; +const ALL_SOURCES = ALL_SOURCES_FILTER; const RIGHT_PANEL_PINNED_STORAGE_KEY = 'bb-taskboard:right-panel-pinned'; const RIGHT_PANEL_PIN_EVENT = 'bb-taskboard:right-panel-pin-changed'; const SIDEBAR_COLLAPSED_STORAGE_KEY = 'bb-taskboard:sidebar-collapsed'; @@ -1620,7 +1628,53 @@ function FilterChip({ ); } +function FilterPresetChip({ + presets, + onApply, + onSaveCurrent +}: { + presets: readonly FilterPreset[]; + onApply: (preset: FilterPreset) => void; + onSaveCurrent: () => void; +}) { + return ( + + + + + + {presets.length === 0 ? ( + No saved presets + ) : ( + presets.map(preset => ( + onApply(preset)} + > + {preset.name} + + )) + )} + + + Save current filters as... + + + + ); +} + function TrackerFilterBar({ + presets, + onApplyPreset, + onSaveCurrentPreset, source, enabledFilters, stateCategories, @@ -1649,6 +1703,9 @@ function TrackerFilterBar({ onViewChange, onClear }: { + presets: readonly FilterPreset[]; + onApplyPreset: (preset: FilterPreset) => void; + onSaveCurrentPreset: () => void; source: SourceFilter; enabledFilters: readonly WorkItemFilterField[]; stateCategories: readonly WorkStateCategory[]; @@ -1702,6 +1759,13 @@ function TrackerFilterBar({ className="tb-filter-bar flex shrink-0 flex-wrap items-center gap-1.5 border-b px-2 py-1.5" >
+ {presets.length > 0 || filtered ? ( + + ) : null} {showSourceFilter ? ( (); const [items, setItems] = useState(); const [boardSettings, setBoardSettings] = useState(() => - defaultProjectBoardSettings(projectId ?? 'proj_across_projects') + defaultProjectBoardSettings(projectId ?? ACROSS_PROJECTS_SCOPE_ID) ); const [boardSettingsReady, setBoardSettingsReady] = useState( projectId === null ); + const [presets, setPresets] = useState([]); + const [presetNameDraft, setPresetNameDraft] = useState(null); + const [savingPreset, setSavingPreset] = useState(false); const [source, setSource] = useState( projectId === null ? (initialPreferences?.source ?? ALL_SOURCES) @@ -2963,34 +3030,93 @@ function TrackerList({ ); const [error, setError] = useState(null); const requestRevisionRef = useRef(0); + // initialPreferences seeds useState at mount. It must NOT drive the load + // effect: the parent re-reads it from a mutable Map on every render, so its + // identity flips as soon as this component records its own preferences, + // which re-ran the load, cancelled the in-flight fetch, and then skipped + // applying the saved state. Capture it once instead. + const initialPreferencesRef = useRef(initialPreferences); + const savedFingerprintRef = useRef(null); + const filterStateLoadedRef = useRef(false); + const saveRevisionRef = useRef(0); + const savePromiseRef = useRef>(Promise.resolve()); const stateFilterEnabled = boardSettings.enabledFilters.includes('state'); + const storageScopeId = filterStateScopeId(preferenceScope); + useEffect(() => { - if (projectId === null) { - setBoardSettings(defaultProjectBoardSettings('proj_across_projects')); - setBoardSettingsReady(true); - return; - } let cancelled = false; setBoardSettingsReady(false); - void rpc - .call('getProjectBoardSettings', { projectId }) - .then(result => { + filterStateLoadedRef.current = false; + const settingsProjectId = projectId ?? ACROSS_PROJECTS_SCOPE_ID; + const loadSettings = + projectId === null + ? Promise.resolve({ + settings: defaultProjectBoardSettings(settingsProjectId) + }) + : rpc.call('getProjectBoardSettings', { projectId }); + + void Promise.all([ + loadSettings, + rpc.call('getBoardFilterState', { projectId: storageScopeId }) + ]) + .then(([settingsResult, stateResult]) => { if (cancelled) return; - setBoardSettings(result.settings); - if (!initialPreferences) setView(result.settings.defaultView); + setBoardSettings(settingsResult.settings); + // Only a successful load may open this gate. A failed load (below, + // in .catch()) must NOT set this: the save effect would then be + // free to write the still-default in-memory state over a saved row + // it never actually read, destroying data it merely failed to + // fetch. Every path from here on is a load we positively trust + // (either "keep in-memory preferences" or "we know what's saved, + // including that nothing is saved"), so it is safe to set once, + // up front, rather than at each return below. + filterStateLoadedRef.current = true; + if (initialPreferencesRef.current) return; + const saved = stateResult.state; + if (!saved) { + setView(settingsResult.settings.defaultView); + return; + } + setSource(projectId === null ? saved.source : ALL_SOURCES); + setStateCategories(saved.stateCategories); + setStatuses(saved.statuses); + setAssignees(saved.assignees); + setPriorities(saved.priorities); + setExternalProjects(saved.externalProjects); + setLabels(saved.labels); + setQuery(saved.query); + setCommittedQuery(saved.query); + setView(saved.view); + savedFingerprintRef.current = boardFilterStateFingerprint(saved); }) .catch(() => { if (cancelled) return; - setBoardSettings(defaultProjectBoardSettings(projectId)); + setBoardSettings(defaultProjectBoardSettings(settingsProjectId)); }) .finally(() => { - if (!cancelled) setBoardSettingsReady(true); + if (cancelled) return; + setBoardSettingsReady(true); }); return () => { cancelled = true; }; - }, [initialPreferences, projectId, rpc]); + }, [projectId, rpc, storageScopeId]); + + useEffect(() => { + let cancelled = false; + void rpc + .call('listFilterPresets', { projectId: storageScopeId }) + .then(result => { + if (!cancelled) setPresets(result.presets); + }) + .catch(() => { + if (!cancelled) setPresets([]); + }); + return () => { + cancelled = true; + }; + }, [rpc, storageScopeId]); const loadItems = useCallback(async () => { const requestRevision = ++requestRevisionRef.current; @@ -3035,6 +3161,11 @@ function TrackerList({ return () => window.clearTimeout(timeout); }, [query]); useEffect(() => { + // Do not cache a snapshot before the load has resolved. The parent uses + // this map to seed a later mount, and an empty pre-load placeholder would + // make that mount look like it had real in-session state, suppressing the + // saved filters entirely. + if (!filterStateLoadedRef.current) return; onPreferencesChange(preferenceScope, { source, stateCategories, @@ -3061,6 +3192,75 @@ function TrackerList({ statuses, view ]); + useEffect(() => { + if (!filterStateLoadedRef.current) return; + const state: BoardFilterState = { + source, + stateCategories, + statuses, + assignees, + priorities, + externalProjects, + labels, + query, + view + }; + const fingerprint = boardFilterStateFingerprint(state); + if (fingerprint === savedFingerprintRef.current) return; + const timeout = window.setTimeout(() => { + savedFingerprintRef.current = fingerprint; + const saveRevision = ++saveRevisionRef.current; + // The transport gives no ordering guarantee -- each rpc.call is an + // independent request, and the server handler awaits a + // variable-latency project lookup before its write -- so two saves + // fired close together could otherwise commit out of edit order. + // Chaining onto savePromiseRef serializes them: the next save's + // request is only issued once the previous one has fully settled, + // so the server always commits in the order the user made the + // edits. The leading .catch(() => {}) is not a swallowed error -- + // it stops one failed save from poisoning the chain and skipping + // every save queued after it. + // + // The fingerprint above is set optimistically, before the request + // is issued, so it reads as "saved" for the duration of the call. + // Nothing outside this effect reads it, so the worst case is one + // redundant re-send. + // + // Known limitation: rpc.call takes no abort signal, so a request + // that never settles stalls the chain and silently stops + // persisting until this component remounts. Racing it against a + // timeout would not help -- it cannot cancel the request, so a + // second save would go out while the first is still in flight and + // reintroduce the out-of-order commit this chain exists to + // prevent. + savePromiseRef.current = savePromiseRef.current + .catch(() => {}) + .then(() => + rpc.call('saveBoardFilterState', { projectId: storageScopeId, state }) + ) + .catch(nextError => { + // Deliberately no toast: a filter save is incidental to what + // the user is doing. Log it anyway, so a schema or contract + // bug is distinguishable from a transient network failure. + console.warn('Taskboard: filter state save failed', nextError); + if (saveRevision !== saveRevisionRef.current) return; + savedFingerprintRef.current = null; + }); + }, 500); + return () => window.clearTimeout(timeout); + }, [ + assignees, + externalProjects, + labels, + priorities, + query, + rpc, + source, + stateCategories, + statuses, + storageScopeId, + view + ]); useEffect( () => () => { requestRevisionRef.current += 1; @@ -3180,6 +3380,69 @@ function TrackerList({ setQuery(''); setCommittedQuery(''); }; + const applyPreset = useCallback( + (preset: FilterPreset) => { + const next = preset.state; + setSource(projectId === null ? next.source : ALL_SOURCES); + setStateCategories(next.stateCategories); + setStatuses(next.statuses); + setAssignees(next.assignees); + setPriorities(next.priorities); + setExternalProjects(next.externalProjects); + setLabels(next.labels); + setQuery(next.query); + setCommittedQuery(next.query); + setView(next.view); + }, + [projectId] + ); + const saveCurrentPreset = useCallback( + async (name: string) => { + // Guard against a double submit: without this the second call loses + // the duplicate-name check against the row the first one just wrote, + // and pops an error toast over an already-closed dialog. + if (savingPreset) return; + setSavingPreset(true); + try { + const result = await rpc.call('saveFilterPreset', { + projectId: storageScopeId, + name, + state: { + source, + stateCategories, + statuses, + assignees, + priorities, + externalProjects, + labels, + query, + view + } + }); + setPresets(result.presets); + setPresetNameDraft(null); + toast.success(`Saved preset "${result.preset.name}"`); + } catch (error) { + toast.error(describeError(error)); + } finally { + setSavingPreset(false); + } + }, + [ + assignees, + externalProjects, + labels, + priorities, + query, + rpc, + savingPreset, + source, + stateCategories, + statuses, + storageScopeId, + view + ] + ); const moveItemStatus = useCallback( async (item: WorkItem, option: WorkStatusOption) => { const matches = (candidate: WorkItem) => @@ -3223,6 +3486,9 @@ function TrackerList({
setPresetNameDraft('')} source={projectId === null ? source : ALL_SOURCES} enabledFilters={boardSettings.enabledFilters} stateCategories={stateCategories} @@ -3348,7 +3614,55 @@ function TrackerList({
); - return {content}; + return ( + + {content} + { + if (!open) setPresetNameDraft(null); + }} + > + + + Save filter preset + +
{ + event.preventDefault(); + const name = (presetNameDraft ?? '').trim(); + if (name) void saveCurrentPreset(name); + }} + className="flex flex-col gap-3" + > + setPresetNameDraft(event.target.value)} + placeholder="My work" + maxLength={PRESET_NAME_MAX_LENGTH} + aria-label="Preset name" + /> +
+ + +
+
+
+
+
+ ); } function DetailMetadata({ @@ -4347,6 +4661,161 @@ function ProjectBoardSettingsForm({ ); } +function FilterPresetsForm({ projectId }: { projectId: string | null }) { + const rpc = useRpc(); + const [managedPresets, setManagedPresets] = useState< + readonly FilterPreset[] + >([]); + // Serializes rename/delete/reorder: each returns the full authoritative + // list, so a slower response landing after a faster one would clobber it. + const [mutating, setMutating] = useState(false); + + useEffect(() => { + if (!projectId) { + setManagedPresets([]); + return; + } + let cancelled = false; + void rpc + .call('listFilterPresets', { projectId }) + .then(result => { + if (!cancelled) setManagedPresets(result.presets); + }) + .catch(() => { + if (!cancelled) setManagedPresets([]); + }); + return () => { + cancelled = true; + }; + }, [projectId, rpc]); + + const renamePreset = async (preset: FilterPreset, name: string) => { + if (!projectId || mutating) return; + setMutating(true); + try { + const result = await rpc.call('saveFilterPreset', { + projectId, + id: preset.id, + name, + state: preset.state + }); + setManagedPresets(result.presets); + } catch (error) { + toast.error(describeError(error)); + } finally { + setMutating(false); + } + }; + + const removePreset = async (preset: FilterPreset) => { + if (!projectId || mutating) return; + if (!window.confirm(`Delete the preset "${preset.name}"?`)) return; + setMutating(true); + try { + const result = await rpc.call('deleteFilterPreset', { + projectId, + id: preset.id + }); + setManagedPresets(result.presets); + } catch (error) { + toast.error(describeError(error)); + } finally { + setMutating(false); + } + }; + + const movePreset = async (preset: FilterPreset, delta: number) => { + if (!projectId || mutating) return; + const ids = managedPresets.map(candidate => candidate.id); + const from = ids.indexOf(preset.id); + const to = from + delta; + if (from < 0 || to < 0 || to >= ids.length) return; + const reordered = [...ids]; + const [moved] = reordered.splice(from, 1); + reordered.splice(to, 0, moved!); + setMutating(true); + try { + const result = await rpc.call('reorderFilterPresets', { + projectId, + ids: reordered + }); + setManagedPresets(result.presets); + } catch (error) { + toast.error(describeError(error)); + } finally { + setMutating(false); + } + }; + + return ( +
+
+

Filter presets

+

+ Rename, reorder, or delete this project's saved filter + presets. +

+
+ {managedPresets.length === 0 ? ( +

+ Save a preset from the Presets menu in the filter bar. +

+ ) : ( +
    + {managedPresets.map((preset, index) => ( +
  • + { + if (mutating) return; + const name = event.target.value.trim(); + if (name && name !== preset.name) { + void renamePreset(preset, name); + } + }} + /> + + + +
  • + ))} +
+ )} +
+ ); +} + function ManageView({ projectId, projects, @@ -4477,6 +4946,10 @@ function ManageView({ return result.settings; }} /> + ) : (
diff --git a/plugins/taskboard/board-settings.ts b/plugins/taskboard/board-settings.ts index ceb7091..d06c78c 100644 --- a/plugins/taskboard/board-settings.ts +++ b/plugins/taskboard/board-settings.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; import { bbProjectIdSchema } from './credential-contract.js'; import { DEFAULT_WORKFLOW_STATUS_ORDER } from './browse.js'; +import { trackerViewSchema } from './work-schemas.js'; export { DEFAULT_WORKFLOW_STATUS_ORDER } from './browse.js'; - -export const trackerViewSchema = z.enum(['list', 'kanban']); -export type TrackerView = z.infer; +export { trackerViewSchema } from './work-schemas.js'; +export type { TrackerView } from './work-schemas.js'; export const workItemFilterFieldSchema = z.enum([ 'state', diff --git a/plugins/taskboard/contract.ts b/plugins/taskboard/contract.ts index ce08b06..a8d22fe 100644 --- a/plugins/taskboard/contract.ts +++ b/plugins/taskboard/contract.ts @@ -8,6 +8,21 @@ import { secretMutationSchema } from './credential-contract.js'; import { projectBoardSettingsSchema } from './board-settings.js'; +import { boardFilterStateSchema } from './filter-state.js'; +import { + filterPresetNameSchema, + filterPresetSchema +} from './filter-presets.js'; +import { + workSourceSchema, + workStateCategorySchema, + type WorkSource +} from './work-schemas.js'; +export { + workSourceSchema, + workStateCategorySchema +} from './work-schemas.js'; +export type { WorkSource, WorkStateCategory } from './work-schemas.js'; export { DEFAULT_WORK_ITEM_FILTER_FIELDS, DEFAULT_WORKFLOW_STATUS_ORDER, @@ -21,6 +36,28 @@ export type { TrackerView, WorkItemFilterField } from './board-settings.js'; +export { + ACROSS_PROJECTS_SCOPE_ID, + ALL_SOURCES_FILTER, + boardFilterStateFingerprint, + boardFilterStateSchema, + defaultBoardFilterState, + filterStateScopeId, + normalizeBoardFilterState, + sourceFilterSchema +} from './filter-state.js'; +export type { + BoardFilterState, + SourceFilterValue +} from './filter-state.js'; +export { + PRESET_NAME_MAX_LENGTH, + filterPresetNameSchema, + filterPresetSchema, + normalizePresetName, + resolvePresetOrder +} from './filter-presets.js'; +export type { FilterPreset } from './filter-presets.js'; export { bbProjectIdSchema, jiraBaseUrlSchema, @@ -34,9 +71,6 @@ export type { SecretMutation } from './credential-contract.js'; -export const workSourceSchema = z.enum(['linear', 'github', 'jira']); -export type WorkSource = z.infer; - export const trackerProjectSchema = z .object({ id: bbProjectIdSchema, @@ -83,15 +117,6 @@ export const projectConfigMutationSchema = projectSourceConfigSchema }); export type ProjectConfigMutation = z.infer; -export const workStateCategorySchema = z.enum([ - 'backlog', - 'todo', - 'in_progress', - 'done', - 'canceled' -]); -export type WorkStateCategory = z.infer; - export const workStatusOptionSchema = z .object({ id: z.string().min(1), @@ -353,6 +378,54 @@ export const taskboardRpcContract = defineRpcContract({ saveProjectBoardSettings: { input: projectBoardSettingsSchema, output: z.object({ settings: projectBoardSettingsSchema }).strict() + }, + getBoardFilterState: { + input: z.object({ projectId: bbProjectIdSchema }).strict(), + output: z.object({ state: boardFilterStateSchema.nullable() }).strict() + }, + saveBoardFilterState: { + input: z + .object({ + projectId: bbProjectIdSchema, + state: boardFilterStateSchema + }) + .strict(), + output: z.object({ state: boardFilterStateSchema }).strict() + }, + listFilterPresets: { + input: z.object({ projectId: bbProjectIdSchema }).strict(), + output: z.object({ presets: z.array(filterPresetSchema) }).strict() + }, + saveFilterPreset: { + input: z + .object({ + projectId: bbProjectIdSchema, + id: z.string().min(1).optional(), + name: filterPresetNameSchema, + state: boardFilterStateSchema + }) + .strict(), + output: z + .object({ + preset: filterPresetSchema, + presets: z.array(filterPresetSchema) + }) + .strict() + }, + deleteFilterPreset: { + input: z + .object({ projectId: bbProjectIdSchema, id: z.string().min(1) }) + .strict(), + output: z.object({ presets: z.array(filterPresetSchema) }).strict() + }, + reorderFilterPresets: { + input: z + .object({ + projectId: bbProjectIdSchema, + ids: z.array(z.string().min(1)) + }) + .strict(), + output: z.object({ presets: z.array(filterPresetSchema) }).strict() } }); diff --git a/plugins/taskboard/filter-presets.ts b/plugins/taskboard/filter-presets.ts new file mode 100644 index 0000000..046144a --- /dev/null +++ b/plugins/taskboard/filter-presets.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; +import { bbProjectIdSchema } from './credential-contract.ts'; +import { boardFilterStateSchema } from './filter-state.ts'; + +export const PRESET_NAME_MAX_LENGTH = 60; + +export const filterPresetNameSchema = z + .string() + .trim() + .min(1) + .max(PRESET_NAME_MAX_LENGTH); + +export const filterPresetSchema = z + .object({ + id: z.string().min(1), + projectId: bbProjectIdSchema, + name: filterPresetNameSchema, + state: boardFilterStateSchema, + position: z.number().int().nonnegative() + }) + .strict(); +export type FilterPreset = z.infer; + +/** + * Names collide case-insensitively. This is stored as `name_normalized` and + * backs a UNIQUE constraint, so it must not depend on the host locale: + * `toLocaleLowerCase()` maps Turkish dotted and dotless I differently under + * `tr-TR` than elsewhere, which would make two names collide on one machine + * and not another, and leave stored values disagreeing with freshly computed + * ones. `board-settings.ts` uses the locale-aware form for status names, but + * only as an in-memory validation check whose result is never persisted. + */ +export function normalizePresetName(name: string): string { + return name.trim().toLowerCase(); +} + +/** + * A reorder must be a permutation of the stored ids. Anything else means the + * client is working from a stale list, so reject rather than guess. + */ +export function resolvePresetOrder( + currentIds: readonly string[], + requestedIds: readonly string[] +): string[] { + if (requestedIds.length !== currentIds.length) { + throw new Error('Preset order must list every preset exactly once'); + } + const current = new Set(currentIds); + const seen = new Set(); + for (const id of requestedIds) { + if (!current.has(id)) throw new Error(`Unknown filter preset: ${id}`); + if (seen.has(id)) throw new Error(`Duplicate filter preset: ${id}`); + seen.add(id); + } + return [...requestedIds]; +} diff --git a/plugins/taskboard/filter-state.ts b/plugins/taskboard/filter-state.ts new file mode 100644 index 0000000..6e313a3 --- /dev/null +++ b/plugins/taskboard/filter-state.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; +import { + trackerViewSchema, + workSourceSchema, + workStateCategorySchema +} from './work-schemas.ts'; + +export const ALL_SOURCES_FILTER = 'all'; +export const ACROSS_PROJECTS_SCOPE_ID = 'proj_across_projects'; +export const ACROSS_PROJECTS_SCOPE_KEY = 'across-projects'; +export const RIGHT_PANEL_SCOPE_PREFIX = 'right-panel:'; + +export const sourceFilterSchema = z.union([ + z.literal(ALL_SOURCES_FILTER), + workSourceSchema +]); +export type SourceFilterValue = z.infer; + +export const boardFilterStateSchema = z + .object({ + source: sourceFilterSchema, + stateCategories: z.array(workStateCategorySchema), + statuses: z.array(z.string()), + assignees: z.array(z.string()), + priorities: z.array(z.string()), + externalProjects: z.array(z.string()), + labels: z.array(z.string()), + query: z.string(), + view: trackerViewSchema + }) + .strict(); +export type BoardFilterState = z.infer; + +export function defaultBoardFilterState(): BoardFilterState { + return { + source: ALL_SOURCES_FILTER, + stateCategories: [], + statuses: [], + assignees: [], + priorities: [], + externalProjects: [], + labels: [], + query: '', + view: 'list' + }; +} + +/** + * The three surfaces rendering TrackerList use different in-memory scope + * keys, but filter state is persisted per project. The right panel and the + * main panel for one project share a row. + * + * An empty scope is deliberately left unaliased: it is rejected downstream + * by bbProjectIdSchema, the same as any other malformed scope, rather than + * silently overwriting the shared across-projects row. + * + * Note: a real bb project literally named `proj_across_projects` would + * collide with the across-projects row. No guard for that today. + */ +export function filterStateScopeId(scope: string): string { + const bare = scope.startsWith(RIGHT_PANEL_SCOPE_PREFIX) + ? scope.slice(RIGHT_PANEL_SCOPE_PREFIX.length) + : scope; + return bare === ACROSS_PROJECTS_SCOPE_KEY ? ACROSS_PROJECTS_SCOPE_ID : bare; +} + +function uniqueSorted(values: readonly T[]): T[] { + return [...new Set(values)].sort(); +} + +/** + * Filter arrays are sets, so order carries no meaning. Canonicalizing them + * keeps stored rows stable and stops a reorder from looking like a change. + */ +export function normalizeBoardFilterState( + state: BoardFilterState +): BoardFilterState { + return { + source: state.source, + stateCategories: uniqueSorted(state.stateCategories), + statuses: uniqueSorted(state.statuses), + assignees: uniqueSorted(state.assignees), + priorities: uniqueSorted(state.priorities), + externalProjects: uniqueSorted(state.externalProjects), + labels: uniqueSorted(state.labels), + query: state.query.trim(), + view: state.view + }; +} + +export function boardFilterStateFingerprint(state: BoardFilterState): string { + return JSON.stringify(normalizeBoardFilterState(state)); +} diff --git a/plugins/taskboard/package.json b/plugins/taskboard/package.json index 8c60079..a74fca1 100644 --- a/plugins/taskboard/package.json +++ b/plugins/taskboard/package.json @@ -64,11 +64,14 @@ "create-issue.ts", "credential-contract.ts", "credentials.ts", + "filter-presets.ts", + "filter-state.ts", "issue-draft.ts", "project-selection.ts", "server.ts", "store.ts", - "tsconfig.json" + "tsconfig.json", + "work-schemas.ts" ], "dependencies": { "@hugeicons/core-free-icons": "^4.1.3", diff --git a/plugins/taskboard/server.ts b/plugins/taskboard/server.ts index b326260..e448b1c 100644 --- a/plugins/taskboard/server.ts +++ b/plugins/taskboard/server.ts @@ -1,8 +1,12 @@ import type { BbPluginApi, PluginRpcHandlers } from '@get-bb/plugin-sdk'; import { + ACROSS_PROJECTS_SCOPE_ID, + ALL_SOURCES_FILTER, bbProjectIdSchema, + boardFilterStateSchema, formatWorkItemContext, issueDraftRecordSchema, + normalizePresetName, projectConfigMutationSchema, projectCredentialsInteractionResponseSchema, projectSourceConfigSchema, @@ -12,6 +16,7 @@ import { taskboardRpcContract, type CreateIssueContext, type CreateIssueInput, + type FilterPreset, type IssueDraftRecord, type ProjectConfigMutation, type ProjectConfigView, @@ -25,6 +30,7 @@ import { type WorkStatusOption, type WorkSourceStatus } from './contract.js'; +import { filterWorkItemsByAttributes } from './browse.js'; import { buildIssueDraftPrompt, parseIssueDraftOutput, @@ -154,13 +160,25 @@ interface ParsedCliArguments { jiraEmail: string | undefined; jiraJql: string | undefined; statusId: string | undefined; + preset: string | undefined; + fromState: string | undefined; json: boolean; cached: boolean; } const CLI_OPTIONS_BY_COMMAND = new Map>([ ['status', new Set(['--project', '--json'])], - ['list', new Set(['--project', '--source', '--query', '--cached', '--json'])], + [ + 'list', + new Set([ + '--project', + '--source', + '--query', + '--preset', + '--cached', + '--json' + ]) + ], ['show', new Set(['--project', '--json'])], ['refresh', new Set(['--project', '--json'])], ['transitions', new Set(['--project', '--json'])], @@ -177,7 +195,8 @@ const CLI_OPTIONS_BY_COMMAND = new Map>([ '--json' ]) ], - ['credentials', new Set(['--project', '--json'])] + ['credentials', new Set(['--project', '--json'])], + ['presets', new Set(['--project', '--from-state', '--json'])] ]); function parseCliArguments( @@ -197,6 +216,8 @@ function parseCliArguments( let jiraEmail: string | undefined; let jiraJql: string | undefined; let statusId: string | undefined; + let preset: string | undefined; + let fromState: string | undefined; let json = false; let cached = false; @@ -250,6 +271,12 @@ function parseCliArguments( } else if (argument === '--status') { statusId = valueAfter(argument, index); index += 1; + } else if (argument === '--preset') { + preset = valueAfter(argument, index); + index += 1; + } else if (argument === '--from-state') { + fromState = valueAfter(argument, index); + index += 1; } else if (argument === '--json') { json = true; } else if (argument === '--cached') { @@ -267,6 +294,8 @@ function parseCliArguments( jiraEmail, jiraJql, statusId, + preset, + fromState, json, cached }; @@ -593,6 +622,29 @@ export default async function plugin(bb: BbPluginApi) { await projectById(projectId); } + async function assertFilterScopeExists(scopeId: string): Promise { + if (scopeId === ACROSS_PROJECTS_SCOPE_ID) return; + await assertProjectExists(scopeId); + } + + const resolvePresetByName = ( + projectId: string, + name: string + ): FilterPreset => { + const presets = store.listFilterPresets(projectId); + const normalized = normalizePresetName(name); + const match = presets.find( + candidate => normalizePresetName(candidate.name) === normalized + ); + if (match) return match; + const available = presets.map(candidate => candidate.name).join(', '); + throw new Error( + available + ? `Unknown filter preset "${name}". Available: ${available}` + : `Unknown filter preset "${name}". This project has no presets.` + ); + }; + async function fallbackGithubRepos(projectId: string): Promise { const project = (await liveProjects()).find( entry => entry.id === projectId @@ -1728,6 +1780,42 @@ export default async function plugin(bb: BbPluginApi) { source: null }); return { settings }; + }, + async getBoardFilterState(input) { + await assertFilterScopeExists(input.projectId); + return { state: store.boardFilterState(input.projectId) }; + }, + async saveBoardFilterState(input) { + await assertFilterScopeExists(input.projectId); + return { + state: store.saveBoardFilterState(input.projectId, input.state) + }; + }, + async listFilterPresets(input) { + await assertFilterScopeExists(input.projectId); + return { presets: store.listFilterPresets(input.projectId) }; + }, + async saveFilterPreset(input) { + await assertFilterScopeExists(input.projectId); + const preset = store.saveFilterPreset({ + projectId: input.projectId, + ...(input.id ? { id: input.id } : {}), + name: input.name, + state: input.state + }); + return { preset, presets: store.listFilterPresets(input.projectId) }; + }, + async deleteFilterPreset(input) { + await assertFilterScopeExists(input.projectId); + return { + presets: store.deleteFilterPreset(input.projectId, input.id) + }; + }, + async reorderFilterPresets(input) { + await assertFilterScopeExists(input.projectId); + return { + presets: store.reorderFilterPresets(input.projectId, input.ids) + }; } }; bb.rpc.register(taskboardRpcContract, handlers); @@ -1821,7 +1909,9 @@ export default async function plugin(bb: BbPluginApi) { name: 'list', summary: 'List cached project work, refreshing first by default', usage: - 'bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--cached] [--json]' + 'bb taskboard list [--project ] ' + + '[--source linear|github|jira] [--query ] ' + + '[--preset ] [--cached] [--json]' }, { name: 'show', @@ -1857,6 +1947,18 @@ export default async function plugin(bb: BbPluginApi) { name: 'credentials', summary: 'Open a secure form for project connector credentials', usage: 'bb taskboard credentials [--project ] [--json]' + }, + { + name: 'presets', + summary: 'List, save, rename, or delete project filter presets', + usage: + 'bb taskboard presets list [--project ] [--json]\n' + + 'bb taskboard presets save --from-state ' + + '[--project ] [--json]\n' + + 'bb taskboard presets rename ' + + '[--project ] [--json]\n' + + 'bb taskboard presets delete ' + + '[--project ] [--json]' } ], async run(argv, ctx) { @@ -1952,7 +2054,9 @@ export default async function plugin(bb: BbPluginApi) { if (command === 'list') { if (args.positionals.length > 0) { throw new Error( - 'Usage: bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--cached] [--json]' + 'Usage: bb taskboard list [--project ] ' + + '[--source linear|github|jira] [--query ] ' + + '[--preset ] [--cached] [--json]' ); } const sourceValue = args.source; @@ -1962,8 +2066,18 @@ export default async function plugin(bb: BbPluginApi) { if (parsedSource && !parsedSource.success) { throw new Error('Source must be linear, github, or jira'); } - const source = parsedSource?.data; const project = await requireProject(); + // Explicit --source/--query flags beat a --preset's saved values; + // a preset source of "all" means the preset applies no filter. + const preset = args.preset + ? resolvePresetByName(project.id, args.preset) + : undefined; + const presetSource = + preset && preset.state.source !== ALL_SOURCES_FILTER + ? preset.state.source + : undefined; + const source = parsedSource?.data ?? presetSource; + const query = args.query ?? preset?.state.query; if (source) { await assertSelectedSourceAfterMutations(project.id, source); } @@ -1971,14 +2085,26 @@ export default async function plugin(bb: BbPluginApi) { const items = store.list({ projectId: project.id, ...(source ? { source } : {}), - ...(args.query ? { query: args.query } : {}), + ...(query ? { query } : {}), + ...(preset + ? { stateCategories: preset.state.stateCategories } + : {}), limit: 200 }); + const narrowedItems = preset + ? filterWorkItemsByAttributes(items, { + statuses: preset.state.statuses, + assignees: preset.state.assignees, + priorities: preset.state.priorities, + projects: preset.state.externalProjects, + labels: preset.state.labels + }) + : items; return { exitCode: 0, stdout: args.json - ? JSON.stringify({ items }, null, 2) - : items + ? JSON.stringify({ items: narrowedItems }, null, 2) + : narrowedItems .map( item => `${item.bbProjectId}\t${sourceName(item.source)}\t${item.key}\t${item.status}\t${item.assignee ?? '-'}\t${item.title}` @@ -2181,6 +2307,103 @@ export default async function plugin(bb: BbPluginApi) { : formatCredentialStatus(config) }; } + if (command === 'presets') { + const verb = args.positionals[0] ?? 'list'; + const rest = args.positionals.slice(1); + const project = await requireProject(); + + if (verb === 'list') { + if (rest.length > 0) { + throw new Error( + 'Usage: bb taskboard presets list ' + + '[--project ] [--json]' + ); + } + const presets = store.listFilterPresets(project.id); + return { + exitCode: 0, + stdout: args.json + ? JSON.stringify({ presets }, null, 2) + : presets.length > 0 + ? presets.map(item => item.name).join('\n') + : 'This project has no filter presets.' + }; + } + + if (verb === 'save') { + if (rest.length !== 1 || !args.fromState) { + throw new Error( + 'Usage: bb taskboard presets save ' + + '--from-state [--project ] [--json]' + ); + } + let fromStateJson: unknown; + try { + fromStateJson = JSON.parse(args.fromState); + } catch { + throw new Error('--from-state must be valid JSON'); + } + const parsedState = + boardFilterStateSchema.safeParse(fromStateJson); + if (!parsedState.success) { + throw new Error('--from-state is not a valid filter state'); + } + const preset = store.saveFilterPreset({ + projectId: project.id, + name: rest[0]!, + state: parsedState.data + }); + return { + exitCode: 0, + stdout: args.json + ? JSON.stringify({ preset }, null, 2) + : `Saved preset "${preset.name}"` + }; + } + + if (verb === 'rename') { + if (rest.length !== 2) { + throw new Error( + 'Usage: bb taskboard presets rename ' + + '[--project ] [--json]' + ); + } + const existing = resolvePresetByName(project.id, rest[0]!); + const preset = store.saveFilterPreset({ + projectId: project.id, + id: existing.id, + name: rest[1]!, + state: existing.state + }); + return { + exitCode: 0, + stdout: args.json + ? JSON.stringify({ preset }, null, 2) + : `Renamed preset "${existing.name}" to "${preset.name}"` + }; + } + + if (verb === 'delete') { + if (rest.length !== 1) { + throw new Error( + 'Usage: bb taskboard presets delete ' + + '[--project ] [--json]' + ); + } + const existing = resolvePresetByName(project.id, rest[0]!); + const presets = store.deleteFilterPreset(project.id, existing.id); + return { + exitCode: 0, + stdout: args.json + ? JSON.stringify({ presets }, null, 2) + : `Deleted preset "${existing.name}"` + }; + } + + throw new Error( + 'Usage: bb taskboard presets ...' + ); + } throw new Error('Unknown bb taskboard command'); } catch (error) { return { exitCode: 1, stderr: `${errorMessage(error)}\n` }; diff --git a/plugins/taskboard/store.ts b/plugins/taskboard/store.ts index 8c78f2c..59bcea9 100644 --- a/plugins/taskboard/store.ts +++ b/plugins/taskboard/store.ts @@ -1,9 +1,19 @@ +import { randomUUID } from 'node:crypto'; import type { BbPluginApi } from '@get-bb/plugin-sdk'; import { + bbProjectIdSchema, + boardFilterStateSchema, defaultProjectBoardSettings, + filterPresetNameSchema, + filterPresetSchema, + normalizeBoardFilterState, + normalizePresetName, projectBoardSettingsSchema, projectSourceConfigSchema, + resolvePresetOrder, workItemSchema, + type BoardFilterState, + type FilterPreset, type ProjectBoardSettings, type ProjectSourceConfig, type WorkItem, @@ -55,6 +65,19 @@ interface ProjectBoardSettingsRow { status_order_json: string; } +interface ProjectFilterStateRow { + bb_project_id: string; + filters_json: string; +} + +interface FilterPresetRow { + id: string; + bb_project_id: string; + name: string; + filters_json: string; + position: number; +} + export interface StoredSyncState { lastSyncedAt: string | null; error: string | null; @@ -118,10 +141,31 @@ function boardSettingsFromRow( }); } +function filterPresetFromRow(row: FilterPresetRow): FilterPreset | null { + const state = parseJsonSafely(row.filters_json); + if (state === undefined) return null; + const parsed = filterPresetSchema.safeParse({ + id: row.id, + projectId: row.bb_project_id, + name: row.name, + state, + position: row.position + }); + return parsed.success ? parsed.data : null; +} + function escapeLike(value: string): string { return value.replace(/[\\%_]/gu, character => `\\${character}`); } +function parseJsonSafely(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + export function createWorkItemStore(bb: BbPluginApi) { const db = bb.storage.database(); bb.storage.migrate(db, [ @@ -323,6 +367,29 @@ export function createWorkItemStore(bb: BbPluginApi) { status_order_json TEXT NOT NULL, updated_at TEXT NOT NULL ); + `, + ` + CREATE TABLE project_filter_state ( + bb_project_id TEXT PRIMARY KEY, + filters_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `, + ` + CREATE TABLE project_filter_presets ( + id TEXT PRIMARY KEY, + bb_project_id TEXT NOT NULL, + name TEXT NOT NULL, + name_normalized TEXT NOT NULL, + filters_json TEXT NOT NULL, + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (bb_project_id, name_normalized) + ); + + CREATE INDEX idx_filter_presets_project + ON project_filter_presets(bb_project_id, position); ` ]); @@ -433,6 +500,25 @@ export function createWorkItemStore(bb: BbPluginApi) { WHERE bb_project_id = ? `); + const readProjectFilterState = db.prepare<[string], ProjectFilterStateRow>(` + SELECT bb_project_id, filters_json + FROM project_filter_state + WHERE bb_project_id = ? + `); + + const readFilterPresets = db.prepare<[string], FilterPresetRow>(` + SELECT id, bb_project_id, name, filters_json, position + FROM project_filter_presets + WHERE bb_project_id = ? + ORDER BY position ASC + `); + + const readFilterPreset = db.prepare<[string, string], FilterPresetRow>(` + SELECT id, bb_project_id, name, filters_json, position + FROM project_filter_presets + WHERE bb_project_id = ? AND id = ? + `); + const clearSourceTransaction = db.transaction( (projectId: string, source: WorkSource) => { db.prepare<[string, WorkSource]>( @@ -683,6 +769,200 @@ export function createWorkItemStore(bb: BbPluginApi) { readProjectBoardSettings.get(settings.projectId)! ); }, + boardFilterState(projectId: string): BoardFilterState | null { + bbProjectIdSchema.parse(projectId); + const row = readProjectFilterState.get(projectId); + if (!row) return null; + // A row whose JSON is malformed or no longer matches the schema + // returns null rather than throwing, so a corrupt write or a future + // schema change degrades to "no saved filters" instead of breaking + // the board. + const raw = parseJsonSafely(row.filters_json); + if (raw === undefined) return null; + const parsed = boardFilterStateSchema.safeParse(raw); + return parsed.success ? normalizeBoardFilterState(parsed.data) : null; + }, + saveBoardFilterState( + projectId: string, + input: BoardFilterState + ): BoardFilterState { + bbProjectIdSchema.parse(projectId); + const state = normalizeBoardFilterState( + boardFilterStateSchema.parse(input) + ); + db.prepare<[string, string, string]>( + ` + INSERT INTO project_filter_state ( + bb_project_id, filters_json, updated_at + ) VALUES (?, ?, ?) + ON CONFLICT(bb_project_id) DO UPDATE SET + filters_json = excluded.filters_json, + updated_at = excluded.updated_at + ` + ).run(projectId, JSON.stringify(state), new Date().toISOString()); + return state; + }, + listFilterPresets(projectId: string): FilterPreset[] { + bbProjectIdSchema.parse(projectId); + return readFilterPresets + .all(projectId) + .map(filterPresetFromRow) + .filter((preset): preset is FilterPreset => preset !== null); + }, + saveFilterPreset(input: { + projectId: string; + id?: string; + name: string; + state: BoardFilterState; + }): FilterPreset { + bbProjectIdSchema.parse(input.projectId); + const name = filterPresetNameSchema.parse(input.name); + const normalized = normalizePresetName(name); + const state = normalizeBoardFilterState( + boardFilterStateSchema.parse(input.state) + ); + const now = new Date().toISOString(); + function readSavedPreset(id: string): FilterPreset { + const saved = filterPresetFromRow( + readFilterPreset.get(input.projectId, id)! + ); + if (!saved) { + throw new Error('Saved filter preset could not be read back'); + } + return saved; + } + const conflict = db + .prepare<[string, string], { id: string; name: string }>( + ` + SELECT id, name + FROM project_filter_presets + WHERE bb_project_id = ? AND name_normalized = ? + ` + ) + .get(input.projectId, normalized); + if (conflict && conflict.id !== input.id) { + throw new Error( + `A filter preset named "${conflict.name}" already exists` + ); + } + + if (input.id) { + const existing = readFilterPreset.get(input.projectId, input.id); + if (!existing) throw new Error(`Unknown filter preset: ${input.id}`); + db.prepare<[string, string, string, string, string, string]>( + ` + UPDATE project_filter_presets + SET name = ?, name_normalized = ?, filters_json = ?, updated_at = ? + WHERE bb_project_id = ? AND id = ? + ` + ).run( + name, + normalized, + JSON.stringify(state), + now, + input.projectId, + input.id + ); + return readSavedPreset(input.id); + } + + const id = `fp_${randomUUID().replaceAll('-', '')}`; + const position = readFilterPresets.all(input.projectId).length; + db.prepare< + [string, string, string, string, string, number, string, string] + >( + ` + INSERT INTO project_filter_presets ( + id, bb_project_id, name, name_normalized, filters_json, position, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run( + id, + input.projectId, + name, + normalized, + JSON.stringify(state), + position, + now, + now + ); + return readSavedPreset(id); + }, + // Deleting an unknown id is a deliberate no-op, unlike saveFilterPreset + // and reorderFilterPresets which throw. Delete is idempotent, and the + // callers resync from the returned list rather than trusting a local + // delta, so a stale id produces no visible inconsistency. + deleteFilterPreset(projectId: string, id: string): FilterPreset[] { + bbProjectIdSchema.parse(projectId); + return db.transaction(() => { + db.prepare<[string, string]>( + `DELETE FROM project_filter_presets + WHERE bb_project_id = ? AND id = ?` + ).run(projectId, id); + // Renumber every remaining row, including any that fail to parse: + // an unreadable row is invisible to clients but still occupies a + // position, so leaving it out here would let it collide with a + // visible row forever instead of just until the next delete. + const remaining = readFilterPresets.all(projectId); + const now = new Date().toISOString(); + const updatePosition = db.prepare<[number, string, string, string]>( + ` + UPDATE project_filter_presets + SET position = ?, updated_at = ? + WHERE bb_project_id = ? AND id = ? + ` + ); + remaining.forEach((row, index) => { + if (row.position === index) return; + updatePosition.run(index, now, projectId, row.id); + }); + return readFilterPresets + .all(projectId) + .map(filterPresetFromRow) + .filter((preset): preset is FilterPreset => preset !== null); + })(); + }, + reorderFilterPresets( + projectId: string, + ids: readonly string[] + ): FilterPreset[] { + bbProjectIdSchema.parse(projectId); + return db.transaction(() => { + const rows = readFilterPresets.all(projectId); + // A client can only ever request an order for presets it was + // shown, and listFilterPresets hides rows that fail to parse. So + // validate against, and renumber, only the parseable subset — + // otherwise one corrupt row permanently blocks every reorder for + // this project, since resolvePresetOrder demands an exact + // permutation of every stored id. An unreadable row keeps its old + // position and may end up sharing it with a visible row; that is + // harmless because the corrupt row is never displayed, and the + // next delete renumbers every row (visible or not) contiguously + // from 0 anyway. + const currentIds = rows + .filter(row => filterPresetFromRow(row) !== null) + .map(row => row.id); + const ordered = resolvePresetOrder(currentIds, ids); + const positionById = new Map(rows.map(row => [row.id, row.position])); + const now = new Date().toISOString(); + const updatePosition = db.prepare<[number, string, string, string]>( + ` + UPDATE project_filter_presets + SET position = ?, updated_at = ? + WHERE bb_project_id = ? AND id = ? + ` + ); + ordered.forEach((id, index) => { + if (positionById.get(id) === index) return; + updatePosition.run(index, now, projectId, id); + }); + return readFilterPresets + .all(projectId) + .map(filterPresetFromRow) + .filter((preset): preset is FilterPreset => preset !== null); + })(); + }, configuredProjectIds(): string[] { return db .prepare<[], { bb_project_id: string }>( diff --git a/plugins/taskboard/test/filter-presets.test.ts b/plugins/taskboard/test/filter-presets.test.ts new file mode 100644 index 0000000..903e4cc --- /dev/null +++ b/plugins/taskboard/test/filter-presets.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { defaultBoardFilterState } from '../filter-state.ts'; +import { + filterPresetSchema, + normalizePresetName, + resolvePresetOrder +} from '../filter-presets.ts'; + +test('normalizes names for collision checks', () => { + assert.equal(normalizePresetName(' My Work '), 'my work'); + assert.equal(normalizePresetName('MY WORK'), normalizePresetName('my work')); +}); + +test('accepts a valid preset', () => { + const preset = filterPresetSchema.parse({ + id: 'fp_1', + projectId: 'proj_alpha', + name: 'My work', + state: defaultBoardFilterState(), + position: 0 + }); + assert.equal(preset.name, 'My work'); +}); + +test('rejects empty and overlong names', () => { + const base = { + id: 'fp_1', + projectId: 'proj_alpha', + state: defaultBoardFilterState(), + position: 0 + }; + assert.throws(() => filterPresetSchema.parse({ ...base, name: ' ' })); + assert.throws(() => + filterPresetSchema.parse({ ...base, name: 'x'.repeat(61) }) + ); +}); + +test('resolves a reorder that lists every preset once', () => { + assert.deepEqual( + resolvePresetOrder(['a', 'b', 'c'], ['c', 'a', 'b']), + ['c', 'a', 'b'] + ); +}); + +test('rejects incomplete, unknown, and duplicated reorders', () => { + assert.throws(() => resolvePresetOrder(['a', 'b'], ['a'])); + assert.throws(() => resolvePresetOrder(['a', 'b'], ['a', 'z'])); + assert.throws(() => resolvePresetOrder(['a', 'b'], ['a', 'a'])); +}); + +test('normalization is locale-independent', () => { + // name_normalized is persisted and backs a UNIQUE constraint, so it must + // produce the same bytes on every machine. Pin the expected codepoints + // rather than comparing two implementations: U+0130 lowercases to + // 'i' + U+0307 under Unicode's locale-independent mapping, but to a bare + // 'i' under tr-TR. Note this assertion cannot fail on a host whose locale + // already agrees with the default mapping; it is here to fail loudly on a + // Turkish host if someone reintroduces toLocaleLowerCase. + assert.equal(normalizePresetName('\u0130'), 'i\u0307'); + assert.equal(normalizePresetName(' \u0130S '), 'i\u0307s'); +}); diff --git a/plugins/taskboard/test/filter-state.test.ts b/plugins/taskboard/test/filter-state.test.ts new file mode 100644 index 0000000..0c4d210 --- /dev/null +++ b/plugins/taskboard/test/filter-state.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { z } from 'zod'; +import { + ACROSS_PROJECTS_SCOPE_ID, + boardFilterStateFingerprint, + boardFilterStateSchema, + defaultBoardFilterState, + filterStateScopeId, + normalizeBoardFilterState +} from '../filter-state.ts'; +import type { BoardFilterState } from '../filter-state.ts'; + +function state(overrides: Partial = {}): BoardFilterState { + return { ...defaultBoardFilterState(), ...overrides }; +} + +test('parses a complete filter state', () => { + const parsed = boardFilterStateSchema.parse({ + source: 'linear', + stateCategories: ['todo'], + statuses: ['In Progress'], + assignees: ['Andrii Los'], + priorities: ['High'], + externalProjects: ['Platform'], + labels: ['bug'], + query: 'flake', + view: 'kanban' + }); + assert.equal(parsed.source, 'linear'); + assert.deepEqual(parsed.assignees, ['Andrii Los']); +}); + +test('rejects unknown keys, including committedQuery', () => { + const result = boardFilterStateSchema.safeParse({ + ...defaultBoardFilterState(), + committedQuery: 'flake' + }); + assert.equal(result.success, false); + assert.equal( + (result.error as z.ZodError).issues[0]?.code, + 'unrecognized_keys' + ); +}); + +test('collapses every surface scope key to one storage key', () => { + assert.equal(filterStateScopeId('proj_alpha'), 'proj_alpha'); + assert.equal(filterStateScopeId('right-panel:proj_alpha'), 'proj_alpha'); + assert.equal(filterStateScopeId('across-projects'), ACROSS_PROJECTS_SCOPE_ID); + assert.equal( + filterStateScopeId('right-panel:across-projects'), + ACROSS_PROJECTS_SCOPE_ID + ); +}); + +test('leaves an empty scope unaliased for the RPC boundary to reject', () => { + // filterStateScopeId does not special-case '' onto the across-projects + // row; both of these are rejected downstream by bbProjectIdSchema + // (startsWith('proj_')), the same as any other malformed scope. + assert.equal(filterStateScopeId(''), ''); + assert.equal(filterStateScopeId('right-panel:'), ''); +}); + +test('normalizes every array field independently and trims the query', () => { + const normalized = normalizeBoardFilterState( + state({ + stateCategories: ['todo', 'backlog', 'todo'], + statuses: ['Todo', 'Backlog', 'Todo'], + assignees: ['Bob', 'Alice', 'Bob'], + priorities: ['Low', 'High', 'Low'], + externalProjects: ['Web', 'Api', 'Web'], + labels: ['ui', 'bug', 'ui'], + query: ' flake ' + }) + ); + assert.deepEqual(normalized.stateCategories, ['backlog', 'todo']); + assert.deepEqual(normalized.statuses, ['Backlog', 'Todo']); + assert.deepEqual(normalized.assignees, ['Alice', 'Bob']); + assert.deepEqual(normalized.priorities, ['High', 'Low']); + assert.deepEqual(normalized.externalProjects, ['Api', 'Web']); + assert.deepEqual(normalized.labels, ['bug', 'ui']); + assert.equal(normalized.query, 'flake'); +}); + +test('fingerprints ignore array order', () => { + assert.equal( + boardFilterStateFingerprint(state({ labels: ['ui', 'bug'] })), + boardFilterStateFingerprint(state({ labels: ['bug', 'ui'] })) + ); + assert.notEqual( + boardFilterStateFingerprint(state({ labels: ['ui'] })), + boardFilterStateFingerprint(state({ labels: ['bug'] })) + ); +}); diff --git a/plugins/taskboard/work-schemas.ts b/plugins/taskboard/work-schemas.ts new file mode 100644 index 0000000..07dc759 --- /dev/null +++ b/plugins/taskboard/work-schemas.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +/** + * Leaf module for the plugin's primitive enums. It has no local imports of + * its own, so any module loaded directly by `node --test + * --experimental-strip-types` (rather than through the bundled plugin) can + * depend on it without dragging in a chain of `.js` specifiers the raw + * test runner cannot resolve to their `.ts` sources. + */ + +export const workSourceSchema = z.enum(['linear', 'github', 'jira']); +export type WorkSource = z.infer; + +export const workStateCategorySchema = z.enum([ + 'backlog', + 'todo', + 'in_progress', + 'done', + 'canceled' +]); +export type WorkStateCategory = z.infer; + +export const trackerViewSchema = z.enum(['list', 'kanban']); +export type TrackerView = z.infer;