This commit is contained in:
@@ -353,6 +353,12 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
sortMode: sort,
|
||||
searchQuery: search,
|
||||
activeTaskId: localTaskId,
|
||||
// Owner scope (自分/すべて). Only active when auth is on — in no-auth mode
|
||||
// every task is owned by 'local' and the toggle would be meaningless.
|
||||
scope: urlState.scope,
|
||||
onScopeChange: (scope: 'mine' | 'all') => setUrlState(prev => ({ ...prev, scope })),
|
||||
currentUserId: user?.id ?? null,
|
||||
scopeEnabled: authEnabled && !!user,
|
||||
onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })),
|
||||
onSortChange: (s: string) => setUrlState(prev => ({ ...prev, sort: s as typeof sort })),
|
||||
onSearchChange: (q: string) => setUrlState(prev => ({ ...prev, search: q })),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LocalTask } from '../../api';
|
||||
import { matchText } from '../../lib/utils';
|
||||
import { COLUMN_LIST, SortMode, StatusColumn } from '../../lib/urlState';
|
||||
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { LocalTaskListItem } from './TaskListItem';
|
||||
import { RailPanel } from './RailPanel';
|
||||
@@ -16,14 +17,56 @@ interface TaskListPanelProps {
|
||||
onSearchChange: (q: string) => void;
|
||||
onSelectTask: (id: number) => void;
|
||||
onOpenCreate: () => void;
|
||||
/**
|
||||
* Owner scope (mine/all). Only meaningful when scopeEnabled — auth must be
|
||||
* on and the viewer known; otherwise everything is owner 'local' and the
|
||||
* control is hidden.
|
||||
*/
|
||||
scope?: TaskScope;
|
||||
onScopeChange?: (scope: TaskScope) => void;
|
||||
currentUserId?: string | null;
|
||||
scopeEnabled?: boolean;
|
||||
/** 'rail' 時は RailPanel を render する。default 'list'。 */
|
||||
mode?: 'list' | 'rail';
|
||||
/** rail mode 時の「リストに戻る」ボタンで呼ばれる。 */
|
||||
onExitFocused?: () => void;
|
||||
}
|
||||
|
||||
function ScopeToggle({
|
||||
scope,
|
||||
mineCount,
|
||||
allCount,
|
||||
onScopeChange,
|
||||
}: {
|
||||
scope: TaskScope;
|
||||
mineCount: number;
|
||||
allCount: number;
|
||||
onScopeChange: (scope: TaskScope) => void;
|
||||
}) {
|
||||
const seg = (value: TaskScope, label: string, count: number) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onScopeChange(value)}
|
||||
aria-pressed={scope === value}
|
||||
className={`flex-1 px-2 py-1 rounded text-[11px] font-medium transition-colors tabular-nums ${
|
||||
scope === value
|
||||
? 'bg-surface text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{label} <span className={scope === value ? 'text-slate-500' : 'text-slate-400'}>{count}</span>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label="タスクの表示範囲">
|
||||
{seg('mine', '自分', mineCount)}
|
||||
{seg('all', 'すべて', allCount)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskListPanel({
|
||||
localTasks,
|
||||
localTasks: allTasks,
|
||||
selectedStatus,
|
||||
sortMode,
|
||||
searchQuery,
|
||||
@@ -33,9 +76,17 @@ export function TaskListPanel({
|
||||
onSearchChange,
|
||||
onSelectTask,
|
||||
onOpenCreate,
|
||||
scope = 'mine',
|
||||
onScopeChange,
|
||||
currentUserId = null,
|
||||
scopeEnabled = false,
|
||||
mode = 'list',
|
||||
onExitFocused,
|
||||
}: TaskListPanelProps) {
|
||||
// Owner scope is the outermost filter: status counts / search / sort all
|
||||
// operate on the scoped list so "自分" mode never counts others' tasks.
|
||||
const effectiveScope: TaskScope = scopeEnabled ? scope : 'all';
|
||||
const localTasks = filterTasksByScope(allTasks, effectiveScope, currentUserId);
|
||||
if (mode === 'rail') {
|
||||
const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
|
||||
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
|
||||
@@ -111,6 +162,14 @@ export function TaskListPanel({
|
||||
</svg>
|
||||
新しい依頼
|
||||
</button>
|
||||
{scopeEnabled && onScopeChange && (
|
||||
<ScopeToggle
|
||||
scope={scope}
|
||||
mineCount={filterTasksByScope(allTasks, 'mine', currentUserId).length}
|
||||
allCount={allTasks.length}
|
||||
onScopeChange={onScopeChange}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-[10px] text-slate-500 px-0.5 pb-2.5 font-mono tabular-nums">
|
||||
<span><span className="font-semibold text-slate-700">{totalCount}</span> 件</span>
|
||||
<span aria-hidden="true" className="text-slate-300">·</span>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// NOTE: renderHelpHtml is NOT covered here — it depends on DOMPurify, which
|
||||
// requires a real DOM (DOMPurify.isSupported === false in this node test
|
||||
// environment, sanitize is not callable). Everything else in help.ts is pure.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
splitFrontmatter,
|
||||
validateFrontmatter,
|
||||
parseHelpDoc,
|
||||
slugify,
|
||||
makeSlugger,
|
||||
filterSections,
|
||||
type HelpSection,
|
||||
} from './help';
|
||||
|
||||
const DOC = `---
|
||||
id: getting-started
|
||||
title: はじめに
|
||||
category: basic
|
||||
order: 1
|
||||
keywords: [setup, 初期設定]
|
||||
---
|
||||
# はじめに
|
||||
|
||||
本文です。`;
|
||||
|
||||
describe('splitFrontmatter', () => {
|
||||
it('splits the frontmatter block from the body', () => {
|
||||
const { frontmatter, body } = splitFrontmatter(DOC);
|
||||
expect(frontmatter).toContain('id: getting-started');
|
||||
expect(body).toContain('# はじめに');
|
||||
expect(body).not.toContain('---');
|
||||
});
|
||||
|
||||
it('returns the whole input as body when there is no frontmatter', () => {
|
||||
const { frontmatter, body } = splitFrontmatter('plain body');
|
||||
expect(frontmatter).toBe('');
|
||||
expect(body).toBe('plain body');
|
||||
});
|
||||
|
||||
it('normalizes CRLF and strips a BOM', () => {
|
||||
const { frontmatter, body } = splitFrontmatter('---\r\nid: x\r\n---\r\nbody');
|
||||
expect(frontmatter).toBe('id: x');
|
||||
expect(body).toBe('body');
|
||||
});
|
||||
|
||||
it('ignores a frontmatter block not at the very start', () => {
|
||||
const { frontmatter } = splitFrontmatter('intro\n---\nid: x\n---\n');
|
||||
expect(frontmatter).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFrontmatter', () => {
|
||||
const VALID = { id: 'a', title: 't', category: 'basic', order: 1, keywords: ['k'] };
|
||||
|
||||
it('accepts a valid frontmatter object', () => {
|
||||
const r = validateFrontmatter(VALID, 'doc.md');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.value).toEqual(VALID);
|
||||
});
|
||||
|
||||
it('defaults keywords to an empty array', () => {
|
||||
const r = validateFrontmatter({ ...VALID, keywords: undefined }, 'doc.md');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.value?.keywords).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing id', { ...VALID, id: '' }, /'id' is required/],
|
||||
['missing title', { ...VALID, title: undefined }, /'title' is required/],
|
||||
['bad category', { ...VALID, category: 'misc' }, /'category' must be one of/],
|
||||
['bad order', { ...VALID, order: 'first' }, /'order' is required/],
|
||||
['bad keywords', { ...VALID, keywords: [1] }, /'keywords' must be a string array/],
|
||||
])('rejects %s with a sourced error', (_label, data, re) => {
|
||||
const r = validateFrontmatter(data, 'doc.md');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.join('\n')).toMatch(re);
|
||||
expect(r.errors[0]).toContain('doc.md');
|
||||
});
|
||||
|
||||
it('collects multiple errors for non-object input', () => {
|
||||
const r = validateFrontmatter(null, 'doc.md');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHelpDoc', () => {
|
||||
it('parses YAML frontmatter and body', () => {
|
||||
const { data, body } = parseHelpDoc(DOC);
|
||||
expect(data).toMatchObject({ id: 'getting-started', category: 'basic', keywords: ['setup', '初期設定'] });
|
||||
expect(body).toContain('本文です。');
|
||||
});
|
||||
|
||||
it('returns an empty object for a doc without frontmatter', () => {
|
||||
expect(parseHelpDoc('just text').data).toEqual({});
|
||||
});
|
||||
|
||||
it('throws on YAML syntax errors', () => {
|
||||
expect(() => parseHelpDoc('---\nid: [unclosed\n---\nbody')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('lowercases and hyphenates ascii text', () => {
|
||||
expect(slugify('Getting Started!')).toBe('getting-started');
|
||||
});
|
||||
|
||||
it('keeps Japanese characters', () => {
|
||||
expect(slugify('タスクの作成 方法')).toBe('タスクの作成-方法');
|
||||
});
|
||||
|
||||
it('strips HTML tags', () => {
|
||||
expect(slugify('<code>config.yaml</code> settings')).toBe('config-yaml-settings');
|
||||
});
|
||||
|
||||
it('falls back to "section" for empty results', () => {
|
||||
expect(slugify('!!!')).toBe('section');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeSlugger', () => {
|
||||
it('appends -2, -3 on collisions', () => {
|
||||
const slug = makeSlugger();
|
||||
expect(slug('Setup')).toBe('setup');
|
||||
expect(slug('Setup')).toBe('setup-2');
|
||||
expect(slug('Setup')).toBe('setup-3');
|
||||
expect(slug('Other')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSections', () => {
|
||||
const sections: HelpSection[] = [
|
||||
{ id: 'a', title: 'タスクの作成', category: 'basic', order: 1, keywords: ['task'], body: '作成手順' },
|
||||
{ id: 'b', title: 'Scheduling', category: 'advanced', order: 2, keywords: ['cron'], body: 'periodic runs' },
|
||||
{ id: 'c', title: 'Admin', category: 'admin', order: 3, keywords: [], body: 'ユーザー管理と権限' },
|
||||
];
|
||||
|
||||
it('returns everything for an empty query', () => {
|
||||
expect(filterSections(sections, ' ')).toEqual(sections);
|
||||
});
|
||||
|
||||
it('matches the title case-insensitively', () => {
|
||||
expect(filterSections(sections, 'scheduling').map((s) => s.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('matches keywords', () => {
|
||||
expect(filterSections(sections, 'CRON').map((s) => s.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('matches the body, including Japanese', () => {
|
||||
expect(filterSections(sections, '権限').map((s) => s.id)).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('returns an empty list when nothing matches', () => {
|
||||
expect(filterSections(sections, 'zzz')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { filterTasksByScope } from './taskScope';
|
||||
|
||||
const tasks = [
|
||||
{ id: 1, ownerId: 'alice' },
|
||||
{ id: 2, ownerId: 'bob' },
|
||||
{ id: 3, ownerId: 'alice' },
|
||||
{ id: 4, ownerId: null }, // legacy no-auth row
|
||||
{ id: 5 }, // ownerId undefined
|
||||
];
|
||||
|
||||
describe('filterTasksByScope', () => {
|
||||
it("scope='mine' keeps only the current user's tasks", () => {
|
||||
expect(filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("scope='all' returns everything", () => {
|
||||
expect(filterTasksByScope(tasks, 'all', 'alice')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('no current user (auth disabled) returns everything even for mine', () => {
|
||||
expect(filterTasksByScope(tasks, 'mine', null)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('legacy null/undefined owners never match mine', () => {
|
||||
expect(filterTasksByScope(tasks, 'mine', 'bob').map(t => t.id)).toEqual([2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* taskScope.ts — タスク一覧の所有者スコープフィルタ。
|
||||
*
|
||||
* visibility (public / org) のせいで他ユーザーのタスクが一覧に混ざり
|
||||
* 「自分のタスクを見失う」問題への対策。'mine' は ownerId が自分のものだけを
|
||||
* 残す。認証無効時 (currentUserId が無い時) はフィルタ自体が無意味なので
|
||||
* 全件を返す。
|
||||
*/
|
||||
|
||||
export type TaskScope = 'mine' | 'all';
|
||||
|
||||
export const TASK_SCOPES: TaskScope[] = ['mine', 'all'];
|
||||
|
||||
export function filterTasksByScope<T extends { ownerId?: string | null }>(
|
||||
tasks: T[],
|
||||
scope: TaskScope,
|
||||
currentUserId: string | null,
|
||||
): T[] {
|
||||
if (scope !== 'mine' || !currentUserId) return tasks;
|
||||
return tasks.filter(t => t.ownerId === currentUserId);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// NOTE: useUnsavedGuard (the React hook) needs a component render and a DOM,
|
||||
// neither of which exist in this node test environment — so the dirty-state
|
||||
// paths cannot be registered here. These tests cover the module's exported
|
||||
// behavior with an empty checker registry, including that window.confirm is
|
||||
// NOT shown when nothing is dirty.
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { hasUnsavedChanges, confirmDiscardUnsaved } from './unsavedGuard';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('hasUnsavedChanges', () => {
|
||||
it('is false when no view registered a checker', () => {
|
||||
expect(hasUnsavedChanges()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmDiscardUnsaved', () => {
|
||||
it('proceeds without prompting when nothing is dirty', () => {
|
||||
const confirm = vi.fn().mockReturnValue(false);
|
||||
vi.stubGlobal('window', { confirm });
|
||||
expect(confirmDiscardUnsaved()).toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
readUiUrlState,
|
||||
buildUiUrlStateSearch,
|
||||
paramsEqualExcept,
|
||||
COLUMN_LABELS,
|
||||
COLUMN_LIST,
|
||||
type UiUrlState,
|
||||
} from './urlState';
|
||||
|
||||
const DEFAULT_STATE: UiUrlState = {
|
||||
page: 'tasks',
|
||||
repo: '',
|
||||
status: 'all',
|
||||
search: '',
|
||||
sort: 'updated',
|
||||
scope: 'mine',
|
||||
detailTab: 'overview',
|
||||
mobileTab: 'chat',
|
||||
taskId: null,
|
||||
};
|
||||
|
||||
function stubLocation(search: string) {
|
||||
vi.stubGlobal('window', { location: { search } });
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('readUiUrlState', () => {
|
||||
it('returns defaults when window is undefined (SSR/node)', () => {
|
||||
// node environment: no window global by default
|
||||
expect(readUiUrlState()).toEqual(DEFAULT_STATE);
|
||||
});
|
||||
|
||||
it('returns defaults for an empty query string', () => {
|
||||
stubLocation('');
|
||||
const state = readUiUrlState();
|
||||
expect(state).toEqual({
|
||||
...DEFAULT_STATE,
|
||||
section: undefined,
|
||||
piece: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses every valid parameter', () => {
|
||||
stubLocation(
|
||||
'?page=settings&repo=local%2Ftask-1&status=running&q=hello&sort=title' +
|
||||
'&tab=files&mobileTab=activity&task=42§ion=auth&piece=chat' +
|
||||
'&pieceSource=user-custom&dashboardWidget=gpu&help=intro',
|
||||
);
|
||||
const state = readUiUrlState();
|
||||
expect(state.page).toBe('settings');
|
||||
expect(state.repo).toBe('local/task-1');
|
||||
expect(state.status).toBe('running');
|
||||
expect(state.search).toBe('hello');
|
||||
expect(state.sort).toBe('title');
|
||||
expect(state.detailTab).toBe('files');
|
||||
expect(state.mobileTab).toBe('activity');
|
||||
expect(state.taskId).toBe(42);
|
||||
expect(state.section).toBe('auth');
|
||||
expect(state.piece).toBe('chat');
|
||||
expect(state.pieceSource).toBe('user-custom');
|
||||
expect(state.dashboardWidget).toBe('gpu');
|
||||
expect(state.help).toBe('intro');
|
||||
});
|
||||
|
||||
it('falls back to defaults on invalid enum values', () => {
|
||||
stubLocation('?page=bogus&status=bogus&sort=bogus&tab=bogus&mobileTab=bogus§ion=bogus&pieceSource=bogus');
|
||||
const state = readUiUrlState();
|
||||
expect(state.page).toBe('tasks');
|
||||
expect(state.status).toBe('all');
|
||||
expect(state.sort).toBe('updated');
|
||||
expect(state.detailTab).toBe('overview');
|
||||
expect(state.mobileTab).toBe('chat');
|
||||
expect(state.section).toBeUndefined();
|
||||
expect(state.pieceSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects non-positive and non-numeric task ids', () => {
|
||||
for (const task of ['0', '-5', 'abc', '']) {
|
||||
stubLocation(`?task=${task}`);
|
||||
expect(readUiUrlState().taskId).toBeNull();
|
||||
}
|
||||
stubLocation('?task=7');
|
||||
expect(readUiUrlState().taskId).toBe(7);
|
||||
});
|
||||
|
||||
it('still parses legacy settings section ids (soft bookmark landing)', () => {
|
||||
for (const legacy of ['gateway-keys', 'provider', 'workspace', 'browser-sessions']) {
|
||||
stubLocation(`?section=${legacy}`);
|
||||
expect(readUiUrlState().section).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an empty piece param as undefined', () => {
|
||||
stubLocation('?piece=');
|
||||
expect(readUiUrlState().piece).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits optional spread keys entirely when their params are absent', () => {
|
||||
stubLocation('?page=tasks');
|
||||
const state = readUiUrlState();
|
||||
expect('pieceSource' in state).toBe(false);
|
||||
expect('dashboardWidget' in state).toBe(false);
|
||||
expect('help' in state).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUiUrlStateSearch', () => {
|
||||
it('produces an empty string for the default state', () => {
|
||||
expect(buildUiUrlStateSearch(DEFAULT_STATE)).toBe('');
|
||||
});
|
||||
|
||||
it('only serializes non-default values', () => {
|
||||
const search = buildUiUrlStateSearch({
|
||||
...DEFAULT_STATE,
|
||||
status: 'failed',
|
||||
taskId: 12,
|
||||
});
|
||||
const params = new URLSearchParams(search);
|
||||
expect(params.get('status')).toBe('failed');
|
||||
expect(params.get('task')).toBe('12');
|
||||
expect(Array.from(params.keys()).sort()).toEqual(['status', 'task']);
|
||||
});
|
||||
|
||||
it('omits the default dashboardWidget (worker-status) but keeps others', () => {
|
||||
expect(
|
||||
buildUiUrlStateSearch({ ...DEFAULT_STATE, dashboardWidget: 'worker-status' }),
|
||||
).toBe('');
|
||||
expect(
|
||||
buildUiUrlStateSearch({ ...DEFAULT_STATE, dashboardWidget: 'gpu' }),
|
||||
).toBe('dashboardWidget=gpu');
|
||||
});
|
||||
|
||||
it('round-trips through readUiUrlState', () => {
|
||||
const state: UiUrlState = {
|
||||
page: 'help',
|
||||
repo: 'local/task-9',
|
||||
status: 'waiting_human',
|
||||
search: 'foo bar',
|
||||
sort: 'status',
|
||||
scope: 'all',
|
||||
detailTab: 'trace',
|
||||
mobileTab: 'files',
|
||||
taskId: 99,
|
||||
section: 'reflection',
|
||||
piece: 'research',
|
||||
pieceSource: 'builtin',
|
||||
dashboardWidget: 'queue',
|
||||
help: 'pieces',
|
||||
};
|
||||
stubLocation(`?${buildUiUrlStateSearch(state)}`);
|
||||
expect(readUiUrlState()).toEqual(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paramsEqualExcept', () => {
|
||||
it('considers identical params equal', () => {
|
||||
const a = new URLSearchParams('a=1&b=2');
|
||||
const b = new URLSearchParams('a=1&b=2');
|
||||
expect(paramsEqualExcept(a, b, [])).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores key order', () => {
|
||||
const a = new URLSearchParams('b=2&a=1');
|
||||
const b = new URLSearchParams('a=1&b=2');
|
||||
expect(paramsEqualExcept(a, b, [])).toBe(true);
|
||||
});
|
||||
|
||||
it('detects value differences', () => {
|
||||
const a = new URLSearchParams('a=1');
|
||||
const b = new URLSearchParams('a=2');
|
||||
expect(paramsEqualExcept(a, b, [])).toBe(false);
|
||||
});
|
||||
|
||||
it('skips ignored keys on either side', () => {
|
||||
const a = new URLSearchParams('a=1&tab=files');
|
||||
const b = new URLSearchParams('a=1&tab=trace');
|
||||
expect(paramsEqualExcept(a, b, ['tab'])).toBe(true);
|
||||
const c = new URLSearchParams('a=1');
|
||||
expect(paramsEqualExcept(a, c, ['tab'])).toBe(true);
|
||||
});
|
||||
|
||||
it('detects extra non-ignored keys', () => {
|
||||
const a = new URLSearchParams('a=1&extra=x');
|
||||
const b = new URLSearchParams('a=1');
|
||||
expect(paramsEqualExcept(a, b, [])).toBe(false);
|
||||
});
|
||||
|
||||
it('handles repeated keys deterministically', () => {
|
||||
const a = new URLSearchParams('k=2&k=1');
|
||||
const b = new URLSearchParams('k=1&k=2');
|
||||
expect(paramsEqualExcept(a, b, [])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('column constants', () => {
|
||||
it('every status column has a label', () => {
|
||||
for (const col of COLUMN_LIST) {
|
||||
expect(COLUMN_LABELS[col], `label for ${col}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,8 @@ export interface UiUrlState {
|
||||
status: 'all' | StatusColumn;
|
||||
search: string;
|
||||
sort: SortMode;
|
||||
/** Owner scope for the task list. 'mine' = own tasks only (default when auth is on). */
|
||||
scope: 'mine' | 'all';
|
||||
detailTab: DetailTabId;
|
||||
mobileTab: MobileTabId;
|
||||
taskId: number | null;
|
||||
@@ -97,6 +99,7 @@ export function readUiUrlState(): UiUrlState {
|
||||
status: 'all',
|
||||
search: '',
|
||||
sort: 'updated',
|
||||
scope: 'mine',
|
||||
detailTab: 'overview',
|
||||
mobileTab: 'chat',
|
||||
taskId: null,
|
||||
@@ -128,6 +131,7 @@ export function readUiUrlState(): UiUrlState {
|
||||
: 'all',
|
||||
search: params.get('q') ?? '',
|
||||
sort: sort && SORT_MODES.includes(sort as SortMode) ? sort as SortMode : 'updated',
|
||||
scope: params.get('scope') === 'all' ? 'all' : 'mine',
|
||||
detailTab: detailTab && DETAIL_TABS.includes(detailTab as DetailTabId)
|
||||
? detailTab as DetailTabId
|
||||
: 'overview',
|
||||
@@ -150,6 +154,7 @@ export function buildUiUrlStateSearch(state: UiUrlState): string {
|
||||
if (state.status !== 'all') params.set('status', state.status);
|
||||
if (state.search) params.set('q', state.search);
|
||||
if (state.sort !== 'updated') params.set('sort', state.sort);
|
||||
if (state.scope === 'all') params.set('scope', 'all');
|
||||
if (state.detailTab !== 'overview') params.set('tab', state.detailTab);
|
||||
if (state.mobileTab !== 'chat') params.set('mobileTab', state.mobileTab);
|
||||
if (state.taskId) params.set('task', String(state.taskId));
|
||||
|
||||
Reference in New Issue
Block a user