Merge pull request #369 from Hexastack/feat/nlp-select-pattern-v2

feat: new nlp pattern input
This commit is contained in:
Med Marrouchi 2024-11-27 09:43:56 +01:00 committed by GitHub
commit 4422b07bee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 363 additions and 115 deletions

View File

@ -456,7 +456,8 @@
"is_rtl": "RTL", "is_rtl": "RTL",
"original_text": "Original Text", "original_text": "Original Text",
"inputs": "Inputs", "inputs": "Inputs",
"outputs": "Outputs" "outputs": "Outputs",
"any": "- Any -"
}, },
"placeholder": { "placeholder": {
"your_username": "Your username", "your_username": "Your username",

View File

@ -456,8 +456,9 @@
"is_default": "Par Défaut", "is_default": "Par Défaut",
"is_rtl": "RTL", "is_rtl": "RTL",
"original_text": "Texte par défaut", "original_text": "Texte par défaut",
"inputs": "Port d'entré", "inputs": "Ports d'entrée",
"outputs": "Port de sorti" "outputs": "Ports de sortie",
"any": "- Toutes -"
}, },
"placeholder": { "placeholder": {
"your_username": "Votre nom d'utilisateur", "your_username": "Votre nom d'utilisateur",

View File

@ -0,0 +1,319 @@
/*
* Copyright © 2024 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 { Cancel } from "@mui/icons-material";
import {
Box,
Chip,
CircularProgress,
IconButton,
InputAdornment,
Skeleton,
Typography,
useTheme,
} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import { forwardRef, SyntheticEvent, useRef } from "react";
import { Input } from "@/app-components/inputs/Input";
import { useFind } from "@/hooks/crud/useFind";
import { useGetFromCache } from "@/hooks/crud/useGet";
import { useSearch } from "@/hooks/useSearch";
import { useTranslate } from "@/hooks/useTranslate";
import { EntityType, Format } from "@/services/types";
import { NlpPattern } from "@/types/block.types";
import { INlpEntity } from "@/types/nlp-entity.types";
import { INlpValue } from "@/types/nlp-value.types";
type NlpPatternSelectProps = {
patterns: NlpPattern[];
onChange: (patterns: NlpPattern[]) => void;
};
const NlpPatternSelect = (
{ patterns, onChange }: NlpPatternSelectProps,
ref,
) => {
const inputRef = useRef(null);
const theme = useTheme();
const { t } = useTranslate();
const { searchPayload } = useSearch<INlpEntity>({
$iLike: ["name"],
});
const { data: options, isLoading } = useFind(
{ entity: EntityType.NLP_ENTITY, format: Format.FULL },
{ hasCount: false, params: searchPayload },
);
const getNlpValueFromCache = useGetFromCache(EntityType.NLP_VALUE);
const handleNlpEntityChange = (
_event: SyntheticEvent<Element, Event>,
entities: INlpEntity[],
): void => {
const intersection = patterns.filter(({ entity: entityName }) =>
entities.find(({ name }) => name === entityName),
);
const additions = entities.filter(
({ name }) =>
!patterns.find(({ entity: entityName }) => name === entityName),
);
const newSelection = [
...intersection,
...additions.map(
({ name }) =>
({
entity: name,
match: "entity",
value: name,
} as NlpPattern),
),
];
onChange(newSelection);
};
const handleNlpValueChange = (
{ id, name }: Pick<INlpEntity, "id" | "name">,
valueId: string,
): void => {
const newSelection = patterns.slice(0);
const update = newSelection.find(({ entity: e }) => e === name);
if (!update) {
throw new Error("Unable to find nlp entity");
}
if (valueId === id) {
update.match = "entity";
update.value = name;
} else {
const value = getNlpValueFromCache(valueId);
if (!value) {
throw new Error("Unable to find nlp value in cache");
}
update.match = "value";
update.value = value.value;
}
onChange(newSelection);
};
if (!options.length) {
return (
<Skeleton animation="wave" variant="rounded" width="100%" height={56} />
);
}
const defaultValue =
options.filter(({ name }) =>
patterns.find(({ entity: entityName }) => entityName === name),
) || {};
return (
<Autocomplete
ref={ref}
size="medium"
disabled={options.length === 0}
value={defaultValue}
multiple={true}
options={options}
onChange={handleNlpEntityChange}
renderOption={(props, { name, doc }, { selected }) => (
<Box
component="li"
{...props}
p={2}
display="flex"
flexDirection="column"
style={{
alignItems: "start",
}}
sx={{
backgroundColor: selected
? theme.palette.action.selected
: "inherit",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
cursor: "pointer",
}}
>
<Typography variant="body1" fontWeight="bold">
{name}
</Typography>
{doc && (
<Typography variant="body2" color="textSecondary">
{doc}
</Typography>
)}
</Box>
)}
getOptionLabel={({ name }) => name}
isOptionEqualToValue={(option, value) => option.id === value.id}
freeSolo={false}
loading={isLoading}
renderTags={(entities, getTagProps) => {
return (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 0.5,
mx: "0.5rem",
}}
>
{entities.map(({ id, name, values }, index) => {
const { key, onDelete } = getTagProps({ index });
const nlpValues = values.map((vId) =>
getNlpValueFromCache(vId),
) as INlpValue[];
const selectedValue = patterns.find(
(e) => e.entity === name,
)?.value;
const { id: selectedId = id } =
nlpValues.find(({ value }) => value === selectedValue) || {};
return (
<Autocomplete
size="small"
value={selectedId}
options={[id].concat(values)}
multiple={false}
key={key}
getOptionLabel={(option) => {
const nlpValueCache = getNlpValueFromCache(option);
if (nlpValueCache) {
return nlpValueCache?.value;
}
if (option === id) {
return t("label.any");
}
return option;
}}
freeSolo={false}
disableClearable
popupIcon={false}
onChange={(e, valueId) =>
handleNlpValueChange({ id, name }, valueId)
}
sx={{
minWidth: 50,
".MuiAutocomplete-input": {
minWidth: "100px !important",
},
"& .MuiOutlinedInput-root": {
paddingRight: "2rem !important",
"&.MuiInputBase-sizeSmall": {
padding: "0 6px 0 0 !important",
},
},
}}
renderInput={(props) => (
<Input
{...props}
InputProps={{
...props.InputProps,
readOnly: true,
sx: {
padding: 0,
overflow: "hidden",
cursor: "pointer",
fontSize: "14px",
},
startAdornment: (
<InputAdornment position="start">
<Chip
sx={{
p: "0 0.3rem",
border: "none",
borderRadius: 0,
}}
color="primary"
label={name}
variant="role"
/>
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
{isLoading ? (
<CircularProgress color="inherit" size={20} />
) : (
<IconButton
sx={{ p: 0, pr: "2px" }}
onClick={(e) => {
onDelete(e);
onChange(
patterns.filter((p) => p.entity !== name),
);
}}
edge="end"
size="small"
>
<Cancel
sx={{
fontSize: "16px",
transition: ".05s",
"&:hover": {
color: theme.palette.grey[700],
},
}}
htmlColor={theme.palette.grey[500]}
/>
</IconButton>
)}
</InputAdornment>
),
}}
/>
)}
/>
);
})}
</Box>
);
}}
renderInput={(props) => (
<Input
{...props}
sx={{
"& .MuiOutlinedInput-root": {
paddingRight: "6px !important",
},
}}
size="small"
label={t("label.nlp")}
InputProps={{
...props.InputProps,
inputRef,
onClick: (event) => {
if (event.target !== inputRef.current) {
event.stopPropagation();
event.preventDefault();
}
},
endAdornment: isLoading ? (
<CircularProgress color="inherit" size={20} />
) : null,
}}
/>
)}
/>
);
};
NlpPatternSelect.displayName = "NlpPatternSelect";
export default forwardRef(NlpPatternSelect) as (
props: NlpPatternSelectProps & {
ref?: React.ForwardedRef<HTMLDivElement>;
},
) => ReturnType<typeof NlpPatternSelect>;

View File

@ -35,7 +35,7 @@ import { useTranslate } from "@/hooks/useTranslate";
import { EntityType } from "@/services/types"; import { EntityType } from "@/services/types";
import { OutgoingMessageFormat } from "@/types/message.types"; import { OutgoingMessageFormat } from "@/types/message.types";
import { IBlockAttributes, IBlock } from "../../types/block.types"; import { IBlock, IBlockAttributes } from "../../types/block.types";
import BlockFormProvider from "./form/BlockFormProvider"; import BlockFormProvider from "./form/BlockFormProvider";
import { MessageForm } from "./form/MessageForm"; import { MessageForm } from "./form/MessageForm";
@ -69,8 +69,7 @@ const BlockDialog: FC<BlockDialogProps> = ({
toast.success(t("message.success_save")); toast.success(t("message.success_save"));
}, },
}); });
const methods = useForm<IBlockAttributes>({ const DEFAULT_VALUES = {
defaultValues: {
name: block?.name || "", name: block?.name || "",
patterns: block?.patterns || [], patterns: block?.patterns || [],
trigger_labels: block?.trigger_labels || [], trigger_labels: block?.trigger_labels || [],
@ -83,10 +82,18 @@ const BlockDialog: FC<BlockDialogProps> = ({
limit: 2, limit: 2,
}, },
assignTo: block?.options?.assignTo, assignTo: block?.options?.assignTo,
fallback: block?.options?.fallback || {
active: true,
message: [],
max_attempts: 1,
},
}, },
assign_labels: block?.assign_labels || [], assign_labels: block?.assign_labels || [],
message: block?.message || [""], message: block?.message || [""],
}, capture_vars: block?.capture_vars || [],
} as IBlockAttributes;
const methods = useForm<IBlockAttributes>({
defaultValues: DEFAULT_VALUES,
}); });
const { const {
reset, reset,
@ -114,10 +121,8 @@ const BlockDialog: FC<BlockDialogProps> = ({
}, [open, reset]); }, [open, reset]);
useEffect(() => { useEffect(() => {
if (block) { if (block && open) {
reset({ reset(DEFAULT_VALUES);
name: block.name,
});
} else { } else {
reset(); reset();
} }

View File

@ -6,14 +6,14 @@
* 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 { Box, Grid, MenuItem, TextFieldProps, Typography } from "@mui/material"; import { Grid, MenuItem, TextFieldProps } from "@mui/material";
import { FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from "react";
import { RegisterOptions, useFormContext } from "react-hook-form"; import { RegisterOptions, useFormContext } from "react-hook-form";
import AutoCompleteEntitySelect from "@/app-components/inputs/AutoCompleteEntitySelect"; import AutoCompleteEntitySelect from "@/app-components/inputs/AutoCompleteEntitySelect";
import { Input } from "@/app-components/inputs/Input"; import { Input } from "@/app-components/inputs/Input";
import NlpPatternSelect from "@/app-components/inputs/NlpPatternSelect";
import { RegexInput } from "@/app-components/inputs/RegexInput"; import { RegexInput } from "@/app-components/inputs/RegexInput";
import { useGetFromCache } from "@/hooks/crud/useGet";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { EntityType, Format } from "@/services/types"; import { EntityType, Format } from "@/services/types";
import { import {
@ -25,8 +25,6 @@ import {
PayloadPattern, PayloadPattern,
} from "@/types/block.types"; } from "@/types/block.types";
import { IMenuItem } from "@/types/menu.types"; import { IMenuItem } from "@/types/menu.types";
import { INlpEntity } from "@/types/nlp-entity.types";
import { INlpValue } from "@/types/nlp-value.types";
import { ContentPostbackInput } from "./ContentPostbackInput"; import { ContentPostbackInput } from "./ContentPostbackInput";
import { PostbackInput } from "./PostbackInput"; import { PostbackInput } from "./PostbackInput";
@ -71,7 +69,7 @@ const PatternInput: FC<PatternInputProps> = ({
register, register,
formState: { errors }, formState: { errors },
} = useFormContext<IBlockAttributes>(); } = useFormContext<IBlockAttributes>();
const getNlpEntityFromCache = useGetFromCache(EntityType.NLP_ENTITY); // const getNlpEntityFromCache = useGetFromCache(EntityType.NLP_ENTITY);
const [pattern, setPattern] = useState<Pattern>(value); const [pattern, setPattern] = useState<Pattern>(value);
const [patternType, setPatternType] = useState<PatternType>(getType(value)); const [patternType, setPatternType] = useState<PatternType>(getType(value));
const types = [ const types = [
@ -147,83 +145,12 @@ const PatternInput: FC<PatternInputProps> = ({
</Input> </Input>
</Grid> </Grid>
<Grid item xs={9}> <Grid item xs={9}>
{patternType === "nlp" ? ( {patternType === "nlp" && (
<AutoCompleteEntitySelect<INlpValue, "value"> <NlpPatternSelect
value={(pattern as NlpPattern[]).map((v) => patterns={pattern as NlpPattern[]}
"value" in v && v.value ? v.value : v.entity, onChange={setPattern}
)}
searchFields={["value", "label"]}
entity={EntityType.NLP_VALUE}
format={Format.FULL}
idKey="value"
labelKey="value"
label={t("label.nlp")}
multiple={true}
onChange={(_e, data) => {
setPattern(
data.map((d) => {
const entity = getNlpEntityFromCache(d.entity) as INlpEntity;
return d.value === "any"
? {
match: "entity",
entity: entity.name,
}
: {
match: "value",
entity: entity.name,
value: d.value,
};
}),
);
}}
getOptionLabel={(option) => {
const entity = getNlpEntityFromCache(option.entity) as INlpEntity;
return `${entity.name}=${option.value}`;
}}
groupBy={(option) => {
const entity = getNlpEntityFromCache(option.entity) as INlpEntity;
return entity.name;
}}
renderGroup={(params) => (
<li key={params.key}>
<Typography
component="h4"
p={2}
fontWeight={700}
color="primary"
>
{params.group}
</Typography>
<Box>{params.children}</Box>
</li>
)}
preprocess={(options) => {
return options.reduce((acc, curr) => {
const entity = getNlpEntityFromCache(curr.entity) as INlpEntity;
if (entity.lookups.includes("keywords")) {
const exists = acc.find(
({ value, id }) => value === "any" && id === entity.id,
);
if (!exists) {
acc.push({
entity: entity.id,
id: entity.id,
value: "any",
} as INlpValue);
}
}
acc.push(curr);
return acc;
}, [] as INlpValue[]);
}}
/> />
) : null} )}
{patternType === "menu" ? ( {patternType === "menu" ? (
<AutoCompleteEntitySelect<IMenuItem, "title", false> <AutoCompleteEntitySelect<IMenuItem, "title", false>
value={pattern ? (pattern as PayloadPattern).value : null} value={pattern ? (pattern as PayloadPattern).value : null}

View File

@ -69,14 +69,9 @@ export interface PayloadPattern {
type?: PayloadType; type?: PayloadType;
} }
export type NlpPattern = export type NlpPattern = {
| {
entity: string; entity: string;
match: "entity"; match: "value" | "entity";
}
| {
entity: string;
match: "value";
value: string; value: string;
}; };