Merge pull request #1052 from Hexastack/fix/use-search-v3

Fix/use search v3
This commit is contained in:
Med Marrouchi 2025-05-29 15:26:51 +01:00 committed by GitHub
commit 74a12e1559
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 425 additions and 207 deletions

View File

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

View File

@ -1,31 +1,100 @@
/* /*
* 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: * 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. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import ClearIcon from "@mui/icons-material/Clear";
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import { TextFieldProps } from "@mui/material"; import {
debounce,
IconButton,
InputAdornment,
TextFieldProps,
} from "@mui/material";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { Adornment } from "./Adornment"; import { Adornment } from "./Adornment";
import { Input } from "./Input"; import { Input } from "./Input";
export const FilterTextfield = (props: TextFieldProps) => { export interface FilterTextFieldProps
const { t } = useTranslate(); extends Omit<TextFieldProps, "value" | "onChange"> {
onChange: (value: string) => void;
delay?: number;
clearable: boolean;
defaultValue?: string;
}
export const FilterTextfield = ({
onChange: onSearch,
defaultValue = "",
delay = 500,
clearable = true,
...props
}) => {
const { t } = useTranslate();
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;
toggleTyping(true);
debouncedSearch(value);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[debouncedSearch],
);
const handleClear = useCallback(() => {
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]);
//TODO: replace the native delete text button by a styled custom button
return ( return (
<Input <Input
type="search" inputRef={ref}
InputProps={{ InputProps={{
startAdornment: <Adornment Icon={SearchIcon} />, startAdornment: <Adornment Icon={SearchIcon} />,
endAdornment: clearable && (
<InputAdornment position="end" onClick={handleClear}>
<IconButton size="small" sx={{ marginRight: -1 }}>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
}} }}
placeholder={t("placeholder.keywords")} placeholder={t("placeholder.keywords")}
{...props} {...props}
// value={inputValue}
defaultValue={defaultValue}
onChange={handleChange}
/> />
); );
}; };

View File

@ -1,59 +1,40 @@
/* /*
* 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: * 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. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import React, { FC, SVGProps } from "react"; import { FC, SVGProps } from "react";
const NoDataIcon: FC<SVGProps<SVGSVGElement>> = ({ ...props }) => { const NoDataIcon: FC<SVGProps<SVGSVGElement>> = ({ width = 96, ...props }) => {
return ( return (
<svg <svg
height="150"
viewBox="0 0 184 152"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none"
width={width}
viewBox="0 0 452 257"
aria-hidden
focusable="false"
{...props} {...props}
> >
<title>No data</title> <path
<g fill="none" fillRule="evenodd"> className="no-rows-primary"
<g transform="translate(24 31.67)"> d="M348 69c-46.392 0-84 37.608-84 84s37.608 84 84 84 84-37.608 84-84-37.608-84-84-84Zm-104 84c0-57.438 46.562-104 104-104s104 46.562 104 104-46.562 104-104 104-104-46.562-104-104Z"
<ellipse
fillOpacity=".8"
fill="#F5F5F7"
cx="67.797"
cy="106.89"
rx="67.797"
ry="12.668"
/> />
<path <path
d="M122.034 69.674L98.109 40.229c-1.148-1.386-2.826-2.225-4.593-2.225h-51.44c-1.766 0-3.444.839-4.592 2.225L13.56 69.674v15.383h108.475V69.674z" className="no-rows-primary"
fill="#AEB8C2" d="M308.929 113.929c3.905-3.905 10.237-3.905 14.142 0l63.64 63.64c3.905 3.905 3.905 10.236 0 14.142-3.906 3.905-10.237 3.905-14.142 0l-63.64-63.64c-3.905-3.905-3.905-10.237 0-14.142Z"
/> />
<path <path
d="M101.537 86.214L80.63 61.102c-1.001-1.207-2.507-1.867-4.048-1.867H31.724c-1.54 0-3.047.66-4.048 1.867L6.769 86.214v13.792h94.768V86.214z" className="no-rows-primary"
fill="url(#linearGradient-1)" d="M308.929 191.711c-3.905-3.906-3.905-10.237 0-14.142l63.64-63.64c3.905-3.905 10.236-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142l-63.64 63.64c-3.905 3.905-10.237 3.905-14.142 0Z"
transform="translate(13.56)"
/> />
<path <path
d="M33.83 0h67.933a4 4 0 0 1 4 4v93.344a4 4 0 0 1-4 4H33.83a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z" className="no-rows-secondary"
fill="#F5F5F7" d="M0 10C0 4.477 4.477 0 10 0h380c5.523 0 10 4.477 10 10s-4.477 10-10 10H10C4.477 20 0 15.523 0 10ZM0 59c0-5.523 4.477-10 10-10h231c5.523 0 10 4.477 10 10s-4.477 10-10 10H10C4.477 69 0 64.523 0 59ZM0 106c0-5.523 4.477-10 10-10h203c5.523 0 10 4.477 10 10s-4.477 10-10 10H10c-5.523 0-10-4.477-10-10ZM0 153c0-5.523 4.477-10 10-10h195.5c5.523 0 10 4.477 10 10s-4.477 10-10 10H10c-5.523 0-10-4.477-10-10ZM0 200c0-5.523 4.477-10 10-10h203c5.523 0 10 4.477 10 10s-4.477 10-10 10H10c-5.523 0-10-4.477-10-10ZM0 247c0-5.523 4.477-10 10-10h231c5.523 0 10 4.477 10 10s-4.477 10-10 10H10c-5.523 0-10-4.477-10-10Z"
/> />
<path
d="M42.678 9.953h50.237a2 2 0 0 1 2 2V36.91a2 2 0 0 1-2 2H42.678a2 2 0 0 1-2-2V11.953a2 2 0 0 1 2-2zM42.94 49.767h49.713a2.262 2.262 0 1 1 0 4.524H42.94a2.262 2.262 0 0 1 0-4.524zM42.94 61.53h49.713a2.262 2.262 0 1 1 0 4.525H42.94a2.262 2.262 0 0 1 0-4.525zM121.813 105.032c-.775 3.071-3.497 5.36-6.735 5.36H20.515c-3.238 0-5.96-2.29-6.734-5.36a7.309 7.309 0 0 1-.222-1.79V69.675h26.318c2.907 0 5.25 2.448 5.25 5.42v.04c0 2.971 2.37 5.37 5.277 5.37h34.785c2.907 0 5.277-2.421 5.277-5.393V75.1c0-2.972 2.343-5.426 5.25-5.426h26.318v33.569c0 .617-.077 1.216-.221 1.789z"
fill="#DCE0E6"
/>
</g>
<path
d="M149.121 33.292l-6.83 2.65a1 1 0 0 1-1.317-1.23l1.937-6.207c-2.589-2.944-4.109-6.534-4.109-10.408C138.802 8.102 148.92 0 161.402 0 173.881 0 184 8.102 184 18.097c0 9.995-10.118 18.097-22.599 18.097-4.528 0-8.744-1.066-12.28-2.902z"
fill="#DCE0E6"
/>
<g transform="translate(149.65 15.383)" fill="#FFF">
<ellipse cx="20.654" cy="3.167" rx="2.849" ry="2.815" />
<path d="M5.698 5.63H0L2.898.704zM9.259.704h4.985V5.63H9.259z" />
</g>
</g>
</svg> </svg>
); );
}; };

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: * 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. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -73,6 +73,12 @@ export const DataGrid = <T extends GridValidRowModel = any>({
autoHeight={autoHeight} autoHeight={autoHeight}
disableRowSelectionOnClick={disableRowSelectionOnClick} disableRowSelectionOnClick={disableRowSelectionOnClick}
slots={slots} slots={slots}
slotProps={{
loadingOverlay: {
variant: "linear-progress",
noRowsVariant: "skeleton",
},
}}
showCellVerticalBorder={showCellVerticalBorder} showCellVerticalBorder={showCellVerticalBorder}
showColumnVerticalBorder={showColumnVerticalBorder} showColumnVerticalBorder={showColumnVerticalBorder}
sx={sx} sx={sx}

View File

@ -1,42 +1,46 @@
/* /*
* 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: * 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. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import { Grid, Typography } from "@mui/material"; import Box from "@mui/material/Box";
import { styled } from "@mui/material/styles";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import NoDataIcon from "../svg/NoDataIcon"; import NoDataIcon from "../svg/NoDataIcon";
const StyledGridOverlay = styled("div")(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
minHeight: "200px",
"& .no-rows-primary": {
fill: "#3D4751",
...theme.applyStyles("light", {
fill: "#AEB8C2",
}),
},
"& .no-rows-secondary": {
fill: "#1D2126",
...theme.applyStyles("light", {
fill: "#E8EAED",
}),
},
}));
export const NoDataOverlay = () => { export const NoDataOverlay = () => {
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
<Grid <StyledGridOverlay>
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
height: "fit-content",
gap: 1,
opacity: 0.5,
paddingY: 1,
}}
>
<NoDataIcon /> <NoDataIcon />
<Grid item> <Box sx={{ mt: 2 }}>{t("label.no_data")}</Box>
<Typography </StyledGridOverlay>
style={{
color: "text.secondary",
}}
>
{t("label.no_data")}
</Typography>
</Grid>
</Grid>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,6 @@ import { useTranslate } from "@/hooks/useTranslate";
import { Title } from "@/layout/content/Title"; import { Title } from "@/layout/content/Title";
import { EntityType, RouterType } from "@/services/types"; import { EntityType, RouterType } from "@/services/types";
import { normalizeDate } from "@/utils/date"; import { normalizeDate } from "@/utils/date";
import { extractQueryParamsUrl } from "@/utils/URL";
import { getAvatarSrc } from "../helpers/mapMessages"; import { getAvatarSrc } from "../helpers/mapMessages";
import { useChat } from "../hooks/ChatContext"; import { useChat } from "../hooks/ChatContext";
@ -33,8 +32,8 @@ export const SubscribersList = (props: {
searchPayload: any; searchPayload: any;
assignedTo: AssignedTo; assignedTo: AssignedTo;
}) => { }) => {
const { query, push } = useRouter(); const router = useRouter();
const subscriber = query.subscriber?.toString() || null; const subscriber = router.query.subscriber?.toString() || null;
const { apiUrl } = useConfig(); const { apiUrl } = useConfig();
const { t, i18n } = useTranslate(); const { t, i18n } = useTranslate();
const chat = useChat(); const chat = useChat();
@ -75,10 +74,17 @@ export const SubscribersList = (props: {
<Conversation <Conversation
onClick={() => { onClick={() => {
chat.setSubscriberId(subscriber.id); chat.setSubscriberId(subscriber.id);
push({ router.push(
pathname: `/${RouterType.INBOX}/subscribers/${subscriber.id}`, {
query: extractQueryParamsUrl(window.location.href), pathname: `/${RouterType.INBOX}/subscribers/[subscriber]`,
}); query: {
...router.query,
subscriber: subscriber.id,
},
},
undefined,
{ shallow: true },
);
}} }}
className="changeColor" className="changeColor"
key={subscriber.id} key={subscriber.id}

View File

@ -6,12 +6,13 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import { MainContainer, Search, Sidebar } from "@chatscope/chat-ui-kit-react"; import { MainContainer, Sidebar } from "@chatscope/chat-ui-kit-react";
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import { Grid, MenuItem } from "@mui/material"; import { Grid, MenuItem } from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import AutoCompleteEntitySelect from "@/app-components/inputs/AutoCompleteEntitySelect"; import AutoCompleteEntitySelect from "@/app-components/inputs/AutoCompleteEntitySelect";
import { FilterTextfield } from "@/app-components/inputs/FilterTextfield";
import { Input } from "@/app-components/inputs/Input"; import { Input } from "@/app-components/inputs/Input";
import { useSearch } from "@/hooks/useSearch"; import { useSearch } from "@/hooks/useSearch";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
@ -26,9 +27,12 @@ import { AssignedTo } from "./types";
export const Inbox = () => { export const Inbox = () => {
const { t } = useTranslate(); const { t } = useTranslate();
const { onSearch, searchPayload, searchText } = useSearch<ISubscriber>({ const { onSearch, searchPayload, searchText } = useSearch<ISubscriber>(
{
$or: ["first_name", "last_name"], $or: ["first_name", "last_name"],
}); },
{ syncUrl: true },
);
const [channels, setChannels] = useState<string[]>([]); const [channels, setChannels] = useState<string[]>([]);
const [assignment, setAssignment] = useState<AssignedTo>(AssignedTo.ALL); const [assignment, setAssignment] = useState<AssignedTo>(AssignedTo.ALL);
@ -46,13 +50,10 @@ export const Inbox = () => {
<Grid item width="100%" height="100%" overflow="hidden"> <Grid item width="100%" height="100%" overflow="hidden">
<MainContainer style={{ height: "100%" }}> <MainContainer style={{ height: "100%" }}>
<Sidebar position="left"> <Sidebar position="left">
<Grid paddingX={1} paddingTop={1}> <Grid paddingX={1} paddingTop={2} paddingBottom={1} mx={1}>
<Search <FilterTextfield
value={searchText} onChange={onSearch}
onClearClick={() => onSearch("")} defaultValue={searchText}
className="changeColor"
onChange={(v) => onSearch(v)}
placeholder="Search..."
/> />
</Grid> </Grid>
<Grid <Grid

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -51,10 +51,13 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
entity: EntityType.NLP_ENTITY, entity: EntityType.NLP_ENTITY,
format: Format.FULL, format: Format.FULL,
}); });
const { onSearch, searchPayload } = useSearch<INlpValue>({ const { onSearch, searchPayload, searchText } = useSearch<INlpValue>(
{
$eq: [{ entity: entityId }], $eq: [{ entity: entityId }],
$or: ["doc", "value"], $or: ["doc", "value"],
}); },
{ syncUrl: true },
);
const { dataGridProps } = useFind( const { dataGridProps } = useFind(
{ entity: EntityType.NLP_VALUE, format: Format.FULL }, { entity: EntityType.NLP_VALUE, format: Format.FULL },
{ {
@ -228,7 +231,10 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
sx={{ width: "max-content", gap: 1 }} sx={{ width: "max-content", gap: 1 }}
> >
<Grid item> <Grid item>
<FilterTextfield onChange={onSearch} /> <FilterTextfield
onChange={onSearch}
defaultValue={searchText}
/>
</Grid> </Grid>
<ButtonGroup sx={{ marginLeft: "auto" }}> <ButtonGroup sx={{ marginLeft: "auto" }}>
{hasPermission( {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: * 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. * 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 { toast } = useToast();
const dialogs = useDialogs(); const dialogs = useDialogs();
const hasPermission = useHasPermission(); const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<IRole>({ const { onSearch, searchPayload, searchText } = useSearch<IRole>(
{
$iLike: ["name"], $iLike: ["name"],
}); },
{ syncUrl: true },
);
const { dataGridProps } = useFind( const { dataGridProps } = useFind(
{ entity: EntityType.ROLE }, { entity: EntityType.ROLE },
{ {
@ -140,7 +143,7 @@ export const Roles = () => {
width="max-content" width="max-content"
> >
<Grid item> <Grid item>
<FilterTextfield onChange={onSearch} /> <FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid> </Grid>
{hasPermission(EntityType.ROLE, PermissionAction.CREATE) ? ( {hasPermission(EntityType.ROLE, PermissionAction.CREATE) ? (
<Grid item> <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: * 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. * 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 }, { hasCount: false },
); );
const [labelFilter, setLabelFilter] = useState<string>(""); const [labelFilter, setLabelFilter] = useState<string>("");
const { onSearch, searchPayload } = useSearch<ISubscriber>({ const { onSearch, searchPayload, searchText } = useSearch<ISubscriber>(
{
$eq: labelFilter ? [{ labels: [labelFilter] }] : [], $eq: labelFilter ? [{ labels: [labelFilter] }] : [],
$or: ["first_name", "last_name"], $or: ["first_name", "last_name"],
}); },
{ syncUrl: true },
);
const { dataGridProps } = useFind( const { dataGridProps } = useFind(
{ entity: EntityType.SUBSCRIBER, format: Format.FULL }, { entity: EntityType.SUBSCRIBER, format: Format.FULL },
{ params: searchPayload }, { params: searchPayload },
@ -172,7 +175,11 @@ export const Subscribers = () => {
flexWrap="nowrap" flexWrap="nowrap"
width="50%" width="50%"
> >
<FilterTextfield onChange={onSearch} fullWidth={true} /> <FilterTextfield
onChange={onSearch}
fullWidth={true}
defaultValue={searchText}
/>
<Input <Input
select select
label={t("label.labels")} label={t("label.labels")}

View File

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

View File

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

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: * 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. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -86,6 +86,7 @@ export const useFind = <
); );
} }
}, },
keepPreviousData: true,
...otherOptions, ...otherOptions,
}); });
const data = (ids || []) const data = (ids || [])
@ -99,7 +100,7 @@ export const useFind = <
dataGridProps: { dataGridProps: {
...dataGridPaginationProps, ...dataGridPaginationProps,
rows: data || [], rows: data || [],
loading: normalizedQuery.isLoading, loading: normalizedQuery.isLoading || normalizedQuery.isFetching,
}, },
}; };
}; };

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: * 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. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -74,6 +74,7 @@ export const useInfiniteFind = <
return result; return result;
}, },
keepPreviousData: true,
...(otherOptions || {}), ...(otherOptions || {}),
}); });

View File

@ -6,9 +6,7 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import { debounce } from "@mui/material"; import { ChangeEvent, useState } from "react";
import { useRouter } from "next/router";
import { ChangeEvent, useCallback, useEffect, useState } from "react";
import { import {
TBuildInitialParamProps, TBuildInitialParamProps,
@ -16,6 +14,8 @@ import {
TParamItem, TParamItem,
} from "@/types/search.types"; } from "@/types/search.types";
import { useUrlQueryParam } from "./useUrlQueryParam";
const buildOrParams = <T,>({ params, searchText }: TBuildParamProps<T>) => ({ const buildOrParams = <T,>({ params, searchText }: TBuildParamProps<T>) => ({
or: params?.map((field) => ({ or: params?.map((field) => ({
[field]: { contains: searchText }, [field]: { contains: searchText },
@ -52,49 +52,39 @@ const buildNeqInitialParams = <T,>({
{}, {},
); );
export const useSearch = <T,>(params: TParamItem<T>) => { interface SearchHookOptions {
const router = useRouter(); syncUrl?: boolean;
const [searchText, setSearchText] = useState<string>( }
(router.query.search as string) || "",
);
useEffect(() => { export const useSearch = <T,>(
if (router.query.search !== searchText) { params: TParamItem<T>,
setSearchText((router.query.search as string) || ""); options: SearchHookOptions = { syncUrl: false },
} ) => {
}, [router.query.search]); const { syncUrl } = options;
const [searchQuery, setSearchQuery] = useUrlQueryParam("search", "");
const updateQueryParams = useCallback( const [search, setSearch] = useState<string>("");
debounce(async (newSearchText: string) => {
await router.replace(
{
pathname: router.pathname,
query: { ...router.query, search: newSearchText || undefined },
},
undefined,
{ shallow: true },
);
}, 300),
[router],
);
const onSearch = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string,
) => {
const newSearchText = typeof e === "string" ? e : e.target.value;
setSearchText(newSearchText);
updateQueryParams(newSearchText);
};
const { const {
$eq: eqInitialParams, $eq: eqInitialParams,
$iLike: iLikeParams, $iLike: iLikeParams,
$neq: neqInitialParams, $neq: neqInitialParams,
$or: orParams, $or: orParams,
} = params; } = params;
const searchText = syncUrl ? searchQuery : search;
return { return {
searchText, searchText,
onSearch, onSearch: (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string,
) => {
const newValue =
typeof e === "object" ? e.target.value.toString() : e.toString();
if (syncUrl) {
setSearchQuery(newValue);
} else {
setSearch(newValue);
}
},
searchPayload: { searchPayload: {
where: { where: {
...buildEqInitialParams({ initialParams: eqInitialParams }), ...buildEqInitialParams({ initialParams: eqInitialParams }),

View File

@ -0,0 +1,119 @@
/*
* 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.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
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) => {
const value = Number(Array.isArray(raw) ? raw[0] : raw);
return isNaN(value) ? 0 : value;
},
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,
serializer: QueryParamSerializer<T> = defaultSerializer(),
): [T, (val: T) => void] => {
const router = useRouter();
const [value, setValue] = useState<T>(() => {
// On initial load, use query or default
const initial = router.query[key];
if (initial === undefined) return defaultValue;
// parse value if needed (e.g., numbers)
try {
return serializer.parse(initial) as T;
} catch {
return initial as unknown as T;
}
});
// Sync from URL to state on changes (including initial load when ready)
useEffect(() => {
if (!router.isReady) return;
const urlValue = router.query[key];
let parsedVal: T = defaultValue;
if (urlValue !== undefined) {
try {
parsedVal = serializer.parse(urlValue);
} catch {
parsedVal = urlValue as unknown as T;
}
} else {
parsedVal = defaultValue;
}
if (parsedVal !== value) {
setValue(parsedVal);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, router.query[key]]);
// Update URL when state changes
const updateValue = useCallback(
(val: T) => {
setValue(val);
if (!router.isReady) return;
const newQuery = { ...router.query };
if (
val === defaultValue ||
val === undefined ||
serializer.stringify(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];
};

View File

@ -6,8 +6,6 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import qs from "qs";
export const buildURL = (baseUrl: string, relativePath: string): string => { export const buildURL = (baseUrl: string, relativePath: string): string => {
try { try {
return new URL(relativePath).toString(); return new URL(relativePath).toString();
@ -39,12 +37,3 @@ export const isAbsoluteUrl = (value: string = ""): boolean => {
return false; return false;
} }
}; };
// todo: in the future we might need to extract this logic into a hook
export const extractQueryParamsUrl = (fullUrl: string): string => {
const extractedQueryParams = qs.parse(new URL(fullUrl).search, {
ignoreQueryPrefix: true,
});
return qs.stringify(extractedQueryParams);
};