feat: apply across all pages

This commit is contained in:
Mohamed Marrouchi
2025-05-28 19:07:46 +01:00
parent 6b0ad29ee8
commit 7a4f8a7786
19 changed files with 236 additions and 101 deletions

View File

@@ -222,7 +222,7 @@ const AttachmentUploader: FC<FileUploadProps> = ({
{
defaultValues: { accept, onChange },
},
{ maxWidth: "xl" },
{ maxWidth: "xl", isSingleton: true },
)
}
>

View File

@@ -14,7 +14,7 @@ import {
InputAdornment,
TextFieldProps,
} from "@mui/material";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate";
@@ -37,30 +37,49 @@ export const FilterTextfield = ({
...props
}) => {
const { t } = useTranslate();
const [inputValue, setInputValue] = useState(defaultValue);
const ref = useRef<HTMLInputElement>(null);
const isTyping = useRef(false);
const toggleTyping = useMemo(
() =>
debounce((value: boolean) => {
isTyping.current = value;
}, delay * 2),
[delay],
);
const debouncedSearch = useMemo(
() =>
debounce((value: string) => {
onSearch?.(value);
toggleTyping(false);
}, delay),
// eslint-disable-next-line react-hooks/exhaustive-deps
[onSearch, delay],
);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setInputValue(value);
toggleTyping(true);
debouncedSearch(value);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[debouncedSearch],
);
const handleClear = useCallback(() => {
setInputValue("");
debouncedSearch("");
}, [debouncedSearch]);
useEffect(() => {
// Avoid infinite loop cycle (input => URL update => default value)
if (defaultValue !== ref.current?.value && !isTyping.current) {
ref.current && (ref.current.value = defaultValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);
return (
<Input
inputRef={ref}
InputProps={{
startAdornment: <Adornment Icon={SearchIcon} />,
endAdornment: clearable && (
@@ -73,7 +92,8 @@ export const FilterTextfield = ({
}}
placeholder={t("placeholder.keywords")}
{...props}
value={inputValue}
// value={inputValue}
defaultValue={defaultValue}
onChange={handleChange}
/>
);

View File

@@ -43,9 +43,12 @@ export const Categories = () => {
const { toast } = useToast();
const dialogs = useDialogs();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<ICategory>({
$iLike: ["label"],
});
const { onSearch, searchPayload, searchText } = useSearch<ICategory>(
{
$iLike: ["label"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.CATEGORY },
{
@@ -142,7 +145,7 @@ export const Categories = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.CATEGORY, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -40,9 +40,12 @@ export const ContentTypes = () => {
const router = useRouter();
const dialogs = useDialogs();
// data fetching
const { onSearch, searchPayload } = useSearch<IContentType>({
$iLike: ["name"],
});
const { onSearch, searchPayload, searchText } = useSearch<IContentType>(
{
$iLike: ["name"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.CONTENT_TYPE },
{
@@ -99,7 +102,7 @@ export const ContentTypes = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.CONTENT_TYPE, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -57,10 +57,13 @@ export const Contents = () => {
const queryClient = useQueryClient();
const dialogs = useDialogs();
// data fetching
const { onSearch, searchPayload } = useSearch<IContent>({
$eq: [{ entity: String(query.id) }],
$iLike: ["title"],
});
const { onSearch, searchPayload, searchText } = useSearch<IContent>(
{
$eq: [{ entity: String(query.id) }],
$iLike: ["title"],
},
{ syncUrl: true },
);
const hasPermission = useHasPermission();
const { dataGridProps } = useFind(
{ entity: EntityType.CONTENT, format: Format.FULL },
@@ -157,7 +160,7 @@ export const Contents = () => {
>
<Grid justifyContent="flex-end" gap={1} container alignItems="center">
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.CONTENT, PermissionAction.CREATE) ? (
<ButtonGroup sx={{ marginLeft: "auto" }}>

View File

@@ -43,9 +43,12 @@ export const ContextVars = () => {
const { toast } = useToast();
const dialogs = useDialogs();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<IContextVar>({
$iLike: ["label"],
});
const { onSearch, searchPayload, searchText } = useSearch<IContextVar>(
{
$iLike: ["label"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.CONTEXT_VAR },
{
@@ -176,7 +179,7 @@ export const ContextVars = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.CONTEXT_VAR, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -27,9 +27,12 @@ import { AssignedTo } from "./types";
export const Inbox = () => {
const { t } = useTranslate();
const { onSearch, searchPayload } = useSearch<ISubscriber>({
$or: ["first_name", "last_name"],
});
const { onSearch, searchPayload, searchText } = useSearch<ISubscriber>(
{
$or: ["first_name", "last_name"],
},
{ syncUrl: true },
);
const [channels, setChannels] = useState<string[]>([]);
const [assignment, setAssignment] = useState<AssignedTo>(AssignedTo.ALL);
@@ -48,13 +51,10 @@ export const Inbox = () => {
<MainContainer style={{ height: "100%" }}>
<Sidebar position="left">
<Grid paddingX={1} paddingTop={2} paddingBottom={1} mx={1}>
<FilterTextfield onChange={onSearch} defaultValue="" />
{/* <Search
onClearClick={() => onSearch("")}
className="changeColor"
onChange={(v) => onSearch(v)}
placeholder="Search..."
/> */}
<FilterTextfield
onChange={onSearch}
defaultValue={searchText}
/>
</Grid>
<Grid
display="flex"

View File

@@ -42,9 +42,12 @@ export const Labels = () => {
const { toast } = useToast();
const dialogs = useDialogs();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<ILabel>({
$or: ["name", "title"],
});
const { onSearch, searchPayload, searchText } = useSearch<ILabel>(
{
$or: ["name", "title"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.LABEL, format: Format.FULL },
{
@@ -173,7 +176,7 @@ export const Labels = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.LABEL, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -43,9 +43,12 @@ export const Languages = () => {
const dialogs = useDialogs();
const queryClient = useQueryClient();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<ILanguage>({
$or: ["title", "code"],
});
const { onSearch, searchPayload, searchText } = useSearch<ILanguage>(
{
$or: ["title", "code"],
},
{ syncUrl: true },
);
const { dataGridProps, refetch } = useFind(
{ entity: EntityType.LANGUAGE },
{
@@ -197,7 +200,7 @@ export const Languages = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.LANGUAGE, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -37,9 +37,13 @@ type MediaLibraryProps = {
export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => {
const { t } = useTranslate();
const formatFileSize = useFormattedFileSize();
const { onSearch, searchPayload } = useSearch<IAttachment>({
$iLike: ["name"],
});
const { onSearch, searchPayload, searchText } = useSearch<IAttachment>(
{
$iLike: ["name"],
},
// Sync URL only in the media library page (not the modal)
{ syncUrl: !onSelect },
);
const { dataGridProps } = useFind(
{ entity: EntityType.ATTACHMENT },
{
@@ -151,7 +155,7 @@ export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
</Grid>
</PageHeader>

View File

@@ -80,9 +80,12 @@ const NlpEntity = () => {
},
});
const [selectedNlpEntities, setSelectedNlpEntities] = useState<string[]>([]);
const { onSearch, searchPayload } = useSearch<INlpEntity>({
$or: ["name", "doc"],
});
const { onSearch, searchPayload, searchText } = useSearch<INlpEntity>(
{
$or: ["name", "doc"],
},
{ syncUrl: true },
);
const { dataGridProps: nlpEntityGrid } = useFind(
{
entity: EntityType.NLP_ENTITY,
@@ -224,7 +227,7 @@ const NlpEntity = () => {
flexShrink={0}
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.NLP_ENTITY, PermissionAction.CREATE) ? (

View File

@@ -86,13 +86,16 @@ export default function NlpSample() {
EntityType.NLP_SAMPLE_ENTITY,
);
const getLanguageFromCache = useGetFromCache(EntityType.LANGUAGE);
const { onSearch, searchPayload } = useSearch<INlpSample>({
$eq: [
...(type !== "all" ? [{ type }] : []),
...(language ? [{ language }] : []),
],
$iLike: ["text"],
});
const { onSearch, searchPayload, searchText } = useSearch<INlpSample>(
{
$eq: [
...(type !== "all" ? [{ type }] : []),
...(language ? [{ language }] : []),
],
$iLike: ["text"],
},
{ syncUrl: true },
);
const { mutate: deleteNlpSample } = useDelete(EntityType.NLP_SAMPLE, {
onError: () => {
toast.error(t("message.internal_server_error"));
@@ -317,6 +320,7 @@ export default function NlpSample() {
>
<FilterTextfield
onChange={onSearch}
defaultValue={searchText}
fullWidth={false}
sx={{ minWidth: "256px" }}
/>

View File

@@ -51,10 +51,13 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
entity: EntityType.NLP_ENTITY,
format: Format.FULL,
});
const { onSearch, searchPayload } = useSearch<INlpValue>({
$eq: [{ entity: entityId }],
$or: ["doc", "value"],
});
const { onSearch, searchPayload, searchText } = useSearch<INlpValue>(
{
$eq: [{ entity: entityId }],
$or: ["doc", "value"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.NLP_VALUE, format: Format.FULL },
{
@@ -228,7 +231,10 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
sx={{ width: "max-content", gap: 1 }}
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield
onChange={onSearch}
defaultValue={searchText}
/>
</Grid>
<ButtonGroup sx={{ marginLeft: "auto" }}>
{hasPermission(

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -40,9 +40,12 @@ export const Roles = () => {
const { toast } = useToast();
const dialogs = useDialogs();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<IRole>({
$iLike: ["name"],
});
const { onSearch, searchPayload, searchText } = useSearch<IRole>(
{
$iLike: ["name"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.ROLE },
{
@@ -140,7 +143,7 @@ export const Roles = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.ROLE, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -43,10 +43,13 @@ export const Subscribers = () => {
{ hasCount: false },
);
const [labelFilter, setLabelFilter] = useState<string>("");
const { onSearch, searchPayload } = useSearch<ISubscriber>({
$eq: labelFilter ? [{ labels: [labelFilter] }] : [],
$or: ["first_name", "last_name"],
});
const { onSearch, searchPayload, searchText } = useSearch<ISubscriber>(
{
$eq: labelFilter ? [{ labels: [labelFilter] }] : [],
$or: ["first_name", "last_name"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.SUBSCRIBER, format: Format.FULL },
{ params: searchPayload },
@@ -172,7 +175,11 @@ export const Subscribers = () => {
flexWrap="nowrap"
width="50%"
>
<FilterTextfield onChange={onSearch} fullWidth={true} />
<FilterTextfield
onChange={onSearch}
fullWidth={true}
defaultValue={searchText}
/>
<Input
select
label={t("label.labels")}

View File

@@ -44,9 +44,12 @@ export const Translations = () => {
hasCount: false,
},
);
const { onSearch, searchPayload } = useSearch<ITranslation>({
$iLike: ["str"],
});
const { onSearch, searchPayload, searchText } = useSearch<ITranslation>(
{
$iLike: ["str"],
},
{ syncUrl: true },
);
const { dataGridProps, refetch: refreshTranslations } = useFind(
{ entity: EntityType.TRANSLATION },
{
@@ -152,7 +155,7 @@ export const Translations = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
<Grid item>
<Button

View File

@@ -53,9 +53,12 @@ export const Users = () => {
},
});
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<IUser>({
$or: ["first_name", "last_name", "email"],
});
const { onSearch, searchPayload, searchText } = useSearch<IUser>(
{
$or: ["first_name", "last_name", "email"],
},
{ syncUrl: true },
);
const { data: roles } = useFind(
{
entity: EntityType.ROLE,
@@ -102,7 +105,7 @@ export const Users = () => {
headerName: t("label.name"),
sortable: false,
disableColumnMenu: true,
valueGetter: (params, val) => `${val.first_name} ${val.last_name}`,
valueGetter: (_params, val) => `${val.first_name} ${val.last_name}`,
headerAlign: "left",
renderHeader,
},
@@ -198,7 +201,7 @@ export const Users = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{!ssoEnabled &&
hasPermission(EntityType.USER, PermissionAction.CREATE) ? (

View File

@@ -14,6 +14,8 @@ import {
TParamItem,
} from "@/types/search.types";
import { useUrlQueryParam } from "./useUrlQueryParam";
const buildOrParams = <T,>({ params, searchText }: TBuildParamProps<T>) => ({
or: params?.map((field) => ({
[field]: { contains: searchText },
@@ -50,20 +52,38 @@ const buildNeqInitialParams = <T,>({
{},
);
export const useSearch = <T,>(params: TParamItem<T>) => {
const [searchText, setSearchText] = useState<string>("");
interface SearchHookOptions {
syncUrl?: boolean;
}
export const useSearch = <T,>(
params: TParamItem<T>,
options: SearchHookOptions = { syncUrl: false },
) => {
const { syncUrl } = options;
const [searchQuery, setSearchQuery] = useUrlQueryParam("search", "");
const [search, setSearch] = useState<string>("");
const {
$eq: eqInitialParams,
$iLike: iLikeParams,
$neq: neqInitialParams,
$or: orParams,
} = params;
const searchText = syncUrl ? searchQuery : search;
return {
searchText,
onSearch: (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string,
) => {
setSearchText(typeof e === "string" ? e : e.target.value);
const newValue =
typeof e === "object" ? e.target.value.toString() : e.toString();
if (syncUrl) {
setSearchQuery(newValue);
} else {
setSearch(newValue);
}
},
searchPayload: {
where: {

View File

@@ -9,9 +9,48 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
type QueryParamSerializer<T> = {
parse: (raw: string | string[] | undefined) => T;
stringify: (val: T) => string | string[] | undefined;
};
export function defaultSerializer<T = string>(): QueryParamSerializer<T> {
return {
parse: (raw) => {
if (Array.isArray(raw)) return raw[0] as unknown as T;
if (typeof raw === "undefined") return "" as unknown as T;
return raw as unknown as T;
},
stringify: (val) => val as unknown as string,
};
}
export function booleanSerializer(): QueryParamSerializer<boolean> {
return {
parse: (raw) => raw === "true",
stringify: (val) => (val ? "true" : "false"),
};
}
export function numberSerializer(): QueryParamSerializer<number> {
return {
parse: (raw) => Number(Array.isArray(raw) ? raw[0] : raw),
stringify: (val) => val.toString(),
};
}
export function arraySerializer(): QueryParamSerializer<string[]> {
return {
parse: (raw) => (Array.isArray(raw) ? raw : raw ? [raw] : []),
stringify: (val) => val,
};
}
export const useUrlQueryParam = <T>(
key: string,
defaultValue: T
key: string,
defaultValue: T,
serializer: QueryParamSerializer<T> = defaultSerializer(),
): [T, (val: T) => void] => {
const router = useRouter();
const [value, setValue] = useState<T>(() => {
@@ -21,7 +60,7 @@ export const useUrlQueryParam = <T>(
if (initial === undefined) return defaultValue;
// parse value if needed (e.g., numbers)
try {
return JSON.parse(initial as string) as T;
return serializer.parse(initial as string) as T;
} catch {
return initial as unknown as T;
}
@@ -35,7 +74,7 @@ export const useUrlQueryParam = <T>(
if (urlValue !== undefined) {
try {
parsedVal = JSON.parse(urlValue as string);
parsedVal = serializer.parse(urlValue as string);
} catch {
parsedVal = urlValue as unknown as T;
}
@@ -49,19 +88,24 @@ export const useUrlQueryParam = <T>(
}, [router.isReady, router.query[key]]);
// Update URL when state changes
const updateValue = useCallback((val: T) => {
debugger
setValue(val);
if (!router.isReady) return;
const newQuery = { ...router.query };
const updateValue = useCallback(
(val: T) => {
setValue(val);
if (!router.isReady) return;
const newQuery = { ...router.query };
if (val === defaultValue || val === undefined || val === '') {
delete newQuery[key];
} else {
newQuery[key] = typeof val === 'string' ? val : JSON.stringify(val);
}
router.push({ pathname: router.pathname, query: newQuery }, undefined, { shallow: true });
}, [router, key, defaultValue]);
if (val === defaultValue || val === undefined || val === "") {
delete newQuery[key];
} else {
newQuery[key] = serializer.stringify(val);
}
router.push({ pathname: router.pathname, query: newQuery }, undefined, {
shallow: true,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[router, key, defaultValue],
);
return [value, updateValue];
}
};