ui phase 11

This commit is contained in:
hamid
2026-07-19 19:19:44 +03:30
parent b4b8c9ea79
commit 87fa4cd497
74 changed files with 3115 additions and 506 deletions
+1
View File
@@ -2,3 +2,4 @@ export * from './auth';
export * from './capabilities';
export * from './event';
export * from './layout';
export * from './useAdminListState';
+115
View File
@@ -0,0 +1,115 @@
import { renderHook, act } from '@testing-library/react';
const mockReplace = jest.fn();
const mockBack = jest.fn();
const mockPush = jest.fn();
let mockSearchParams = new URLSearchParams();
jest.mock('next/navigation', () => ({
useRouter: () => ({ replace: mockReplace, back: mockBack, push: mockPush }),
usePathname: () => '/fa/admin/tickets',
useSearchParams: () => mockSearchParams,
}));
import { useAdminListState, useAdminBackToList } from './useAdminListState';
interface Filters {
status?: string;
}
const CONFIG = {
parse: (params: URLSearchParams): Filters => ({ status: params.get('status') ?? undefined }),
serialize: (filters: Filters): Record<string, string> => (filters.status ? { status: filters.status } : {}),
empty: {} as Filters,
};
describe('useAdminListState', () => {
beforeEach(() => {
mockReplace.mockReset();
mockBack.mockReset();
mockPush.mockReset();
mockSearchParams = new URLSearchParams();
});
it('reads the initial applied filters + page from the URL', () => {
mockSearchParams = new URLSearchParams('status=open&page=3');
const { result } = renderHook(() => useAdminListState(CONFIG));
expect(result.current.applied).toEqual({ status: 'open' });
expect(result.current.draft).toEqual({ status: 'open' });
expect(result.current.page).toBe(3);
});
it('defaults to page 1 with empty filters when the URL carries none', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
expect(result.current.applied).toEqual({ status: undefined });
expect(result.current.page).toBe(1);
});
it('editing draft never touches applied or the URL', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.setDraft({ status: 'closed' }));
expect(result.current.draft).toEqual({ status: 'closed' });
expect(result.current.applied).toEqual({ status: undefined });
expect(mockReplace).not.toHaveBeenCalled();
});
it('apply commits draft into applied, resets to page 1, and writes the URL', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.setDraft({ status: 'closed' }));
act(() => result.current.apply());
expect(result.current.applied).toEqual({ status: 'closed' });
expect(result.current.page).toBe(1);
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=closed', { scroll: false });
});
it('clear resets draft + applied and writes a bare URL', () => {
mockSearchParams = new URLSearchParams('status=open');
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.clear());
expect(result.current.draft).toEqual({});
expect(result.current.applied).toEqual({});
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets', { scroll: false });
});
it('applyFilters commits an explicit value atomically (never a stale draft)', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
// No setDraft call first — applyFilters must not depend on draft having been set beforehand.
act(() => result.current.applyFilters({ status: 'closed' }));
expect(result.current.draft).toEqual({ status: 'closed' });
expect(result.current.applied).toEqual({ status: 'closed' });
expect(result.current.page).toBe(1);
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=closed', { scroll: false });
});
it('goToPage keeps applied filters and appends page to the URL', () => {
mockSearchParams = new URLSearchParams('status=open');
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.goToPage(2));
expect(result.current.page).toBe(2);
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=open&page=2', { scroll: false });
});
});
describe('useAdminBackToList', () => {
beforeEach(() => {
mockReplace.mockReset();
mockBack.mockReset();
mockPush.mockReset();
});
it('calls router.back() when there is browser history', () => {
Object.defineProperty(window, 'history', { value: { length: 3 }, configurable: true });
const { result } = renderHook(() => useAdminBackToList('/fa/admin/tickets'));
act(() => result.current());
expect(mockBack).toHaveBeenCalledTimes(1);
expect(mockPush).not.toHaveBeenCalled();
});
it('falls back to pushing the list href when there is no history', () => {
Object.defineProperty(window, 'history', { value: { length: 1 }, configurable: true });
const { result } = renderHook(() => useAdminBackToList('/fa/admin/tickets'));
act(() => result.current());
expect(mockPush).toHaveBeenCalledWith('/fa/admin/tickets');
expect(mockBack).not.toHaveBeenCalled();
});
});
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useCallback, useState, type Dispatch, type SetStateAction } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
/** The URL query-param name every admin/partner worklist uses for its page number. */
const PAGE_PARAM = 'page';
export interface AdminListStateConfig<F> {
/** Rebuild a filters object from the URL's current search params (only defined keys are read). */
parse: (params: URLSearchParams) => F;
/** Serialize a filters object to a plain string record, omitting empty/absent filters. */
serialize: (filters: F) => Record<string, string>;
/** The "clear"/default filters value. */
empty: F;
}
export interface AdminListState<F> {
/** Local, uncommitted filter edits — typing here never refetches or touches the URL. */
draft: F;
setDraft: Dispatch<SetStateAction<F>>;
/** The filters actually driving the query (and the URL) — set only by `apply`/`clear`. */
applied: F;
page: number;
/** Commits `draft` into `applied`, resets to page 1, and writes both into the URL. */
apply: () => void;
/**
* Commits an EXPLICIT filters value (not `draft`) into `applied` and the URL, resetting to page 1 — for a
* discrete control (a status tab/select) that should commit the instant it changes. Prefer this over
* `setDraft(next); apply()` in the same handler: `apply()` closes over the `draft` from the render it was
* created in, so calling it synchronously right after `setDraft` would commit the OLD value, not `next`.
* Also updates `draft` to the same value, so the two states never drift apart.
*/
applyFilters: (filters: F) => void;
/** Resets both draft and applied to `empty` and writes that (page 1) into the URL. */
clear: () => void;
/** Moves to a page, keeping `applied` filters, and writes it into the URL. */
goToPage: (page: number) => void;
}
/**
* URL-synced worklist state for the admin/partner consoles — mirrors **applied** filters + the current
* page into `searchParams` via `router.replace` (never a full navigation, `scroll: false`), so browser
* back/refresh/a pasted link all reproduce the exact same queue view. Draft filter state stays local per
* the established draft-vs-applied pattern (tickets/audit): typing in `draft` never refetches and never
* touches the URL — only `apply`/`clear`/`goToPage` do, mirroring `services/search/filterParams.ts`'s
* "the filter object is the query key" model onto every other worklist.
*
* The initial `applied`/`page` are read from the URL **once, on mount** — after that the URL only ever
* follows local state, so a user typing in `draft` can never have their input clobbered by a stale
* `searchParams` re-read. Callers using this hook must be rendered inside a `<Suspense>` boundary
* (`useSearchParams` requirement) — wrap the page body the way `SearchScreen`/`search/SearchScreen.tsx`
* does.
*/
export function useAdminListState<F>({
parse,
serialize,
empty,
}: AdminListStateConfig<F>): AdminListState<F> {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [initial] = useState<{ filters: F; page: number }>(() => {
const rawPage = Number(searchParams.get(PAGE_PARAM));
return {
filters: parse(searchParams),
page: Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1,
};
});
const [draft, setDraft] = useState<F>(initial.filters);
const [applied, setApplied] = useState<F>(initial.filters);
const [page, setPage] = useState<number>(initial.page);
const writeUrl = useCallback(
(filters: F, nextPage: number) => {
const params = new URLSearchParams(serialize(filters));
if (nextPage > 1) params.set(PAGE_PARAM, String(nextPage));
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
},
[pathname, router, serialize],
);
const apply = useCallback(() => {
setApplied(draft);
setPage(1);
writeUrl(draft, 1);
}, [draft, writeUrl]);
const applyFilters = useCallback(
(filters: F) => {
setDraft(filters);
setApplied(filters);
setPage(1);
writeUrl(filters, 1);
},
[writeUrl],
);
const clear = useCallback(() => {
setDraft(empty);
setApplied(empty);
setPage(1);
writeUrl(empty, 1);
}, [empty, writeUrl]);
const goToPage = useCallback(
(next: number) => {
setPage(next);
writeUrl(applied, next);
},
[applied, writeUrl],
);
return { draft, setDraft, applied, page, apply, applyFilters, clear, goToPage };
}
/**
* A detail page's "back" affordance: a real `router.back()` when there is browser history to return to
* (the common case — a queue row was clicked to get here, so back restores its filter/page/scroll state),
* falling back to pushing `listHref` when there isn't (a pasted/bookmarked detail link). `listHref` must
* already be locale-prefixed (`next/navigation`'s router does not add it) — pass
* `` `/${locale}${ROUTES.ADMIN_TICKETS}` ``.
*/
export function useAdminBackToList(listHref: string): () => void {
const router = useRouter();
return useCallback(() => {
if (typeof window !== 'undefined' && window.history.length > 1) {
router.back();
} else {
router.push(listHref);
}
}, [router, listHref]);
}