diff --git a/src/management-system-v2/app/(dashboard)/iam/roles/role-page.tsx b/src/management-system-v2/app/(dashboard)/iam/roles/role-page.tsx index aafad4618..4265cf9e2 100644 --- a/src/management-system-v2/app/(dashboard)/iam/roles/role-page.tsx +++ b/src/management-system-v2/app/(dashboard)/iam/roles/role-page.tsx @@ -3,7 +3,7 @@ import { FC, useState } from 'react'; import { DeleteOutlined } from '@ant-design/icons'; import { Tooltip, Space, Button, Result, Table, Popconfirm, App } from 'antd'; -import { useGetAsset, useDeleteAsset, ApiData } from '@/lib/fetch-data'; +import { useGetAsset, useDeleteAsset } from '@/lib/fetch-data'; import { CloseOutlined } from '@ant-design/icons'; import Content from '@/components/content'; import HeaderActions from './header-actions'; @@ -14,8 +14,6 @@ import Bar from '@/components/bar'; import { AuthCan } from '@/lib/clientAuthComponents'; import { useAbilityStore } from '@/lib/abilityStore'; -type Role = ApiData<'/roles', 'get'>[number]; - const RolesPage: FC = () => { const { message: messageApi } = App.useApp(); const ability = useAbilityStore((store) => store.ability); @@ -25,12 +23,17 @@ const RolesPage: FC = () => { onError: () => messageApi.open({ type: 'error', content: 'Something went wrong' }), }); - const { setSearchQuery, filteredData: filteredRoles } = useFuzySearch(roles || [], ['name'], { - useSearchParams: false, + const { setSearchQuery, filteredData: filteredRoles } = useFuzySearch({ + data: roles || [], + keys: ['name'], + highlightedKeys: ['name'], + transformData: (items) => + items.map((item) => ({ ...item.item, name: item.item.name.highlighted })), }); + type FilteredRole = (typeof filteredRoles)[number]; const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [selectedRow, setSelectedRows] = useState([]); + const [selectedRow, setSelectedRows] = useState([]); const cannotDeleteSelected = selectedRow.some( (role) => !ability.can('delete', toCaslResource('Role', role)), @@ -47,12 +50,16 @@ const RolesPage: FC = () => { title: 'Name', dataIndex: 'name', key: 'display', - render: (name: string, role: Role) => {name}, + render: (name: string, role: FilteredRole) => ( + + {name} + + ), }, { title: 'Members', dataIndex: 'members', - render: (_: any, record: Role) => record.members.length, + render: (_: any, record: FilteredRole) => record.members.length, key: 'username', }, { @@ -60,7 +67,7 @@ const RolesPage: FC = () => { key: 'tooltip', title: '', width: 100, - render: (id: string, role: Role) => + render: (id: string, role: FilteredRole) => selectedRowKeys.length === 0 ? ( @@ -112,9 +119,9 @@ const RolesPage: FC = () => { }} /> - dataSource={filteredRoles} + columns={columns} rowSelection={{ selectedRowKeys, onChange: (selectedRowKeys, selectedRows) => { diff --git a/src/management-system-v2/components/bpmn-viewer.tsx b/src/management-system-v2/components/bpmn-viewer.tsx index b76c5974f..57d29827c 100644 --- a/src/management-system-v2/components/bpmn-viewer.tsx +++ b/src/management-system-v2/components/bpmn-viewer.tsx @@ -14,16 +14,14 @@ const BPMNViewer = typeof window !== 'undefined' ? import('bpmn-js/lib/Viewer').then((mod) => mod.default) : null; type ViewerProps = { - selectedElement?: Process | undefined; + selectedElementId?: string; rerenderTrigger?: any; reduceLogo?: boolean; }; -const Viewer: FC = ({ selectedElement, rerenderTrigger, reduceLogo }) => { +const Viewer: FC = ({ selectedElementId, rerenderTrigger, reduceLogo }) => { const [initialized, setInitialized] = useState(false); - const { data: bpmn, isSuccess } = useProcessBpmn( - selectedElement ? selectedElement.definitionId : '', - ); + const { data: bpmn, isSuccess } = useProcessBpmn(selectedElementId ?? ''); const canvas = useRef(null); const previewer = useRef(null); @@ -42,18 +40,18 @@ const Viewer: FC = ({ selectedElement, rerenderTrigger, reduceLogo }, [bpmn]); useEffect(() => { - if (initialized && bpmn && selectedElement) { + if (initialized && bpmn && selectedElementId) { previewer.current!.importXML(bpmn).then(() => { (previewer.current!.get('canvas') as any).zoom('fit-viewport', 'auto'); }); } - }, [initialized, bpmn, selectedElement]); + }, [initialized, bpmn, selectedElementId]); useEffect(() => { - if (initialized && selectedElement) { + if (initialized && selectedElementId) { (previewer.current!.get('canvas') as any)?.zoom('fit-viewport', 'auto'); } - }, [initialized, rerenderTrigger, selectedElement]); + }, [initialized, rerenderTrigger, selectedElementId]); return (
[number]; + selectedElement?: ProcessListProcess; setOpen: (open: boolean) => void; }; @@ -81,7 +82,7 @@ const Preview: React.FC = ({ selectedElement, setOpen }} onMouseDown={handleMouseDown} >
- + ); diff --git a/src/management-system-v2/components/process-icon-list.tsx b/src/management-system-v2/components/process-icon-list.tsx index f81015c0e..9736319b2 100644 --- a/src/management-system-v2/components/process-icon-list.tsx +++ b/src/management-system-v2/components/process-icon-list.tsx @@ -4,19 +4,16 @@ import React, { Dispatch, FC, Key, SetStateAction } from 'react'; import TabCard from './tabcard-model-metadata'; -import { ApiData } from '@/lib/fetch-data'; import ScrollBar from './scrollbar'; - -type Processes = ApiData<'/process', 'get'>; +import { ProcessListProcess } from './processes'; type IconViewProps = { - data?: Processes; + data?: ProcessListProcess[]; selection: Key[]; setSelection: Dispatch>; - search?: string; }; -const IconView: FC = ({ data, selection, setSelection, search }) => { +const IconView: FC = ({ data, selection, setSelection }) => { return ( <> @@ -29,7 +26,7 @@ const IconView: FC = ({ data, selection, setSelection, search }) gridGap: '20px', }} > - {data?.map((item, i, arr) => ( + {data?.map((item) => ( = ({ data, selection, setSelection, search }) selection={selection} setSelection={setSelection} tabcard={false} - search={search} /> ))} diff --git a/src/management-system-v2/components/process-info-card.tsx b/src/management-system-v2/components/process-info-card.tsx index df8f65c45..8d200c6e6 100644 --- a/src/management-system-v2/components/process-info-card.tsx +++ b/src/management-system-v2/components/process-info-card.tsx @@ -1,6 +1,6 @@ 'use client'; -import { generateDateString, getPreferences, addUserPreference } from '@/lib/utils'; +import { generateDateString } from '@/lib/utils'; import { Card, Divider, Button } from 'antd'; import { DoubleRightOutlined, DoubleLeftOutlined } from '@ant-design/icons'; import React, { FC, Key, use, useCallback, useEffect, useState } from 'react'; @@ -8,11 +8,10 @@ import Viewer from './bpmn-viewer'; import classNames from 'classnames'; import { ApiData } from '@/lib/fetch-data'; import { useUserPreferences } from '@/lib/user-preferences'; - -type Processes = ApiData<'/process', 'get'>; +import { ProcessListProcess } from './processes'; type MetaDataType = { - data?: Processes; + data?: ProcessListProcess[]; selection: Key[]; triggerRerender?: () => void; }; @@ -103,7 +102,7 @@ const MetaData: FC = ({ data, selection, triggerRerender }) => { {selection.length - ? data?.find((item) => item.definitionId === selection[0])?.definitionName + ? data?.find((item) => item.definitionId === selection[0])?.definitionName.value : 'How to PROCEED?'} } @@ -119,7 +118,9 @@ const MetaData: FC = ({ data, selection, triggerRerender }) => { <> {showViewer && ( item.definitionId === selection[0])} + selectedElementId={ + data?.find((item) => item.definitionId === selection[0])?.definitionId + } reduceLogo={true} /> )} @@ -155,7 +156,7 @@ const MetaData: FC = ({ data, selection, triggerRerender }) => {
Description
-

{data?.find((item) => item.definitionId === selection[0])?.description}

+

{data?.find((item) => item.definitionId === selection[0])?.description.value}

Access Rights

diff --git a/src/management-system-v2/components/process-list.tsx b/src/management-system-v2/components/process-list.tsx index 24d5c472e..628742781 100644 --- a/src/management-system-v2/components/process-list.tsx +++ b/src/management-system-v2/components/process-list.tsx @@ -1,16 +1,6 @@ 'use client'; -import { - Button, - Checkbox, - Dropdown, - MenuProps, - Row, - Table, - TableColumnProps, - TableColumnsType, - Tooltip, -} from 'antd'; +import { Button, Checkbox, Dropdown, MenuProps, Row, Table, TableColumnsType, Tooltip } from 'antd'; import React, { useCallback, useState, @@ -30,7 +20,7 @@ import { MoreOutlined, } from '@ant-design/icons'; import { useRouter } from 'next/navigation'; -import { ColumnType, TableRowSelection } from 'antd/es/table/interface'; +import { TableRowSelection } from 'antd/es/table/interface'; import styles from './process-list.module.scss'; import { CheckboxChangeEvent } from 'antd/es/checkbox'; import Preview from './previewProcess'; @@ -39,17 +29,15 @@ import classNames from 'classnames'; import { generateDateString } from '@/lib/utils'; import ProcessEditButton from './process-edit-button'; import { toCaslResource } from '@/lib/ability/caslAbility'; -import { ApiData, useDeleteAsset, useInvalidateAsset, usePostAsset } from '@/lib/fetch-data'; +import { useDeleteAsset, useInvalidateAsset, usePostAsset } from '@/lib/fetch-data'; import { useUserPreferences } from '@/lib/user-preferences'; import ProcessDeleteSingleModal from './process-delete-single'; import { useAbilityStore } from '@/lib/abilityStore'; import { AuthCan } from '@/lib/clientAuthComponents'; - -type Processes = ApiData<'/process', 'get'>; -type Process = Processes[number]; +import { ProcessListProcess } from './processes'; type ProcessListProps = PropsWithChildren<{ - data?: Processes; + data?: ProcessListProcess[]; selection: Key[]; setSelection: Dispatch>; isLoading?: boolean; @@ -77,7 +65,6 @@ const ProcessList: FC = ({ setSelection, isLoading, onExportProcess, - search, setDeleteProcessIds, deleteProcessKeys, }) => { @@ -88,13 +75,13 @@ const ProcessList: FC = ({ const [previewerOpen, setPreviewerOpen] = useState(false); - const [hovered, setHovered] = useState(undefined); + const [hovered, setHovered] = useState(undefined); const [dropdownOpen, setDropdownOpen] = useState(false); const favourites = [0]; - const [previewProcess, setPreviewProcess] = useState(); + const [previewProcess, setPreviewProcess] = useState(); const lastProcessId = useLastClickedStore((state) => state.processId); const setLastProcessId = useLastClickedStore((state) => state.setProcessId); @@ -108,48 +95,6 @@ const ProcessList: FC = ({ const ability = useAbilityStore((state) => state.ability); - const clipAndHighlightText = useCallback( - (dataIndexElement: any) => { - const searchLower = search?.toLowerCase(); - const dataIndexElementLower = dataIndexElement?.toLowerCase(); - const withoutSearchTerm = dataIndexElementLower?.split(searchLower); - let res = dataIndexElement; - if (search && withoutSearchTerm?.length > 1) { - let lastIndex = 0; - res = withoutSearchTerm.map( - (word: string | any[], i: React.Key | null | undefined, arr: string | any[]) => { - const highlightedWord = dataIndexElement.slice(lastIndex, lastIndex + word.length); - lastIndex += word.length + search.length; - if (i === arr.length - 1) return highlightedWord; - - return ( - - {highlightedWord} - - {dataIndexElement.slice(lastIndex - search.length, lastIndex)} - - - ); - }, - ); - } - - return ( -
- {res} -
- ); - }, - [search], - ); - const { mutateAsync: createProcess } = usePostAsset('/process'); const { mutateAsync: deleteProcess } = useDeleteAsset('/process/{definitionId}', { @@ -157,7 +102,7 @@ const ProcessList: FC = ({ }); const actionBarGenerator = useCallback( - (record: Process) => { + (record: ProcessListProcess) => { return ( <> @@ -177,7 +122,7 @@ const ProcessList: FC = ({ bpmn: record.bpmn || '', variables: [ { - name: `${record.definitionName} Copy`, + name: `${record.definitionName.value} Copy`, type: '', }, ], @@ -245,22 +190,22 @@ const ProcessList: FC = ({ // rowSelection object indicates the need for row selection - const rowSelection: TableRowSelection = { + const rowSelection: TableRowSelection = { selectedRowKeys: selection, - onChange: (selectedRowKeys: React.Key[], selectedRows: Processes) => { + onChange: (selectedRowKeys: React.Key[]) => { setSelection(selectedRowKeys); }, - getCheckboxProps: (record: Processes[number]) => ({ + getCheckboxProps: (record: ProcessListProcess) => ({ name: record.definitionId, }), - onSelect: (record, selected, selectedRows, nativeEvent) => { + onSelect: (_, __, selectedRows) => { // setSelection(selectedRows); setSelection(selectedRows.map((row) => row.definitionId)); }, onSelectNone: () => { setSelection([]); }, - onSelectAll: (selected, selectedRows, changeRows) => { + onSelectAll: (_, selectedRows) => { // setSelection(selectedRows); setSelection(selectedRows.map((row) => row.definitionId)); }, @@ -296,13 +241,13 @@ const ProcessList: FC = ({ key: title, })); - const columns: TableColumnsType = [ + const columns: TableColumnsType = [ { title: , dataIndex: 'definitionId', key: '', width: '40px', - render: (definitionId, record, index) => + render: (definitionId, _, index) => favourites?.includes(index) ? ( ) : hovered?.definitionId === definitionId ? ( @@ -317,7 +262,7 @@ const ProcessList: FC = ({ dataIndex: 'definitionName', key: 'Process Name', className: styles.Title, - sorter: (a, b) => a.definitionName.localeCompare(b.definitionName), + sorter: (a, b) => a.definitionName.value.localeCompare(b.definitionName.value), onCell: (record, rowIndex) => ({ onClick: (event) => { // TODO: This is a hack to clear the parallel route when selecting @@ -328,13 +273,24 @@ const ProcessList: FC = ({ // router.push(`/processes/${record.definitionId}`); }, }), - render: clipAndHighlightText, + render: (_, record) => ( + + {record.definitionName.highlighted} + + ), }, { title: 'Description', dataIndex: 'description', key: 'Description', - sorter: (a, b) => a.description.localeCompare(b.description), + sorter: (a, b) => a.description.value.localeCompare(b.description.value), onCell: (record, rowIndex) => ({ // onClick: (event) => { // // TODO: This is a hack to clear the parallel route when selecting @@ -344,7 +300,18 @@ const ProcessList: FC = ({ // router.push(`/processes/${record.definitionId}`); // }, }), - render: clipAndHighlightText, + render: (_, record) => ( +
+ {record.description.highlighted} +
+ ), }, { diff --git a/src/management-system-v2/components/processes.tsx b/src/management-system-v2/components/processes.tsx index 1d62109ec..48cf685fe 100644 --- a/src/management-system-v2/components/processes.tsx +++ b/src/management-system-v2/components/processes.tsx @@ -31,28 +31,13 @@ import ProcessDeleteSingleModal from './process-delete-single'; import ProcessCopyModal from './process-copy'; import { copy } from 'fs-extra'; import { useAbilityStore } from '@/lib/abilityStore'; +import useFuzySearch, { ReplaceKeysWithHighlighted } from '@/lib/useFuzySearch'; type Processes = ApiData<'/process', 'get'>; -type Process = Processes[number]; - -export const fuseOptions = { - /* Option for Fuzzy-Search for Processlistfilter */ - /* https://www.fusejs.io/api/options.html#useextendedsearch */ - // isCaseSensitive: false, - // includeScore: false, - // shouldSort: true, - includeMatches: true, - findAllMatches: true, - // minMatchCharLength: 1, - // location: 0, - threshold: 0.75, - // distance: 100, - useExtendedSearch: true, - ignoreLocation: true, - // ignoreFieldNorm: false, - // fieldNormWeight: 1, - keys: ['definitionName', 'description'], -}; +export type ProcessListProcess = ReplaceKeysWithHighlighted< + Processes[number], + 'definitionName' | 'description' +>; type CopyProcessType = { bpmn: string; @@ -86,7 +71,6 @@ const Processes: FC = () => { data, isLoading, isError, - isSuccess, refetch: pullNewProcessData, } = useGetAsset('/process', { params: { @@ -167,23 +151,21 @@ const Processes: FC = () => { ); - const [searchTerm, setSearchTerm] = useState(''); + const { + filteredData, + searchQuery: searchTerm, + setSearchQuery: setSearchTerm, + } = useFuzySearch({ + data: data ?? [], + keys: ['definitionName', 'description'], + highlightedKeys: ['definitionName', 'description'], + transformData: (matches) => matches.map((match) => match.item), + }); const rerenderLists = () => { //setFilteredData(filteredData);, }; - const { data: filteredData } = useMemo(() => { - if (data && searchTerm !== '') { - const fuse = new Fuse(data, fuseOptions); - return { - data: fuse.search(searchTerm).map((item) => item.item), - highlight: fuse.search(searchTerm).map((item) => item.matches), - }; - } - return { data, highlight: [] }; - }, [data, searchTerm]); - const deselectAll = () => { setSelectedRowKeys([]); }; @@ -323,7 +305,6 @@ const Processes: FC = () => { data={filteredData} selection={selectedRowKeys} setSelection={setSelectedRowKeys} - search={searchTerm} /> ) : ( ; -type Process = Processes[number]; +import { ProcessListProcess } from './processes'; type TabCardProps = { - item: Process; + item: ProcessListProcess; selection: Key[]; setSelection: Dispatch>; tabcard?: boolean; - completeList: Processes; - search?: string; + completeList: ProcessListProcess[]; }; const tabList = [ @@ -43,9 +30,9 @@ const tabList = [ }, ]; -type Tab = (typeof tabList)[number]['key']; +type Tab = 'viewer' | 'meta'; // has to be defined manually, antdesign errors if tabList is defined 'as const' -const generateDescription = (data: Process) => { +const generateDescription = (data: ProcessListProcess) => { const { description, createdOn, lastEdited, owner } = data; const desc: DescriptionsProps['items'] = [ { @@ -66,18 +53,31 @@ const generateDescription = (data: Process) => { { key: `4`, label: 'Owner', - children: `${owner}`, + children: owner, }, { key: `5`, label: 'Description', - children: description.length > 20 ? `${description.slice(0, 21)} ...` : `${description}`, + children: ( + + {description.highlighted} + + ), }, ]; return desc; }; -const generateContentList = (data: Process, showViewer: boolean = true) => { +const generateContentList = (data: ProcessListProcess, showViewer: boolean = true) => { return { viewer: (
{ borderRadius: '8px', }} > - {showViewer && } + {showViewer && }
), meta: ( @@ -103,14 +103,7 @@ const generateContentList = (data: Process, showViewer: boolean = true) => { } as { [key in Tab]: ReactNode }; }; -const TabCard: FC = ({ - item, - selection, - setSelection, - tabcard, - completeList, - search, -}) => { +const TabCard: FC = ({ item, selection, setSelection, tabcard, completeList }) => { const router = useRouter(); const [activeTabKey, setActiveTabKey] = useState('viewer'); @@ -120,47 +113,10 @@ const TabCard: FC = ({ const lastProcessId = useLastClickedStore((state) => state.processId); const setLastProcessId = useLastClickedStore((state) => state.setProcessId); - const onTabChange = (key: Tab) => { - setActiveTabKey(key); + const onTabChange = (key: string) => { + setActiveTabKey(key as Tab); }; - const clipAndHighlightText = useCallback( - (dataIndexElement: any) => { - const withoutSearchTerm = dataIndexElement?.split(search); - let res = dataIndexElement; - if (search && withoutSearchTerm?.length > 1) { - res = withoutSearchTerm.map( - ( - word: - | string - | number - | boolean - | React.ReactElement> - | Iterable - | React.ReactPortal - | React.PromiseLikeOfReactNode - | null - | undefined, - i: React.Key | null | undefined, - arr: string | any[], - ) => { - if (i === arr.length - 1) return word; - - return ( - - {word} - {search} - - ); - }, - ); - } - - return
{res}
; - }, - [search], - ); - return ( = ({ title={
{/* {item?.definitionName} */} - {clipAndHighlightText(item?.definitionName)} + {item?.definitionName.highlighted}
columns={tableColums} - dataSource={filteredData.map((user) => ({ - ...user, - display: ( - - - {user.picture ? null : user.firstName.slice(0, 1) + user.lastName.slice(0, 1)} - - - {user.firstName} {user.lastName} - - - ), - }))} + dataSource={filteredData} rowSelection={{ selectedRowKeys, - onChange: (selectedRowKeys: React.Key[], selectedObjects: typeof users) => { + onChange: (selectedRowKeys: React.Key[], selectedObjects) => { setSelectedRowKeys(selectedRowKeys as string[]); setSelectedRows(selectedObjects); }, diff --git a/src/management-system-v2/lib/fetch-data.ts b/src/management-system-v2/lib/fetch-data.ts index 72f7ee232..758257a1f 100644 --- a/src/management-system-v2/lib/fetch-data.ts +++ b/src/management-system-v2/lib/fetch-data.ts @@ -12,6 +12,7 @@ import { FilterKeys, PathsWithMethod } from 'openapi-typescript-helpers'; import { paths } from './openapiSchema'; import { useCallback, useMemo } from 'react'; import { useCsrfTokenStore } from './csrfTokenStore'; +import { Prettify } from './typescript-utils'; const BASE_URL = process.env.API_URL; type Paths = paths extends Record ? paths : never; @@ -104,7 +105,6 @@ export const del: >( > > = addAuthHeaders(apiClient.DELETE); -type Prettify = T extends (infer L)[] ? Prettify[] : { [K in keyof T]: T[K] } & {}; type QueryData any> = Prettify< Extract>, { data: any }>['data'] >; diff --git a/src/management-system-v2/lib/typescript-utils.ts b/src/management-system-v2/lib/typescript-utils.ts new file mode 100644 index 000000000..9e65774dd --- /dev/null +++ b/src/management-system-v2/lib/typescript-utils.ts @@ -0,0 +1,7 @@ +/** + * A TypeScript type alias called `Prettify`. + * + * Returns the same type it was given, but the properties are not intersected. + * This means that the new type is easier to read and understand. + */ +export type Prettify = T extends (infer L)[] ? Prettify[] : { [K in keyof T]: T[K] } & {}; diff --git a/src/management-system-v2/lib/use-search-param-state.ts b/src/management-system-v2/lib/use-search-param-state.ts new file mode 100644 index 000000000..7bf0c7078 --- /dev/null +++ b/src/management-system-v2/lib/use-search-param-state.ts @@ -0,0 +1,58 @@ +/* + * For now this hook is needed, as router.replace causes a hard reload https://github.com/vercel/next.js/discussions/48110 + */ + +import { useEffect, useState } from 'react'; + +type ReplaceStateEvent = Event & { arguments: Parameters }; + +export function useSearchParamState(paramName: string): [string, (newValue: string) => void] { + // Get the initial value from the URL search parameter or use the provided initial value. + const initialQueryParam = + typeof window !== 'undefined' + ? new URLSearchParams(window.location.search).get(paramName) ?? '' + : ''; + + const [state, setState] = useState(initialQueryParam); + + useEffect(() => { + const replaceStateListener = (e: ReplaceStateEvent) => { + const argUrl = e.arguments[2] as string | URL | undefined; + if (!argUrl) return; + const url = new URL(argUrl, window.location.origin); + const searchParam = (url.searchParams.get(paramName) as string) ?? ''; + if (searchParam !== state) { + setState(searchParam); + } + }; + + window.addEventListener('replaceState', replaceStateListener as EventListener); + + return () => { + window.removeEventListener('replaceState', replaceStateListener as EventListener); + }; + }, [paramName, state]); + + // Function to update both state and URL search parameter without adding to history. + const updateState = (newValue: string) => { + setState(newValue); + + // Update the URL search parameter with the new value without modifying history. + const searchParams = new URLSearchParams(window.location.search); + if (newValue) { + searchParams.set(paramName, newValue); + } else { + searchParams.delete(paramName); + } + const newUrl = `${window.location.pathname}?${searchParams.toString()}`; + + // Replace the current URL without adding to the browser history. + window.history.replaceState( + { ...window.history.state, as: newUrl.toString(), url: newUrl.toString() }, + '', + newUrl, + ); + }; + + return [state, updateState]; +} diff --git a/src/management-system-v2/lib/useFuzySearch.ts b/src/management-system-v2/lib/useFuzySearch.ts deleted file mode 100644 index aa1495795..000000000 --- a/src/management-system-v2/lib/useFuzySearch.ts +++ /dev/null @@ -1,57 +0,0 @@ -import Fuse from 'fuse.js'; -import { useEffect, useMemo, useState } from 'react'; -import { usePathname, useSearchParams, useRouter } from 'next/navigation'; -import useDebounce from './useDebounce'; - -type FuzzySearchOptions = { useSearchParams: true; queryName: string } | { useSearchParams: false }; - -/** - * Handles state and search logic for fuzzy search. - * - * @warning useSearchParams: true makes the page flicker - * - */ -export default function useFuzySearch>( - data: TData[], - keys: (keyof TData)[], - fuzzySearchOptions: FuzzySearchOptions, - fuseOptions: ConstructorParameters>[1] = {}, -) { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const [searchQuery, setSearchQuery] = useState( - searchParams.get(fuzzySearchOptions.useSearchParams ? fuzzySearchOptions.queryName : '') ?? '', - ); - const debouncedSearchQuery = useDebounce(searchQuery, 200, fuzzySearchOptions.useSearchParams); - - useEffect(() => { - if (!fuzzySearchOptions.useSearchParams) return; - - const params = new URLSearchParams(searchParams); - params.set(fuzzySearchOptions.queryName, debouncedSearchQuery ?? ''); - - router.replace(pathname + '?' + params.toString(), { scroll: false }); - }, [debouncedSearchQuery, router, fuzzySearchOptions, pathname, searchParams]); - - const fuse = useMemo( - () => - new Fuse(data, { - findAllMatches: true, - threshold: 0.75, - useExtendedSearch: true, - ignoreLocation: true, - keys: keys as string[], - ...fuseOptions, - }), - [data, keys, fuseOptions], - ); - - const filteredData = useMemo(() => { - if (!searchQuery) return data; - return fuse.search(searchQuery).map((result) => result.item); - }, [fuse, searchQuery, data]); - - return { searchQuery, setSearchQuery, filteredData }; -} diff --git a/src/management-system-v2/lib/useFuzySearch.tsx b/src/management-system-v2/lib/useFuzySearch.tsx new file mode 100644 index 000000000..1e5019019 --- /dev/null +++ b/src/management-system-v2/lib/useFuzySearch.tsx @@ -0,0 +1,129 @@ +import Fuse from 'fuse.js'; +import { useMemo, useState, JSX } from 'react'; +import { Prettify } from './typescript-utils'; +import { useSearchParamState } from './use-search-param-state'; + +export type ReplaceKeysWithHighlighted< + TData extends Record, + TKeys extends keyof TData, +> = Prettify & Record>>>; + +function highlightText( + fuseElement: Fuse.FuseResult, + dataIndexElement: keyof TObj, + color: string = '#3e93de', +) { + const value = fuseElement.item[dataIndexElement] as string; + const matches = fuseElement.matches?.find((match) => match.key === dataIndexElement); + + if (!matches) return { highlighted: {value}, value }; + + const result: JSX.Element[] = []; + let lastIndex = 0; + for (const [start, end] of matches.indices) { + if (lastIndex < start) + result.push({value.slice(lastIndex, start)}); + + result.push( + + {value.slice(start, end + 1)} + , + ); + + lastIndex = end + 1; + } + if (lastIndex !== value.length) + result.push({value.slice(lastIndex)}); + + return { highlighted: {result}, value }; +} + +type UseFuzySearchOptions = { + data: TData[]; + /** Keys on which the search should be performed on */ + keys: TKeys[]; + /** Highlight keys in hook's output */ + highlightedKeys?: THighlightKeys[]; + /** Color of the highlighted letters, the default color is #3e93de*/ + highlightColor?: string; + /** Transfrom the hook's output (result is memoized) */ + transformData?: TTransformFunc; + /** If specified, the search query will be stored as a search param with the given name */ + queryName?: string; + fuseOptions?: ConstructorParameters>[1]; +}; + +export default function useFuzySearch< + TData extends Record, + TKeys extends keyof TData, + THighlightKeys extends TKeys, + TTransformFunc extends ( + items: Fuse.FuseResult>[], + ) => any = ( + items: Fuse.FuseResult>[], + ) => Fuse.FuseResult>[], +>({ + data, + keys, + fuseOptions, + queryName, + highlightedKeys, + highlightColor = '#3e93de', + transformData, +}: UseFuzySearchOptions) { + const searchParams = useSearchParamState(queryName ?? ''); + const state = useState(''); + + const [searchQuery, setSearchQuery] = queryName ? searchParams : state; + + const fuse = useMemo( + () => + new Fuse(data, { + findAllMatches: true, + threshold: 0.75, + useExtendedSearch: true, + ignoreLocation: true, + includeMatches: !!highlightedKeys, + keys: keys as string[], + ...fuseOptions, + }), + [data, keys, fuseOptions, highlightedKeys], + ); + + const filteredData: ReturnType = useMemo(() => { + let results; + + if (!searchQuery) + results = data.map((val, idx) => ({ + item: Object.assign(val, {}), + matches: [], + score: 1, + refIndex: idx, + })); + else results = fuse.search(searchQuery); + + // @ts-ignore + const highlightedResults = results.map((result) => { + if (highlightedKeys) { + // shallow copy of item, to avoid overwrithing data input + result.item = { ...result.item }; + + for (const highlightKey of highlightedKeys) { + // @ts-ignore + result.item[highlightKey] = highlightText(result, highlightKey, highlightColor); + } + } + + return result; + }) as Fuse.FuseResult>[]; + + if (transformData) return transformData(highlightedResults); + else return highlightedResults; + }, [data, fuse, searchQuery, transformData, highlightColor, highlightedKeys]); + + return { + searchQuery, + setSearchQuery, + filteredData, + }; +}