IO-3624 Finalize admin config UX and validation polish
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import { CloseOutlined, DeleteFilled } from "@ant-design/icons";
|
||||
import { CloseOutlined, DeleteFilled, HolderOutlined } from "@ant-design/icons";
|
||||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { arrayMove, rectSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button, Form, Select, Space } from "antd";
|
||||
import { ChromePicker } from "react-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import { DEFAULT_TRANSLUCENT_CARD_COLOR, getTintedCardSurfaceStyles } from "./shop-info.color.utils";
|
||||
|
||||
@@ -58,20 +60,43 @@ const SelectorDiv = styled.div`
|
||||
.job-statuses-source-tag-wrapper {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
margin-inline-end: 4px;
|
||||
margin-inline-end: 6px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.job-statuses-source-tag-wrapper .ant-select-selection-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
min-width: 132px;
|
||||
max-width: 100%;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
justify-content: space-between;
|
||||
max-width: 100%;
|
||||
cursor: grab;
|
||||
margin-inline-end: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.job-statuses-source-tag-wrapper .job-statuses-source-tag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-statuses-source-tag-wrapper .ant-select-selection-item-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-statuses-source-tag-wrapper .ant-select-selection-item:active {
|
||||
@@ -79,7 +104,22 @@ const SelectorDiv = styled.div`
|
||||
}
|
||||
|
||||
.job-statuses-source-tag-wrapper .ant-select-selection-item-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.job-statuses-source-tag-wrapper .ant-select-selection-item-remove:hover {
|
||||
background: var(--ant-color-fill-secondary);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.job-statuses-source-tag-wrapper--dragging {
|
||||
@@ -89,6 +129,30 @@ const SelectorDiv = styled.div`
|
||||
|
||||
const normalizeStatuses = (statuses) => [...new Set((statuses || []).map((item) => item?.trim()).filter(Boolean))];
|
||||
|
||||
const getTranslatedDragRect = (active, delta) => {
|
||||
const rect = active?.rect?.current?.initial || active?.rect?.current?.translated;
|
||||
|
||||
if (!rect) return null;
|
||||
|
||||
const x = delta?.x || 0;
|
||||
const y = delta?.y || 0;
|
||||
|
||||
return {
|
||||
left: rect.left + x,
|
||||
right: rect.right + x,
|
||||
top: rect.top + y,
|
||||
bottom: rect.bottom + y,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
};
|
||||
|
||||
const isPointWithinRect = (point, rect) => {
|
||||
if (!point || !rect) return false;
|
||||
|
||||
return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom;
|
||||
};
|
||||
|
||||
const DraggableStatusTag = ({ label, value, closable, onClose }) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: value
|
||||
@@ -99,11 +163,13 @@ const DraggableStatusTag = ({ label, value, closable, onClose }) => {
|
||||
<span
|
||||
ref={setNodeRef}
|
||||
className={`job-statuses-source-tag-wrapper ${isDragging ? "job-statuses-source-tag-wrapper--dragging" : ""}`}
|
||||
data-status-tag-value={value}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (event.target.closest(".ant-tag-close-icon")) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
@@ -117,9 +183,21 @@ const DraggableStatusTag = ({ label, value, closable, onClose }) => {
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target.closest(".ant-select-selection-item-remove")) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
}}
|
||||
title={labelText}
|
||||
>
|
||||
<span className="job-statuses-source-tag-handle" aria-hidden>
|
||||
<HolderOutlined />
|
||||
</span>
|
||||
<span className="ant-select-selection-item-content">{labelText}</span>
|
||||
{closable ? (
|
||||
<span
|
||||
@@ -140,8 +218,12 @@ const DraggableStatusTag = ({ label, value, closable, onClose }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SortableStatusesSelect = ({ value, onChange }) => {
|
||||
const SortableStatusesSelect = ({ value, onChange, mode = "tags", options = [] }) => {
|
||||
const statuses = normalizeStatuses(value);
|
||||
const isTagsMode = mode === "tags";
|
||||
const [knownStatuses, setKnownStatuses] = useState(statuses);
|
||||
const selectWrapperRef = useRef(null);
|
||||
const dragRectRef = useRef(null);
|
||||
const tagSensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
@@ -151,16 +233,75 @@ const SortableStatusesSelect = ({ value, onChange }) => {
|
||||
);
|
||||
|
||||
const handleStatusesChange = (nextValues) => {
|
||||
onChange?.(normalizeStatuses(nextValues));
|
||||
const normalizedNextValues = normalizeStatuses(nextValues);
|
||||
if (isTagsMode) {
|
||||
setKnownStatuses((currentKnownStatuses) => normalizeStatuses([...currentKnownStatuses, ...normalizedNextValues]));
|
||||
}
|
||||
onChange?.(normalizedNextValues);
|
||||
};
|
||||
|
||||
const handleStatusSortEnd = ({ active, over }) => {
|
||||
if (!over || active.id === over.id) return;
|
||||
useEffect(() => {
|
||||
if (isTagsMode) {
|
||||
setKnownStatuses((currentKnownStatuses) => normalizeStatuses([...currentKnownStatuses, ...statuses]));
|
||||
}
|
||||
}, [isTagsMode, statuses]);
|
||||
|
||||
const shouldMoveStatusToEnd = (activeId, dragRect) => {
|
||||
const selectRect =
|
||||
selectWrapperRef.current?.querySelector?.(".ant-select-selector")?.getBoundingClientRect?.() ||
|
||||
selectWrapperRef.current?.getBoundingClientRect?.();
|
||||
if (!dragRect || !selectRect) return false;
|
||||
|
||||
const dragLeadingPoint = {
|
||||
x: dragRect.left,
|
||||
y: dragRect.top
|
||||
};
|
||||
const dragTrailingPoint = {
|
||||
x: dragRect.right,
|
||||
y: dragRect.bottom
|
||||
};
|
||||
|
||||
if (!isPointWithinRect(dragLeadingPoint, selectRect) && !isPointWithinRect(dragTrailingPoint, selectRect)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trailingStatus = statuses.filter((status) => status !== activeId).at(-1);
|
||||
if (!trailingStatus) return false;
|
||||
|
||||
const trailingTagNode = selectWrapperRef.current?.querySelector?.(
|
||||
`.job-statuses-source-tag-wrapper[data-status-tag-value="${CSS.escape(String(trailingStatus))}"]`
|
||||
);
|
||||
const trailingTagRect = trailingTagNode?.getBoundingClientRect?.();
|
||||
|
||||
if (!trailingTagRect) return false;
|
||||
|
||||
const isOnTrailingRow = dragRect.bottom >= trailingTagRect.top && dragRect.top <= trailingTagRect.bottom;
|
||||
if (isOnTrailingRow) {
|
||||
return dragRect.left >= trailingTagRect.right - 4;
|
||||
}
|
||||
|
||||
return dragRect.top >= trailingTagRect.bottom - 4;
|
||||
};
|
||||
|
||||
const handleStatusSortEnd = ({ active, over, delta }) => {
|
||||
const oldIndex = statuses.indexOf(active.id);
|
||||
const dragRect = dragRectRef.current || getTranslatedDragRect(active, delta);
|
||||
dragRectRef.current = null;
|
||||
|
||||
if (oldIndex < 0) return;
|
||||
|
||||
if (!over) {
|
||||
if (oldIndex !== statuses.length - 1 && shouldMoveStatusToEnd(active.id, dragRect)) {
|
||||
onChange?.(arrayMove(statuses, oldIndex, statuses.length - 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (active.id === over.id) return;
|
||||
|
||||
const newIndex = statuses.indexOf(over.id);
|
||||
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
if (newIndex < 0) return;
|
||||
|
||||
onChange?.(arrayMove(statuses, oldIndex, newIndex));
|
||||
};
|
||||
@@ -169,18 +310,50 @@ const SortableStatusesSelect = ({ value, onChange }) => {
|
||||
return <DraggableStatusTag closable={closable} label={label} onClose={onClose} value={tagValue} />;
|
||||
};
|
||||
|
||||
const statusSelectOptions = isTagsMode
|
||||
? knownStatuses.map((status) => ({
|
||||
value: status,
|
||||
label: status
|
||||
}))
|
||||
: options;
|
||||
|
||||
if (statuses.length === 0) {
|
||||
return (
|
||||
<Select
|
||||
className="job-statuses-source-select"
|
||||
mode={mode}
|
||||
onChange={handleStatusesChange}
|
||||
options={statusSelectOptions}
|
||||
value={statuses}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleStatusSortEnd} sensors={tagSensors}>
|
||||
<SortableContext items={statuses} strategy={rectSortingStrategy}>
|
||||
<Select
|
||||
className="job-statuses-source-select"
|
||||
mode="tags"
|
||||
onChange={handleStatusesChange}
|
||||
tagRender={renderStatusTag}
|
||||
value={statuses}
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<div ref={selectWrapperRef}>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragCancel={() => {
|
||||
dragRectRef.current = null;
|
||||
}}
|
||||
onDragEnd={handleStatusSortEnd}
|
||||
onDragMove={({ active, delta }) => {
|
||||
dragRectRef.current = getTranslatedDragRect(active, delta);
|
||||
}}
|
||||
sensors={tagSensors}
|
||||
>
|
||||
<SortableContext items={statuses} strategy={rectSortingStrategy}>
|
||||
<Select
|
||||
className="job-statuses-source-select"
|
||||
mode={mode}
|
||||
onChange={handleStatusesChange}
|
||||
options={statusSelectOptions}
|
||||
tagRender={renderStatusTag}
|
||||
value={statuses}
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -209,13 +382,20 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "statuses"]}
|
||||
label={t("bodyshop.labels.alljobstatuses")}
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const populatedStatuses = normalizeStatuses(value);
|
||||
|
||||
if (populatedStatuses.length === 0) {
|
||||
return Promise.reject(new Error(t("general.validation.required")));
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
t("general.validation.required", {
|
||||
label: t("bodyshop.labels.alljobstatuses")
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (populatedStatuses.length !== (value || []).filter(Boolean).length) {
|
||||
@@ -238,7 +418,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple" options={statusSelectOptions} />
|
||||
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "pre_production_statuses"]}
|
||||
@@ -251,7 +431,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple" options={statusSelectOptions} />
|
||||
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "production_statuses"]}
|
||||
@@ -264,7 +444,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple" options={statusSelectOptions} />
|
||||
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "post_production_statuses"]}
|
||||
@@ -277,7 +457,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple" options={statusSelectOptions} />
|
||||
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "ready_statuses"]}
|
||||
@@ -290,7 +470,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple" options={statusSelectOptions} />
|
||||
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "additional_board_statuses"]}
|
||||
@@ -303,7 +483,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple" options={statusSelectOptions} />
|
||||
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</LayoutFormRow>
|
||||
@@ -449,7 +629,9 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<LayoutFormRow
|
||||
<InlineValidatedFormRow
|
||||
form={form}
|
||||
errorNames={[["md_ro_statuses", "production_colors", field.name, "status"]]}
|
||||
key={field.key}
|
||||
noDivider
|
||||
title={
|
||||
@@ -506,7 +688,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
<ColorPicker />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</LayoutFormRow>
|
||||
</InlineValidatedFormRow>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
|
||||
Reference in New Issue
Block a user