Загрузка данных
----Form.tsx
import { FieldValues, FormProvider, useForm } from "react-hook-form";
import { Button } from "@shared/ui";
import { FormAction, FormConfig } from "@shared/types/form";
import { Box } from "@mui/material";
import styles from "./Form.module.scss";
import { FormGroup } from "@shared/ui/components/form/form-group/FormGroup";
import { SimpleValueTag } from "../../../../../examples/object-details/SimpleValueTag";
import { SimpleValue } from "@lib/models/SimpleValue";
import {
AttributeDataDto,
AttributeValuesModifyRequest,
ObjectVedAddRequest,
} from "@gnr/api";
import {
NestedAttributeData,
RootAttributeData,
SimpleAttributeData,
} from "@lib/dtos/AttributeDataDto";
import { AttributeDataTag } from "@lib/tags/AttributeDataTag";
import { useEffect, useMemo, useState } from "react";
import { sortByFieldOrder } from "@shared/utils/sortByFieldOrder";
import { useRootAccordion } from "@shared/hooks";
import {
CommonFormObject,
CommonFormValuesType,
} from "../common-form-object/CommonFormObject";
import { useObjectGetById } from "@api";
import { collectUsedRoots, UsedRoot } from "@shared/utils/attributeUseValue";
export const Form = <T,>({
fields,
actions,
loading = false,
objectId,
expandAll,
setExpandAll,
type,
sectionRefs,
parentForm,
}: FormConfig<T>) => {
// eslint-disable-next-line no-undef
const currentForm = useForm({
mode: "onBlur",
shouldFocusError: true,
});
const form = parentForm ?? currentForm;
const {
handleSubmit,
formState: { isSubmitting, dirtyFields },
reset,
} = form;
const { data: objData } = useObjectGetById(objectId, type === "update");
const [usedRoots, setUsedRoots] = useState<UsedRoot[]>(() =>
type === "filter" || !fields?.length
? []
: collectUsedRoots(fields, type, objectId),
);
useEffect(() => {
if (type === "filter" || !fields?.length) {
setUsedRoots([]);
return;
}
setUsedRoots(collectUsedRoots(fields, type, objectId));
}, [fields, type, objectId]);
const [commonValues, setCommonValues] = useState<CommonFormValuesType>({
name: objData?.name ?? "",
imagePath: objData?.imagePath ?? "",
zone: null,
hideFilled: false,
});
useEffect(() => {
setCommonValues({
name: objData?.name ?? "",
imagePath: objData?.imagePath ?? "",
zone: null,
hideFilled: false,
});
}, [objData]);
const handleActionClick = (action: FormAction) => {
if (action.type === "submit") {
handleSubmit((data) => submitHandler(data, action))();
} else if (action.type === "reset") {
reset({});
action.onClick();
}
};
const submitHandler = (data: FieldValues, action: FormAction) => {
const changedFieldsIds = Object.keys(dirtyFields);
const transformedData = transformFormData(
fields,
data,
changedFieldsIds,
type,
objectId,
);
const attrVals = transformedData.attributeValues ?? [];
action.onClick([...attrVals, ...usedRoots], commonValues, data);
};
const sortedFields = useMemo(() => {
return fields?.slice()?.sort(sortByFieldOrder) || [];
}, [fields]);
const rootAccordionIds = useMemo(() => {
return sortedFields?.map((item) => item.id) || [];
}, [sortedFields]);
const { handleAccordionChange, isExpanded } = useRootAccordion({
rootAccordionIds,
expandAll,
setExpandAll,
});
return (
<Box className={styles.formWrap}>
<CommonFormObject
onValuesChange={setCommonValues}
commonValues={commonValues}
type={type}
/>
<FormProvider {...form}>
<form style={{ width: "100%" }}>
{sortedFields?.map((fieldGroup) => (
<div
ref={(el: HTMLDivElement | null) => {
sectionRefs.current[fieldGroup.id] = el;
}}
key={fieldGroup.name}
>
<FormGroup
fieldGroup={fieldGroup}
objectId={objectId}
expanded={isExpanded(fieldGroup.id)}
onExpandChange={handleAccordionChange(fieldGroup.id)}
setUsedRoots={setUsedRoots}
usedRoots={usedRoots}
zone={commonValues.zone}
type={type}
hideFilled={commonValues.hideFilled}
/>
</div>
))}
<Box className={styles.buttonsContainer}>
{actions.map((action, index) => (
<Button
key={index}
onClick={(e) => {
e.preventDefault();
handleActionClick(action);
}}
color={action.color ?? "primary"}
disabled={
loading ||
isSubmitting ||
(type !== "filter" && !commonValues.name)
}
label={loading || isSubmitting ? "Загрузка..." : action.label}
/>
))}
</Box>
</form>
</FormProvider>
</Box>
);
};
const getDefaultFormValue = (simple: SimpleAttributeData): unknown => {
const value = simple.value;
if (!value) return undefined;
return SimpleValueTag.matchWithArg<unknown>({
boolean: (v) => v.value,
string: (v) => v.value ?? "",
number: (v) => v.value,
nullableBool: (v) => v.value,
date: (v) => v.value,
htmlText: (v) => v.value ?? "",
file: (v) => v.value,
image: (v) => v.value,
money: (v) => v.value,
singleChoiceListItem: (v) => v.value.find((item) => item.isActive),
pluralChoiceListItem: (v) => v.value.filter((item) => item.isActive),
webLink: (v) => ({ url: v.url, name: v.name || "" }),
categoryLink: (v) => v.value,
linkedObjectList: (v) => v.value,
objectAttributeLink: (v) => v,
})(value);
};
const collectValues = (
groups: readonly AttributeDataDto[],
formData: FieldValues,
changedFieldsIds: string[],
type: "update" | "create" | "filter",
objectId: number,
) => {
const attributeValues: AttributeValuesModifyRequest[] = [];
groups.forEach((group) => {
AttributeDataTag.match({
root: () => {
const root = group as RootAttributeData;
attributeValues.push(
...collectValues(
root.children as readonly AttributeDataDto[],
formData,
changedFieldsIds,
type,
objectId,
),
);
},
simple: () => {
if (
(type === "update" ||
((type === "create" || type === "filter") && !objectId)) &&
!changedFieldsIds.includes(group.id.toString())
)
return;
const simple = group as SimpleAttributeData;
const formValue = formData[group.id];
const valueToParse =
formValue !== undefined
? formValue
: type === "create" && objectId
? getDefaultFormValue(simple)
: formValue;
const parsedValue = parseFormValue(valueToParse, simple);
attributeValues.push({
attributeId: group.id,
showInTable: group.showInTable,
useForComparison: group.useForComparison,
value: SimpleValue.toDto(parsedValue),
});
},
nested: () => {
const nested = group as NestedAttributeData;
attributeValues.push(
...collectValues(
nested.children as readonly AttributeDataDto[],
formData,
changedFieldsIds,
type,
objectId,
),
);
},
})(group.tag);
});
return attributeValues;
};
const transformFormData = (
fields: readonly AttributeDataDto[],
formData: FieldValues,
changedFieldsIds: string[],
type: "update" | "create" | "filter",
objectId: number,
): Pick<ObjectVedAddRequest, "attributeValues"> => {
const attributeValues: AttributeValuesModifyRequest[] = collectValues(
fields || [],
formData,
changedFieldsIds,
type,
objectId,
);
return { attributeValues };
};
const parseFormValue = (value: any, group: SimpleAttributeData) => {
const simple = group.value;
const parseParams = SimpleValueTag.matchWithArg({
boolean: () => ({ tag: "native", value: value ?? null }),
string: () => ({ tag: "native", value: value ? String(value) : "" }),
number: () => ({ tag: "native", value: value ? String(value) : "0" }),
nullableBool: () => ({ tag: "native", value: value ?? null }),
date: () => ({ tag: "native", value: value ? String(value) : null }),
htmlText: () => ({ tag: "native", value: value ? String(value) : "" }),
file: () => ({
tag: "native",
value: value ? String(value) : "no-file.jpg",
}),
image: () => ({
tag: "native",
value: value ? String(value) : "no-image.jpg",
}),
money: () => ({ tag: "native", value: value ? String(value) : "0" }),
singleChoiceListItem: () => ({ tag: "id", ...value }),
webLink: () => ({
tag: "webLink",
url: value.url || "https://no-url.com",
name: value.name,
}),
pluralChoiceListItem: () => ({
tag: "ids",
ids: value?.map((f: { id: number }) => f.id) ?? [],
}),
categoryLink: () => ({
tag: "attributeAndCategory",
attributeId: group.id,
categoryId: value.categoryId ?? null,
}),
linkedObjectList: () => ({
tag: "attributeAndObjectList",
items: Array.isArray(value) ? value : [value],
}),
objectAttributeLink: () => ({
tag: "objectAttributeLink",
attributeId: value.attributeId,
linkObjectId: value.linkObjectId,
linkAttributeId: value.linkAttributeId,
link: {
tag: value.link.tag,
},
}),
})(simple);
// @ts-ignore
return SimpleValueTag.parse(group.value.tag)(parseParams);
};
----FormGroup.tsx
import React, { FC, useMemo, useCallback, useState } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Typography,
} from "@mui/material";
import { Switch } from "@components";
import styles from "./FormGroup.module.scss";
import { FormField } from "@shared/ui/components/form/form-field/FormField";
import { ExpandMore } from "@mui/icons-material";
import { AttributeDataTag } from "../../../../../examples/object-details/AttributeDataTag";
import { sortByFieldOrder } from "@shared/utils/sortByFieldOrder";
import { AttributeDataDto } from "@gnr/api";
import {
NestedAttributeData,
RootAttributeData,
} from "@lib/dtos/AttributeDataDto.ts";
import { SimpleValueTag } from "@lib/tags/SimpleValueTag";
import {
isUsedRootChecked,
toggleUsedRoot,
UsedRoot,
} from "@shared/utils/attributeUseValue";
type FormGroupType = "filter" | "update" | "create";
type Props = {
readonly fieldGroup: AttributeDataDto;
readonly objectId: number;
readonly expanded?: boolean;
readonly onExpandChange?: (
event: React.SyntheticEvent,
isExpanded: boolean,
) => void;
readonly setUsedRoots?: React.Dispatch<React.SetStateAction<UsedRoot[]>>;
readonly usedRoots?: UsedRoot[];
readonly zone: number | null;
readonly type: FormGroupType;
readonly hideFilled?: boolean;
};
export const FormGroup: React.FC<Props> = ({
fieldGroup,
objectId,
expanded,
onExpandChange,
setUsedRoots,
usedRoots,
zone,
type,
hideFilled,
}) => {
const group = useMemo(
() =>
groupGeneration({
objectId,
expanded,
onExpandChange,
setUsedRoots,
usedRoots,
zone,
type,
fieldGroup,
hideFilled,
}),
[
expanded,
fieldGroup,
objectId,
onExpandChange,
setUsedRoots,
usedRoots,
type,
zone,
hideFilled,
],
);
const hasVisibleContent = useMemo(() => {
const checkVisibleChildren = (group: AttributeDataDto): boolean => {
if (
zone !== null &&
group.tag === "simple" &&
group.responsibilityZone !== zone
) {
return false;
}
if (group.tag === "simple" && hideFilled) {
return AttributeDataTag.matchWithArg({
root: () => true,
nested: () => true,
simple: (v) => {
return SimpleValueTag.matchWithArg({
boolean: (v) => !v.value,
string: (v) => !v.value,
number: (v) => !v.value,
nullableBool: (v) => !v.value,
file: (v) => !v.value,
webLink: (v) => !v.url || !v.name,
image: (v) => !v.value,
money: (v) => !v.value,
date: (v) => !v.value,
htmlText: (v) => !v.value,
categoryLink: (v) => !v.value,
objectAttributeLink: (v) => !v.linkPath,
pluralChoiceListItem: (v) =>
v.value.every((val) => !val.isActive),
singleChoiceListItem: (v) =>
!v.value?.some((val) => val.isActive),
linkedObjectList: (v) => !v.value,
})(v.value);
},
})(group);
}
if (group.tag === "root" || group.tag === "nested") {
return (
// @ts-ignore
group.children?.some((child: AttributeDataDto) =>
checkVisibleChildren(child),
) || false
);
}
return true;
};
return checkVisibleChildren(fieldGroup);
}, [fieldGroup, zone, hideFilled]);
if (!hasVisibleContent) {
return null;
}
return (
<Box className={styles.formGroup}>
<Box className={styles.formFields}>{group}</Box>
</Box>
);
};
function groupGeneration({
objectId,
expanded,
onExpandChange,
setUsedRoots,
usedRoots,
zone,
type,
fieldGroup,
hideFilled,
}: Props) {
return AttributeDataTag.matchWithArg({
root: (v) => (
<RootComponent
key={v.name}
children={v.children}
name={v.name}
objectId={objectId}
expanded={expanded}
onExpandChange={onExpandChange}
v={v}
setUsedRoots={setUsedRoots}
usedRoots={usedRoots}
zone={zone}
type={type}
hideFilled={hideFilled}
/>
),
simple: (v) => {
return (
<FormField
key={v.name}
value={v.value}
name={v.name}
id={v.id}
objectId={objectId}
targetCategoryId={v.targetCategoryId}
type={type}
required={type !== "filter" ? (v.required ?? false) : false}
/>
);
},
nested: (v) => (
<NestedComponent
name={v.name}
children={v.children}
objectId={objectId}
zone={zone}
type={type}
v={v}
setUsedRoots={setUsedRoots}
usedRoots={usedRoots}
hideFilled={hideFilled}
/>
),
})(fieldGroup);
}
type RootProps = {
readonly name: string;
readonly children: AttributeDataDto[];
readonly objectId: number;
readonly expanded?: boolean;
readonly onExpandChange?: (
event: React.SyntheticEvent,
isExpanded: boolean,
) => void;
readonly v: RootAttributeData;
readonly setUsedRoots?: React.Dispatch<React.SetStateAction<UsedRoot[]>>;
readonly usedRoots?: UsedRoot[];
readonly zone: number | null;
readonly type: FormGroupType;
readonly hideFilled?: boolean;
};
const RootComponent: FC<RootProps> = ({
name,
children,
objectId,
expanded,
onExpandChange,
v,
setUsedRoots,
usedRoots,
zone,
type,
hideFilled,
}) => {
const sortedChildren = useMemo(() => {
const filtered =
zone !== null
? children?.filter((child) => {
if (child.tag === "simple") {
return child.responsibilityZone === zone;
}
return true;
}) || []
: children || [];
return filtered.slice().sort(sortByFieldOrder);
}, [children, zone]);
const isChecked = isUsedRootChecked(usedRoots, v.id);
const onSwitchUseRoot = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (setUsedRoots) {
setUsedRoots((prev) =>
toggleUsedRoot(prev, v.id, event.target.checked),
);
}
},
[setUsedRoots, v.id],
);
return (
<Accordion elevation={0} expanded={expanded} onChange={onExpandChange}>
<AccordionSummary
expandIcon={<ExpandMore />}
className={styles.titleBlock}
>
<Box className={styles.titleBlockWithSwitch}>
<Typography className={styles.title}>{name}</Typography>
{v.id !== 0 && type !== "filter" && (
<Switch
label="Использовать"
checked={isChecked}
onChange={onSwitchUseRoot}
onClick={(e) => e.stopPropagation()}
/>
)}
</Box>
</AccordionSummary>
<AccordionDetails className={styles.accordionDetail}>
{expanded && sortedChildren.length > 0
? sortedChildren.map((i) => (
<FormGroup
key={i.id}
fieldGroup={i}
objectId={objectId}
zone={zone}
type={type}
setUsedRoots={setUsedRoots}
usedRoots={usedRoots}
hideFilled={hideFilled}
/>
))
: null}
</AccordionDetails>
</Accordion>
);
};
type NestedProps = {
readonly name: string;
readonly children: AttributeDataDto[];
readonly objectId: number;
readonly zone: number | null;
readonly type: FormGroupType;
readonly v: NestedAttributeData;
readonly setUsedRoots?: React.Dispatch<React.SetStateAction<UsedRoot[]>>;
readonly usedRoots?: UsedRoot[];
readonly hideFilled?: boolean;
};
const NestedComponent: FC<NestedProps> = ({
name,
children,
objectId,
zone,
type,
v,
setUsedRoots,
usedRoots,
hideFilled,
}) => {
const [expanded, setExpanded] = useState(false);
const sortedChildren = useMemo(() => {
const filtered =
zone !== null
? children?.filter((child) => {
if (child.tag === "simple") {
return child.responsibilityZone === zone;
}
return true;
}) || []
: children || [];
return filtered.slice().sort(sortByFieldOrder);
}, [children, zone]);
const isChecked = isUsedRootChecked(usedRoots, v.id);
const onSwitchUseRoot = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (setUsedRoots) {
setUsedRoots((prev) =>
toggleUsedRoot(prev, v.id, event.target.checked),
);
}
},
[setUsedRoots, v.id],
);
return (
<Accordion
className={styles.toggleSection}
expanded={expanded}
onChange={(_, isExpanded) => setExpanded(isExpanded)}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box className={styles.titleBlockWithSwitch}>
<Typography className={styles.toggleSectionTitle}>{name}</Typography>
{v.id !== 0 && type !== "filter" && (
<Switch
label="Использовать"
checked={isChecked}
onChange={onSwitchUseRoot}
onClick={(e) => e.stopPropagation()}
/>
)}
</Box>
</AccordionSummary>
<AccordionDetails className={styles.toggleSectionContent}>
{expanded && sortedChildren.length > 0
? sortedChildren.map((i) => (
<FormGroup
key={i.id}
fieldGroup={i}
objectId={objectId}
zone={zone}
type={type}
setUsedRoots={setUsedRoots}
usedRoots={usedRoots}
hideFilled={hideFilled}
/>
))
: null}
</AccordionDetails>
</Accordion>
);
};
----index.ts
export * from "./RecursionUtils";
export * from "./sort";
export * from "./attributeUseValue";
----CategoryObjectCardInfoModalDetails.tsx
import * as React from "react";
import { SimpleComponent } from "../simple-component/SimpleComponent";
import { AttributeDataDto } from "@lib/dtos/AttributeDataDto";
import { AttributeDataTag } from "@lib/tags/AttributeDataTag";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
CircularProgress,
Typography,
} from "@mui/material";
import styles from "./CategoryObjectCardInfoModalDetails.module.scss";
import { ExpandMore } from "@mui/icons-material";
import clsx from "clsx";
import { useMemo, useState } from "react";
import { sortByFieldOrder } from "@shared/utils/sortByFieldOrder";
import { useRootAccordion } from "@shared/hooks";
import { isAttributeUsed } from "@shared/utils/attributeUseValue";
import { CommonDetails } from "./components/commonDetails/CommonDetails";
type Props = Readonly<{
readonly sectionRefs: React.RefObject<{
[key: number]: HTMLDivElement | null;
}>;
readonly id: number;
readonly expandAll: boolean;
readonly setExpandAll: React.Dispatch<React.SetStateAction<boolean>>;
dataWithLoading: { data?: readonly AttributeDataDto[]; isLoading?: boolean };
readonly title: string;
}>;
export const CategoryObjectCardInfoModalDetails: React.FC<Props> = ({
sectionRefs,
id,
expandAll,
setExpandAll,
dataWithLoading,
title,
}) => {
const { data, isLoading } = dataWithLoading;
const [responsibilityZone, setResponsibilityZone] = useState<number | null>(
null,
);
const sortedData = useMemo(() => {
return data?.slice()?.sort(sortByFieldOrder) || [];
}, [data]);
const isSwitchActive = (
item: AttributeDataDto,
isDirectChildOfRootId0 = false,
) => isAttributeUsed(item, isDirectChildOfRootId0) || item.id === 0;
const rootAccordionIds = useMemo(() => {
return sortedData?.map((item) => item.id) || [];
}, [sortedData]);
const { handleAccordionChange, isExpanded } = useRootAccordion({
rootAccordionIds,
expandAll,
setExpandAll,
});
const renderChildren = (
children: AttributeDataDto[],
level: number = 1,
zone: number | null = null,
isDirectChildOfRootId0 = false,
) => {
const filtered =
zone !== null
? children?.filter((child) => {
if (child.tag === "simple") {
return child.responsibilityZone === zone;
}
return true;
}) || []
: children || [];
const sortedChildren = filtered.slice().sort(sortByFieldOrder);
return sortedChildren.map((child) => (
<Box
key={child.id}
sx={{
flex: child.tag === "nested" ? "1 1 100%" : "1 1 50%",
maxWidth: child.tag === "nested" ? "100%" : "50%",
minWidth: 0,
}}
>
{AttributeDataTag.matchWithArg({
root: (v) => (
<RootComponent
name={v.name}
children={v.children}
expanded={isExpanded(v.id)}
onChange={handleAccordionChange(v.id)}
responsibilityZone={responsibilityZone}
rootId={v.id}
isSwitchActive={isSwitchActive(
v as AttributeDataDto,
isDirectChildOfRootId0,
)}
/>
),
simple: (v) => (
<Box className={styles.simpleComponent}>
<SimpleComponent name={v.name} value={v.value} level={level} />
</Box>
),
nested: (v) => (
<NestedComponent
name={v.name}
children={v.children}
level={level}
responsibilityZone={zone}
isSwitchActive={isSwitchActive(
v as AttributeDataDto,
isDirectChildOfRootId0,
)}
/>
),
})(child)}
</Box>
));
};
const RootComponent = ({
name,
children,
expanded,
onChange,
responsibilityZone,
rootId,
isSwitchActive,
}: {
name: string;
children: AttributeDataDto[];
expanded: boolean;
onChange: (event: React.SyntheticEvent, isExpanded: boolean) => void;
responsibilityZone: number | null;
rootId: number;
isSwitchActive: boolean;
}) => {
if (children.length === 0 && isSwitchActive) return;
return (
<Accordion elevation={0} expanded={expanded} onChange={onChange}>
<AccordionSummary
expandIcon={<ExpandMore />}
className={styles.titleBlock}
>
<Typography className={styles.title}>{name}</Typography>
</AccordionSummary>
<AccordionDetails>
{isSwitchActive ? (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
m: "0 16px",
}}
>
{renderChildren(children, 1, responsibilityZone, rootId === 0)}
</Box>
) : (
<Box
sx={{
m: "0 16px",
}}
>
<Typography>Нет</Typography>
</Box>
)}
</AccordionDetails>
</Accordion>
);
};
const NestedComponent = ({
name,
children,
level,
responsibilityZone,
isSwitchActive,
}: {
name: string;
children: AttributeDataDto[];
level: number;
responsibilityZone: number | null;
isSwitchActive: boolean;
}) => {
if (children.length === 0 && isSwitchActive) return;
return (
<Accordion className={styles.toggleSection}>
<AccordionSummary
expandIcon={<ExpandMore />}
className={clsx({ [styles.toggleSectionBackground]: level > 1 })}
>
<Typography className={styles.toggleSectionTitle}>{name}</Typography>
</AccordionSummary>
<AccordionDetails className={styles.toggleSectionContent}>
{isSwitchActive ? (
renderChildren(children, level + 1, responsibilityZone)
) : (
<Box>
<Typography>Нет</Typography>
</Box>
)}
</AccordionDetails>
</Accordion>
);
};
return (
<Box
className={clsx(styles.productDetails, {
[styles.isLoading]: isLoading,
})}
>
{isLoading ? (
<CircularProgress size={100} />
) : (
<>
<CommonDetails
id={id}
responsibilityZone={responsibilityZone}
setResponsibilityZone={setResponsibilityZone}
title={title}
/>
{sortedData?.map((item) => (
<div
key={item.id}
ref={(el: HTMLDivElement | null) => {
sectionRefs.current[item.id] = el;
}}
>
{AttributeDataTag.matchWithArg({
root: (v) => (
<RootComponent
name={v.name}
children={v.children}
expanded={isExpanded(v.id)}
onChange={handleAccordionChange(v.id)}
responsibilityZone={responsibilityZone}
rootId={v.id}
isSwitchActive={isSwitchActive(v as AttributeDataDto)}
/>
),
simple: (v) => (
<SimpleComponent name={v.name} value={v.value} level={1} />
),
nested: (v) => (
<NestedComponent
name={v.name}
children={v.children}
level={1}
responsibilityZone={responsibilityZone}
isSwitchActive={isSwitchActive(v as AttributeDataDto)}
/>
),
})(item)}
</div>
))}
</>
)}
</Box>
);
};
----AttributeDataDto.ts
import * as gnr from "@gnr/api";
import { AttributeDataTag } from "@lib/tags/AttributeDataTag.ts";
import { SimpleValue } from "@lib/models/SimpleValue.ts";
import { log } from "@logger";
import { getErrorMessage } from "@shared/utils/ErrorUtils.ts";
import { normalizeUseFlagValue } from "@shared/utils/attributeUseValue";
type AttributeDataBase<T extends AttributeDataTag> = {
readonly tag: T;
readonly id: number;
readonly displayOrder: number;
readonly name: string;
readonly showInTable: boolean;
readonly useForComparison: boolean;
readonly responsibilityZone: number | null;
readonly targetCategoryId: number | null;
};
export type RootAttributeData = AttributeDataBase<"root"> &
gnr.AttributeDataDtoOneOf & {
readonly children: AttributeDataDto[];
};
export type SimpleAttributeData = AttributeDataBase<"simple"> &
Omit<gnr.AttributeDataDtoOneOfTwo, "tag"> & {
readonly value: SimpleValue | null;
};
export type NestedAttributeData = AttributeDataBase<"nested"> &
gnr.AttributeDataDtoOneOfThree & {
readonly children: AttributeDataDto[];
};
type AttributeData =
| RootAttributeData
| SimpleAttributeData
| NestedAttributeData;
type AttributeDataDto = gnr.AttributeDataDto & AttributeData;
// eslint-disable-next-line no-redeclare
namespace AttributeDataDto {
export function parse(value: unknown): AttributeDataDto[] {
const dto: gnr.AttributeDataDto[] =
gnr.objectGetDetailsResponse.parse(value);
try {
return dto.map(parseItem);
} catch (e) {
const errorMessage = `En: 'w6qgTBPf', value: ${value}, error: ${getErrorMessage(e)}`;
const error = new Error(errorMessage);
log("ERROR", error);
throw error;
}
}
const parseItem = (dto: gnr.AttributeDataDto): AttributeDataDto => {
return AttributeDataTag.match({
root: () => {
return makeRoot(dto as gnr.AttributeDataDtoOneOf) as AttributeDataDto;
},
simple: () => {
return makeSimple(dto as gnr.AttributeDataDtoOneOfTwo);
},
nested: () => {
return makeNested(dto as gnr.AttributeDataDtoOneOfThree);
},
})(dto.tag);
};
function makeBase<A extends AttributeDataTag>(
tag: A,
dto: gnr.AttributeDataDto,
): AttributeDataBase<A> {
return {
...dto,
tag,
id: dto.id,
displayOrder: dto.displayOrder,
name: dto.name,
showInTable: dto.showInTable,
useForComparison: dto.useForComparison,
responsibilityZone: dto.responsibilityZone,
targetCategoryId: dto.targetCategoryId,
};
}
// eslint-disable-next-line no-unused-vars
type MakeRoot = (dto: gnr.AttributeDataDtoOneOf) => RootAttributeData;
const makeRoot: MakeRoot = (dto) => ({
...makeBase("root", dto),
children: dto.children.map(parseItem),
value: {
tag: "boolean",
value: normalizeUseFlagValue(dto.value),
},
required: dto.required,
});
// eslint-disable-next-line no-unused-vars
type MakeSimple = (dto: gnr.AttributeDataDtoOneOfTwo) => SimpleAttributeData;
const makeSimple: MakeSimple = (dto) => ({
...makeBase("simple", dto),
value: SimpleValue.parse(dto.value),
required: dto.required,
});
// eslint-disable-next-line no-unused-vars
type MakeNested = (
dto: gnr.AttributeDataDtoOneOfThree,
) => NestedAttributeData;
const makeNested: MakeNested = (dto) => ({
...makeBase("nested", dto),
children: dto.children.map(parseItem),
value: {
tag: "boolean",
value: normalizeUseFlagValue(dto.value),
},
required: dto.required,
});
}
export { AttributeDataDto };
----attributeUseValue.ts
import type {
NestedAttributeData,
RootAttributeData,
} from "@lib/dtos/AttributeDataDto.ts";
import { AttributeDataDto } from "@gnr/api";
import { AttributeDataTag } from "@lib/tags/AttributeDataTag";
export type UsedRoot = {
attributeId: number;
value: { tag: "boolean"; value: string };
};
type AttributeWithUseValue = RootAttributeData | NestedAttributeData;
const parseBooleanUseValue = (raw: unknown): boolean | null => {
if (raw == null) return null;
if (typeof raw === "boolean") return raw;
if (typeof raw === "string") {
const normalized = raw.toLowerCase();
if (normalized === "true") return true;
if (normalized === "false") return false;
return null;
}
if (typeof raw === "object") {
const obj = raw as Record<string, unknown>;
if (obj.tag === "boolean") {
return parseBooleanUseValue(obj.value);
}
if (obj.tag === "nullableBool" && typeof obj.nullableValue === "boolean") {
return obj.nullableValue;
}
if ("value" in obj) return parseBooleanUseValue(obj.value);
if (typeof obj.nullableValue === "boolean") return obj.nullableValue;
}
return null;
};
/** Нормализует флаг «Использовать» из API в "True" | "False" | null */
export const normalizeUseFlagValue = (
dtoValue: unknown,
): "True" | "False" | null => {
const parsed = parseBooleanUseValue(dtoValue);
if (parsed === null) return null;
return parsed ? "True" : "False";
};
export const getAttributeUseValue = (
type: "filter" | "update" | "create",
objectId: number,
attribute: AttributeWithUseValue,
isDirectChildOfRootId0 = false,
): "True" | "False" => {
if (type === "create" && !objectId) return "True";
const flag = normalizeUseFlagValue(attribute.value);
if (flag === "True" || flag === "False") return flag;
if (isDirectChildOfRootId0) return "True";
return "False";
};
export const isAttributeUsed = (
attribute: { value?: unknown },
isDirectChildOfRootId0 = false,
): boolean => {
const flag = normalizeUseFlagValue(attribute.value);
if (flag === "True") return true;
if (flag === "False") return false;
return isDirectChildOfRootId0;
};
export const toggleUsedRoot = (
prev: UsedRoot[],
attributeId: number,
checked: boolean,
): UsedRoot[] => {
const value = checked ? "True" : "False";
const entry: UsedRoot = {
attributeId,
value: { tag: "boolean", value },
};
const exists = prev.some((item) => item.attributeId === attributeId);
return exists
? prev.map((item) => (item.attributeId === attributeId ? entry : item))
: [...prev, entry];
};
export const isUsedRootChecked = (
usedRoots: UsedRoot[] | undefined,
attributeId: number,
): boolean =>
usedRoots?.find((item) => item.attributeId === attributeId)?.value.value ===
"True";
export const collectUsedRoots = (
groups: readonly AttributeDataDto[],
type: "update" | "create" | "filter",
objectId: number,
): UsedRoot[] => {
const result: UsedRoot[] = [];
const traverse = (
items: readonly AttributeDataDto[],
isDirectChildOfRootId0 = false,
) => {
items.forEach((group) => {
AttributeDataTag.match({
root: () => {
const root = group as RootAttributeData;
if (root.id !== 0) {
result.push({
attributeId: root.id,
value: {
tag: "boolean",
value: getAttributeUseValue(
type,
objectId,
root,
isDirectChildOfRootId0,
),
},
});
}
traverse(root.children ?? [], root.id === 0);
},
nested: () => {
const nested = group as NestedAttributeData;
if (nested.id !== 0) {
result.push({
attributeId: nested.id,
value: {
tag: "boolean",
value: getAttributeUseValue(
type,
objectId,
nested,
isDirectChildOfRootId0,
),
},
});
}
traverse(nested.children ?? [], false);
},
simple: () => {},
})(group.tag);
});
};
traverse(groups);
return result;
};