feat: dnd for options in select editor

This commit is contained in:
2025-11-05 20:52:08 +04:00
parent 38ae35795b
commit d3270a3532
23 changed files with 279 additions and 215 deletions

View File

@ -0,0 +1,49 @@
"use client";
import React, { FC } from "react";
import { Drawer } from "@mantine/core";
import EditorBody from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/EditorBody";
import { SelectEditorContextProvider } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
import { DrawerProps } from "@/drawers/types";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { AttrSelectSchema, UpdateAttrSelectSchema } from "@/lib/client";
type Props = {
select: AttrSelectSchema;
onSelectChange: (
values: UpdateAttrSelectSchema,
onSuccess: () => void
) => void;
};
const AttrSelectEditorDrawer: FC<DrawerProps<Props>> = ({
onClose,
opened,
props,
}) => {
const isMobile = useIsMobile();
return (
<Drawer
size={isMobile ? "100%" : "30%"}
title={"Редактирование справочника"}
position={"left"}
onClose={onClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton
opened={opened}
trapFocus={false}
styles={{
body: {
display: "flex",
flexDirection: "column",
},
}}>
<SelectEditorContextProvider {...props}>
<EditorBody />
</SelectEditorContextProvider>
</Drawer>
);
};
export default AttrSelectEditorDrawer;

View File

@ -1,6 +1,6 @@
import { Button, Flex, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useSelectEditorContext } from "@/app/attributes/modals/AttrSelectEditorModal/contexts/SelectEditorContext";
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
import { UpdateAttrSelectSchema } from "@/lib/client";
const CommonInfoEditor = () => {

View File

@ -1,6 +1,6 @@
import { IconCheck } from "@tabler/icons-react";
import { Flex, TextInput } from "@mantine/core";
import { useSelectEditorContext } from "@/app/attributes/modals/AttrSelectEditorModal/contexts/SelectEditorContext";
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
@ -16,14 +16,20 @@ const CreateOptionButton = () => {
if (!isCreatingOption) {
return (
<InlineButton onClick={onStartCreating}>
Добавить опцию
</InlineButton>
<Flex flex={1}>
<InlineButton
fullWidth
onClick={onStartCreating}>
Добавить опцию
</InlineButton>
</Flex>
);
}
return (
<Flex gap={"xs"}>
<Flex
gap={"xs"}
flex={1}>
<TextInput
{...createOptionForm.getInputProps("name")}
flex={1}

View File

@ -0,0 +1,22 @@
import { Divider, Flex } from "@mantine/core";
import CommonInfoEditor from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/CommonInfoEditor";
import CreateOptionButton from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/CreateOptionButton";
import OptionsTable from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/OptionsTable";
const EditorBody = () => {
return (
<Flex
gap={"xs"}
direction={"column"}>
<CommonInfoEditor />
<Divider
label={"Опции"}
my={"xs"}
/>
<CreateOptionButton />
<OptionsTable />
</Flex>
);
};
export default EditorBody;

View File

@ -0,0 +1,82 @@
import React, { FC, ReactNode } from "react";
import { IconCheck, IconEdit, IconTrash } from "@tabler/icons-react";
import { Divider, Flex, Group, Stack, TextInput } from "@mantine/core";
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import { AttrOptionSchema } from "@/lib/client";
type Props = {
option: AttrOptionSchema;
renderDraggable?: (item: AttrOptionSchema) => ReactNode;
};
const OptionTableRow: FC<Props> = ({ option, renderDraggable }) => {
const {
optionsActions: {
onStartEditing,
onFinishEditing,
onDelete,
editingOptionsData,
setEditingOptionsData,
},
} = useSelectEditorContext();
const onChange = (
e: React.ChangeEvent<HTMLInputElement>,
optionId: number
) => {
setEditingOptionsData(prev => {
prev.set(optionId, e.currentTarget.value);
return new Map(prev);
});
};
return (
<Stack
gap={"xs"}
mt={"xs"}>
<Group
wrap={"nowrap"}
justify={"space-between"}>
<Group wrap={"nowrap"}>
{renderDraggable && renderDraggable(option)}
{editingOptionsData.has(option.id) ? (
<TextInput
value={editingOptionsData.get(option.id)}
onChange={e => onChange(e, option.id)}
/>
) : (
option.name
)}
</Group>
<Flex
justify={"center"}
gap={"xs"}>
{editingOptionsData.has(option.id) ? (
<ActionIconWithTip
onClick={() => onFinishEditing(option)}
tipLabel={"Сохранить"}>
<IconCheck />
</ActionIconWithTip>
) : (
<ActionIconWithTip
onClick={() => onStartEditing(option)}
tipLabel={"Редактировать"}>
<IconEdit />
</ActionIconWithTip>
)}
<ActionIconWithTip
color={"red"}
onClick={() => onDelete(option)}
tipLabel={"Удалить"}>
<IconTrash />
</ActionIconWithTip>
</Flex>
</Group>
<Divider />
</Stack>
);
};
export default OptionTableRow;

View File

@ -0,0 +1,38 @@
import React from "react";
import { IconGripVertical } from "@tabler/icons-react";
import { Box, Divider, Stack } from "@mantine/core";
import OptionTableRow from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/OptionTableRow";
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
import SortableDnd from "@/components/dnd/SortableDnd";
const OptionsTable = () => {
const { options } = useSelectEditorContext();
const { onDragEnd } = useSelectEditorContext();
const renderDraggable = () => (
<Box p={"xs"}>
<IconGripVertical />
</Box>
);
return (
<Stack gap={0}>
<Divider />
<SortableDnd
initialItems={options}
onDragEnd={onDragEnd}
renderItem={(item, renderDraggable) => (
<OptionTableRow
option={item}
renderDraggable={renderDraggable}
/>
)}
renderDraggable={renderDraggable}
dragHandleStyle={{ width: "auto" }}
vertical
/>
</Stack>
);
};
export default OptionsTable;

View File

@ -1,6 +1,6 @@
"use client";
import useAttrOptionsList from "@/app/attributes/modals/AttrSelectEditorModal/hooks/useAttrOptionsList";
import useAttrOptionsList from "@/app/attributes/drawers/AttrSelectEditorDrawer/hooks/useAttrOptionsList";
import {
AttrOptionSchema,
AttrSelectSchema,
@ -15,6 +15,7 @@ type SelectEditorContextState = {
onSelectChange: (values: UpdateAttrSelectSchema) => void;
options: AttrOptionSchema[];
optionsActions: OptionsActions;
onDragEnd: (itemId: number, newLexorank: string) => void;
};
type Props = {
@ -31,7 +32,7 @@ const useSelectEditorContextState = ({
}: Props): SelectEditorContextState => {
const { options, queryKey } = useAttrOptionsList({ selectId: select.id });
const optionsActions = useOptionsActions({ queryKey, select });
const optionsActions = useOptionsActions({ queryKey, select, options });
const onSelectChangeWithMsg = (values: UpdateAttrSelectSchema) => {
onSelectChange(values, () => {
@ -41,11 +42,16 @@ const useSelectEditorContextState = ({
});
};
const onDragEnd = (itemId: number, newLexorank: string) => {
optionsActions.onUpdate(itemId, { lexorank: newLexorank });
};
return {
select,
onSelectChange: onSelectChangeWithMsg,
options,
optionsActions,
onDragEnd,
};
};

View File

@ -1,3 +1,4 @@
import { LexoRank } from "lexorank";
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
import {
AttrOptionSchema,
@ -9,9 +10,12 @@ import {
deleteAttrOptionMutation,
updateAttrOptionMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { getNewLexorank } from "@/utils/lexorank/generation";
import { getMaxByLexorank } from "@/utils/lexorank/max";
type Props = {
queryKey: any[];
options: AttrOptionSchema[];
};
export type AttrOptionsCrud = {
@ -27,7 +31,10 @@ export type AttrOptionsCrud = {
onDelete: (option: AttrOptionSchema, onSuccess?: () => void) => void;
};
export const useAttrOptionsCrud = ({ queryKey }: Props): AttrOptionsCrud => {
export const useAttrOptionsCrud = ({
queryKey,
options,
}: Props): AttrOptionsCrud => {
return useCrudOperations<
AttrOptionSchema,
UpdateAttrOptionSchema,
@ -40,13 +47,21 @@ export const useAttrOptionsCrud = ({ queryKey }: Props): AttrOptionsCrud => {
update: updateAttrOptionMutation(),
delete: deleteAttrOptionMutation(),
},
getCreateEntity: data => ({
name: data.name!,
selectId: data.selectId!,
}),
getCreateEntity: data => {
const lastOption = getMaxByLexorank(options);
const newLexorank = getNewLexorank(
lastOption ? LexoRank.parse(lastOption.lexorank) : null
);
return {
name: data.name!,
selectId: data.selectId!,
lexorank: newLexorank.toString(),
};
},
getUpdateEntity: (old, update) => ({
...old,
name: update.name ?? old.name,
lexorank: update.lexorank ?? old.lexorank,
}),
getDeleteConfirmTitle: () => "Удаление опции",
});

View File

@ -4,6 +4,7 @@ import {
getAttrOptionsOptions,
getAttrOptionsQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
import { sortByLexorank } from "@/utils/lexorank/sort";
type Props = {
selectId: number;
@ -27,7 +28,7 @@ const useAttrOptionsList = ({ selectId }: Props) => {
};
return {
options: data?.items ?? [],
options: sortByLexorank(data?.items ?? []),
setOptions,
refetch,
queryKey,

View File

@ -1,16 +1,18 @@
import { Dispatch, SetStateAction, useState } from "react";
import { useForm, UseFormReturnType } from "@mantine/form";
import { useAttrOptionsCrud } from "@/app/attributes/modals/AttrSelectEditorModal/hooks/useAttrOptionsCrud";
import {
AttrOptionSchema,
AttrSelectSchema,
CreateAttrOptionSchema,
UpdateAttrOptionSchema,
} from "@/lib/client";
import { notifications } from "@/lib/notifications";
import { useAttrOptionsCrud } from "@/app/attributes/drawers/AttrSelectEditorDrawer/hooks/useAttrOptionsCrud";
type Props = {
queryKey: any[];
select: AttrSelectSchema;
options: AttrOptionSchema[];
};
export type OptionsActions = {
@ -22,10 +24,11 @@ export type OptionsActions = {
setEditingOptionsData: Dispatch<SetStateAction<Map<number, string>>>;
onStartEditing: (option: AttrOptionSchema) => void;
onFinishEditing: (option: AttrOptionSchema) => void;
onUpdate: (optionId: number, data: UpdateAttrOptionSchema) => void;
onDelete: (option: AttrOptionSchema) => void;
};
const useOptionsActions = ({ queryKey, select }: Props) => {
const useOptionsActions = ({ queryKey, select, options }: Props) => {
const [isCreatingOption, setIsCreatingOption] = useState<boolean>(false);
const [editingOptionsData, setEditingOptionsData] = useState<
Map<number, string>
@ -34,6 +37,7 @@ const useOptionsActions = ({ queryKey, select }: Props) => {
const createOptionForm = useForm<CreateAttrOptionSchema>({
initialValues: {
name: "",
lexorank: "",
selectId: select.id,
},
validate: {
@ -41,7 +45,7 @@ const useOptionsActions = ({ queryKey, select }: Props) => {
},
});
const optionCrud = useAttrOptionsCrud({ queryKey });
const optionCrud = useAttrOptionsCrud({ queryKey, options });
const onStartCreating = () => {
setIsCreatingOption(true);
@ -69,7 +73,7 @@ const useOptionsActions = ({ queryKey, select }: Props) => {
notifications.error({ message: "Название не может быть пустым" });
return;
}
optionCrud.onUpdate(option.id, { name: newName }, () => {
optionCrud.onUpdate(option.id, { ...option, name: newName }, () => {
notifications.success({ message: "Опция сохранена" });
setEditingOptionsData(prev => {
prev.delete(option.id);
@ -84,6 +88,8 @@ const useOptionsActions = ({ queryKey, select }: Props) => {
);
};
const onUpdate = optionCrud.onUpdate;
return {
isCreatingOption,
createOptionForm,
@ -94,6 +100,7 @@ const useOptionsActions = ({ queryKey, select }: Props) => {
onStartEditing,
onFinishEditing,
onDelete,
onUpdate,
};
};

View File

@ -1,4 +1,5 @@
import { modals } from "@mantine/modals";
import { useDrawersContext } from "@/drawers/DrawersContext";
import { useAttrSelectsCrud } from "@/hooks/cruds/useSelectsCrud";
import { AttrSelectSchema } from "@/lib/client";
@ -14,6 +15,7 @@ export type SelectsActions = {
const useSelectsActions = (props: Props): SelectsActions => {
const attrSelectsCrud = useAttrSelectsCrud(props);
const { openDrawer } = useDrawersContext();
const onCreate = () => {
modals.openContextModal({
@ -26,10 +28,9 @@ const useSelectsActions = (props: Props): SelectsActions => {
};
const onUpdate = (select: AttrSelectSchema) => {
modals.openContextModal({
modal: "attrSelectEditorModal",
title: "Редактирование справочника",
innerProps: {
openDrawer({
key: "attrSelectEditorDrawer",
props: {
onSelectChange: (values, onSuccess) =>
attrSelectsCrud.onUpdate(select.id, values, onSuccess),
select,

View File

@ -1,24 +0,0 @@
"use client";
import { ContextModalProps } from "@mantine/modals";
import EditorBody from "@/app/attributes/modals/AttrSelectEditorModal/components/EditorBody";
import { SelectEditorContextProvider } from "@/app/attributes/modals/AttrSelectEditorModal/contexts/SelectEditorContext";
import { AttrSelectSchema, UpdateAttrSelectSchema } from "@/lib/client";
type Props = {
select: AttrSelectSchema;
onSelectChange: (
values: UpdateAttrSelectSchema,
onSuccess: () => void
) => void;
};
const AttrSelectEditorModal = ({ innerProps }: ContextModalProps<Props>) => {
return (
<SelectEditorContextProvider {...innerProps}>
<EditorBody />
</SelectEditorContextProvider>
);
};
export default AttrSelectEditorModal;

View File

@ -1,20 +0,0 @@
import { Divider, Stack } from "@mantine/core";
import CommonInfoEditor from "@/app/attributes/modals/AttrSelectEditorModal/components/CommonInfoEditor";
import CreateOptionButton from "@/app/attributes/modals/AttrSelectEditorModal/components/CreateOptionButton";
import OptionsTable from "@/app/attributes/modals/AttrSelectEditorModal/components/OptionsTable";
const EditorBody = () => {
return (
<Stack gap={"xs"}>
<CommonInfoEditor />
<Divider
label={"Опции"}
my={"xs"}
/>
<CreateOptionButton />
<OptionsTable />
</Stack>
);
};
export default EditorBody;

View File

@ -1,38 +0,0 @@
import { IconMoodSad } from "@tabler/icons-react";
import { Group, Text } from "@mantine/core";
import { useSelectEditorContext } from "@/app/attributes/modals/AttrSelectEditorModal/contexts/SelectEditorContext";
import useOptionsTableColumns from "@/app/attributes/modals/AttrSelectEditorModal/hooks/useOptionsTableColumns";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import useIsMobile from "@/hooks/utils/useIsMobile";
const OptionsTable = () => {
const { options } = useSelectEditorContext();
const isMobile = useIsMobile();
const columns = useOptionsTableColumns();
return (
<BaseTable
withTableBorder
columns={columns}
records={options}
verticalSpacing={"md"}
emptyState={
<Group mt={options.length === 0 ? "xl" : 0}>
<Text>Нет опций</Text>
<IconMoodSad />
</Group>
}
groups={undefined}
styles={{
table: {
width: "100%",
minHeight: options.length === 0 ? "150px" : "auto",
},
header: { zIndex: 1 },
}}
mx={isMobile ? "xs" : 0}
/>
);
};
export default OptionsTable;

View File

@ -1,84 +0,0 @@
"use client";
import React, { useMemo } from "react";
import { IconCheck, IconEdit, IconTrash } from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable";
import { Center, Flex, TextInput } from "@mantine/core";
import { useSelectEditorContext } from "@/app/attributes/modals/AttrSelectEditorModal/contexts/SelectEditorContext";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { AttrOptionSchema } from "@/lib/client";
const useSelectsTableColumns = () => {
const isMobile = useIsMobile();
const {
optionsActions: {
onStartEditing,
onFinishEditing,
onDelete,
editingOptionsData,
setEditingOptionsData,
},
} = useSelectEditorContext();
const onChange = (
e: React.ChangeEvent<HTMLInputElement>,
optionId: number
) => {
setEditingOptionsData(prev => {
prev.set(optionId, e.currentTarget.value);
return new Map(prev);
});
};
return useMemo(
() =>
[
{
title: "Название опции",
accessor: "name",
render: option =>
editingOptionsData.has(option.id) ? (
<TextInput
value={editingOptionsData.get(option.id)}
onChange={e => onChange(e, option.id)}
/>
) : (
option.name
),
},
{
accessor: "actions",
title: <Center>Действия</Center>,
width: "0%",
render: option => (
<Flex gap={"xs"}>
{editingOptionsData.has(option.id) ? (
<ActionIconWithTip
onClick={() => onFinishEditing(option)}
tipLabel={"Сохранить"}>
<IconCheck />
</ActionIconWithTip>
) : (
<ActionIconWithTip
onClick={() => onStartEditing(option)}
tipLabel={"Редактировать"}>
<IconEdit />
</ActionIconWithTip>
)}
<ActionIconWithTip
color={"red"}
onClick={() => onDelete(option)}
tipLabel={"Удалить"}>
<IconTrash />
</ActionIconWithTip>
</Flex>
),
},
] as DataTableColumn<AttrOptionSchema>[],
[isMobile, editingOptionsData]
);
};
export default useSelectsTableColumns;

View File

@ -18,8 +18,8 @@ const BoardsMobileEditorDrawer: FC<DrawerProps<Props>> = ({
}) => {
return (
<Drawer
size={"100%"}
position={"right"}
size={"50%"}
position={"left"}
onClose={onClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton={false}