From 04e185fa447f5933a6a1f19d1ebb8998f2985a9c Mon Sep 17 00:00:00 2001 From: leider Date: Tue, 25 Mar 2025 22:23:22 +0100 Subject: [PATCH] better error feedback in table --- .../widgets/EditableTable/EditableTable.tsx | 283 +++--------------- .../EditableTable/EditableTableInner.tsx | 253 ++++++++++++++++ .../EditableTable/InlineEditableActions.tsx | 2 +- .../widgets/EditableTable/editableTable.css | 4 + 4 files changed, 293 insertions(+), 249 deletions(-) create mode 100644 application/vue/src/widgets/EditableTable/EditableTableInner.tsx diff --git a/application/vue/src/widgets/EditableTable/EditableTable.tsx b/application/vue/src/widgets/EditableTable/EditableTable.tsx index e4441b39..fa3bae86 100644 --- a/application/vue/src/widgets/EditableTable/EditableTable.tsx +++ b/application/vue/src/widgets/EditableTable/EditableTable.tsx @@ -1,28 +1,16 @@ -import { Form, Table, type TableProps, Typography } from "antd"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { EditableContext } from "@/widgets/EditableTable/EditableContext.tsx"; -import EditableCell from "@/widgets/EditableTable/widgets/EditableCell.tsx"; -import { TableContext, useCreateTableContext } from "@/widgets/EditableTable/useTableContext.ts"; +import { Form } from "antd"; +import React, { useMemo, useState } from "react"; import { UserWithKann } from "@/widgets/MitarbeiterMultiSelect.tsx"; import { NamePath, ValidatorRule } from "rc-field-form/es/interface"; -import InlineEditableActions from "@/widgets/EditableTable/InlineEditableActions.tsx"; -import cloneDeep from "lodash/cloneDeep"; -import ButtonWithIcon from "@/widgets/buttonsAndIcons/ButtonWithIcon.tsx"; import isNil from "lodash/isNil"; import "./editableTable.css"; import { Columns } from "./types"; -import useColumnRenderer from "@/widgets/EditableTable/widgets/useColumnRenderer.tsx"; -import findIndex from "lodash/findIndex"; -import find from "lodash/find"; import map from "lodash/map"; import forEach from "lodash/forEach"; -import reject from "lodash/reject"; import filter from "lodash/filter"; import compact from "lodash/compact"; -import keys from "lodash/keys"; import uniq from "lodash/uniq"; - -type WithKey = T & { key: string }; +import EditableTableInner, { DuplInfo, WithKey } from "@/widgets/EditableTable/EditableTableInner.tsx"; interface EditableTableProps { readonly name: NamePath; @@ -31,255 +19,51 @@ interface EditableTableProps { readonly newRowFactory: (vals: T) => T; } -function EditableRow(props: React.DetailedHTMLProps, HTMLTableRowElement>) { - const [form] = Form.useForm(); - return ( -
- - - -
- ); +function duplicates(values: string[]) { + const compacted = compact(values); + return filter(compacted, (item, index) => index !== compacted.indexOf(item)); } -function InnerTable({ - value, - onTable, - columnDescriptions, - usersWithKann, - newRowFactory, -}: { - readonly value?: T[]; - readonly onTable?: (val?: T[]) => void; - readonly columnDescriptions?: Columns[]; - readonly usersWithKann?: UserWithKann[]; - readonly newRowFactory: (val: T) => T; -}) { - type TWithKey = WithKey; - type ColumnTypes = Exclude["columns"], undefined>; - const [rows, setRows] = useState([]); - - useEffect(() => { - const withKey: TWithKey[] = map(value, (row, index) => { - (row as TWithKey).key = "row" + index; - return row as TWithKey; - }); - setRows(withKey); - }, [value]); - - function newKey() { - const numbers = map(rows, (row) => Number.parseInt(row.key.replace("key", ""), 10)); - return Math.max(...numbers) + 1; - } - - const handleDelete = (key: React.Key) => { - onTable?.(reject(rows, ["key", key])); - }; - - const handleCopy = (key: React.Key) => { - if (!rows) { - return; - } - const current = find(rows, { key: key }) as WithKey; - if (!current) { - return; - } - const copied = cloneDeep(current); - copied.key = "" + newKey(); - const clone = cloneDeep(rows); - clone.splice(clone.indexOf(current), 0, copied); - onTable?.(clone); - }; - - const handleAdd = () => { - const newData = newRowFactory({} as T); - (newData as TWithKey).key = "" + newKey(); - onTable?.([newData, ...rows]); - }; - - const handleSave = useCallback( - (row: TWithKey, field: object) => { - const newData = [...(rows ?? [])]; - const index = findIndex(newData, ["key", row.key]); - const newRow = newRowFactory({ ...row, ...field }); - (newRow as TWithKey).key = row.key; - newData.splice(index, 1, newRow as TWithKey); - onTable?.(newData); - }, - [newRowFactory, onTable, rows], - ); - - const renderByType = useColumnRenderer(usersWithKann); - - function widthForType({ width, type }: Columns) { - if (width) { - return width; - } - switch (type) { - case "integer": - return "60px"; - case "color": - return "45px"; - case "date": - return "100px"; - case "startEnd": - return "200px"; - } - } - - const defaultColumns: (Omit & Columns)[] = useMemo( - () => - map(columnDescriptions, (item, index) => { - return { - editable: item.editable ?? true, - dataIndex: item.dataIndex, - title: item.title, - type: item.type, - index: index, - required: item.required, - filters: item.filters, - presets: item.presets, - usersWithKann: usersWithKann, - render: renderByType(item), - align: item.type === "integer" ? "end" : item.type === "boolean" ? "center" : "start", - onCell: undefined, - width: widthForType(item), - min: item.min, - initialValue: item.initialValue, - multiline: item.multiline, - }; - }), - [columnDescriptions, renderByType, usersWithKann], - ); - - const addButton = ( - - ); - defaultColumns.push({ - title: addButton, - dataIndex: "operation", - width: "70px", - align: "end", - render: (_: unknown, record: TWithKey) => ( - handleDelete(record.key), copy: () => handleCopy(record.key) }} /> - ), - }); - - const components = { - body: { - row: EditableRow, - cell: EditableCell, - }, - }; - - const columns = useMemo( - () => - map(defaultColumns, (col) => { - if (!col.editable) { - return col; - } - return { - ...col, - filters: undefined, // disable filter dropdown - onCell: (record: TWithKey) => ({ - index: rows.indexOf(record), - record, - editable: col.editable, - dataIndex: col.dataIndex, - title: col.title, - handleSave, - type: col.type, - required: col.required, - presets: col.presets, - filters: col.filters, - usersWithKann: col.usersWithKann, - width: col.width, - min: col.min, - initialValue: col.initialValue, - multiline: col.multiline, - }), - }; - }), - [defaultColumns, handleSave, rows], - ); - - const hidePagination = useMemo(() => rows.length < 50, [rows.length]); - - const tableContext = useCreateTableContext(); - return ( - - - bordered - className="editable-table" - columns={columns as ColumnTypes} - components={components} - dataSource={rows} - pagination={{ position: ["topRight"], defaultPageSize: 50, hideOnSinglePage: hidePagination }} - scroll={{ y: "60vh" }} - size="small" - /> - - ); -} - -function DulicatesInfo({ duplInfo }: { readonly duplInfo: { [idx: string]: unknown[] } }) { - if (keys(duplInfo).length === 0) { - return undefined; - } - return ( - <> - Du hast doppelte Einträge! - {" " + JSON.stringify(duplInfo, null, 2)} - - ); -} export default function EditableTable({ name, columnDescriptions, usersWithKann, newRowFactory }: EditableTableProps) { const requiredFields = useMemo(() => map(filter(columnDescriptions, "required"), "dataIndex") as string[], [columnDescriptions]); + const uniqueFields = useMemo(() => filter(columnDescriptions, "uniqueValues"), [columnDescriptions]); + const [duplInfo, setDuplInfo] = useState([]); + const [requiredErrors, setRequiredErrors] = useState([]); const requiredValidator = useMemo(() => { return { - validator: (_, rows: T[]) => { - let broken = false; + validator: (_, rows: WithKey[]) => { + const broken: string[] = []; forEach(requiredFields, (field) => { forEach(rows, (row) => { const val = row[field as keyof T]; if (isNil(val) || val === "") { - broken = true; + broken.push(row.key); } }); }); - return broken ? Promise.reject(new Error()) : Promise.resolve(); + setRequiredErrors(broken); + return broken.length ? Promise.reject(new Error()) : Promise.resolve(); }, message: "Du musst alle Pflichtfelder füllen", } as ValidatorRule; }, [requiredFields]); - function duplicates(values: unknown[]) { - const compacted = compact(values); - return filter(compacted, (item, index) => index !== compacted.indexOf(item)); - } - - const uniqueFields = useMemo(() => filter(columnDescriptions, "uniqueValues"), [columnDescriptions]); - - const [duplInfo, setDuplInfo] = useState<{ [idx: string]: unknown[] }>({}); const uniqueValidator = useMemo(() => { return { validator: (_, value: T[]) => { let broken = false; - const details: { [idx: string]: unknown[] } = {}; + const details: DuplInfo = []; forEach(uniqueFields, (field) => { - const valsToCheck = map(value, (row) => row[field.dataIndex as keyof T]); + const valsToCheck = map(value, (row) => "" + row[field.dataIndex as keyof T]); const dupes = duplicates(valsToCheck); + const keyForDupes = map( + filter(value, (row) => dupes.includes("" + row[field.dataIndex as keyof T])), + "key", + ); if (dupes.length) { broken = true; - details[field.title as string] = uniq(dupes); + details.push({ name: field.title as string, vals: uniq(dupes), keys: keyForDupes }); } }); setDuplInfo(details); @@ -290,16 +74,19 @@ export default function EditableTable({ name, columnDescriptions, usersWithKa }, [uniqueFields]); return ( - <> - - - columnDescriptions={columnDescriptions} newRowFactory={newRowFactory} usersWithKann={usersWithKann} /> - - + + + columnDescriptions={columnDescriptions} + duplInfo={duplInfo} + newRowFactory={newRowFactory} + requiredErrors={requiredErrors} + usersWithKann={usersWithKann} + /> + ); } diff --git a/application/vue/src/widgets/EditableTable/EditableTableInner.tsx b/application/vue/src/widgets/EditableTable/EditableTableInner.tsx new file mode 100644 index 00000000..2c70fdc8 --- /dev/null +++ b/application/vue/src/widgets/EditableTable/EditableTableInner.tsx @@ -0,0 +1,253 @@ +import { Form, Table, type TableProps, Typography } from "antd"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { EditableContext } from "@/widgets/EditableTable/EditableContext.tsx"; +import EditableCell from "@/widgets/EditableTable/widgets/EditableCell.tsx"; +import { TableContext, useCreateTableContext } from "@/widgets/EditableTable/useTableContext.ts"; +import { UserWithKann } from "@/widgets/MitarbeiterMultiSelect.tsx"; +import InlineEditableActions from "@/widgets/EditableTable/InlineEditableActions.tsx"; +import cloneDeep from "lodash/cloneDeep"; +import ButtonWithIcon from "@/widgets/buttonsAndIcons/ButtonWithIcon.tsx"; +import "./editableTable.css"; +import { Columns } from "./types"; +import useColumnRenderer from "@/widgets/EditableTable/widgets/useColumnRenderer.tsx"; +import findIndex from "lodash/findIndex"; +import find from "lodash/find"; +import map from "lodash/map"; +import reject from "lodash/reject"; +import flatMap from "lodash/flatMap"; +import keys from "lodash/keys"; + +export type WithKey = T & { key: string }; + +function DulicatesInfo({ duplInfo }: { readonly duplInfo: DuplInfo }) { + if (keys(duplInfo).length === 0) { + return undefined; + } + return ( + <> + Du hast doppelte Einträge! +
    + {map(duplInfo, (info) => ( +
  • + {info.name}: {info.vals} +
  • + ))} +
+ + ); +} + +function EditableRow(props: React.DetailedHTMLProps, HTMLTableRowElement>) { + const [form] = Form.useForm(); + return ( +
+ + + +
+ ); +} + +function widthForType({ width, type }: Columns) { + if (width) { + return width; + } + switch (type) { + case "integer": + return "60px"; + case "color": + return "45px"; + case "date": + return "100px"; + case "startEnd": + return "200px"; + } +} + +export type DuplInfo = { name: string; vals: string[]; keys: string[] }[]; + +export default function EditableTableInner({ + value, + onTable, + columnDescriptions, + usersWithKann, + newRowFactory, + duplInfo, + requiredErrors, +}: { + readonly value?: T[]; + readonly onTable?: (val?: T[]) => void; + readonly columnDescriptions?: Columns[]; + readonly usersWithKann?: UserWithKann[]; + readonly newRowFactory: (val: T) => T; + readonly duplInfo: DuplInfo; + readonly requiredErrors: string[]; +}) { + type TWithKey = WithKey; + type ColumnTypes = Exclude["columns"], undefined>; + const [rows, setRows] = useState([]); + const [page, setPage] = useState(1); + const tableContext = useCreateTableContext(); + const hidePagination = useMemo(() => rows.length < 50, [rows.length]); + + useEffect(() => { + const withKey: TWithKey[] = map(value, (row, index) => { + (row as TWithKey).key = "row" + index; + return row as TWithKey; + }); + setRows(withKey); + }, [value]); + + const newKey = useCallback(() => { + const numbers = map(rows, (row) => Number.parseInt(row.key.replace("key", ""), 10)); + return Math.max(...numbers) + 1; + }, [rows]); + + const handleDelete = useCallback( + (key: React.Key) => { + onTable?.(reject(rows, ["key", key])); + }, + [onTable, rows], + ); + + const handleCopy = useCallback( + (key: React.Key) => { + if (!rows) { + return; + } + const current = find(rows, { key: key }) as WithKey; + if (!current) { + return; + } + const copied = cloneDeep(current); + copied.key = "" + newKey(); + const clone = cloneDeep(rows); + clone.splice(rows.indexOf(current), 0, copied); + onTable?.(clone); + }, + [newKey, onTable, rows], + ); + + const handleAdd = useCallback(() => { + const newData = newRowFactory({} as T); + (newData as TWithKey).key = "" + newKey(); + onTable?.([newData, ...rows]); + setPage(1); + }, [newKey, newRowFactory, onTable, rows]); + + const handleSave = useCallback( + (row: TWithKey, field: object) => { + const newData = [...(rows ?? [])]; + const index = findIndex(newData, ["key", row.key]); + const newRow = newRowFactory({ ...row, ...field }); + (newRow as TWithKey).key = row.key; + newData.splice(index, 1, newRow as TWithKey); + onTable?.(newData); + }, + [newRowFactory, onTable, rows], + ); + + const hasRecordErrors = useCallback( + (record: TWithKey) => flatMap(map(duplInfo, "keys")).includes(record.key) || requiredErrors.includes(record.key), + [duplInfo, requiredErrors], + ); + + const renderByType = useColumnRenderer(usersWithKann); + + const defaultColumns = useMemo(() => { + const result = map(columnDescriptions, (item, index) => { + return { + editable: item.editable ?? true, + dataIndex: item.dataIndex, + title: item.title, + type: item.type, + index: index, + required: item.required, + filters: item.filters, + presets: item.presets, + usersWithKann: usersWithKann, + render: renderByType(item), + align: item.type === "integer" ? "end" : item.type === "boolean" ? "center" : "start", + onCell: undefined, + width: widthForType(item), + min: item.min, + initialValue: item.initialValue, + multiline: item.multiline, + }; + }); + result.push({ + title: ( + + ), + dataIndex: "operation", + width: "70px", + align: "end", + // @ts-expect-error I do not know why this is bad here + render: (_: unknown, record: TWithKey) => ( + handleDelete(record.key), copy: () => handleCopy(record.key) }} /> + ), + }); + return result; + }, [columnDescriptions, handleAdd, handleCopy, handleDelete, renderByType, usersWithKann]); + + const columns = useMemo( + () => + map(defaultColumns, (col) => { + if (!col.editable) { + return col; + } + return { + ...col, + filters: undefined, // disable filter dropdown + onCell: (record: TWithKey) => ({ + index: rows.indexOf(record), + record, + editable: col.editable, + dataIndex: col.dataIndex, + title: col.title, + handleSave, + type: col.type, + required: col.required, + presets: col.presets, + filters: col.filters, + usersWithKann: col.usersWithKann, + width: col.width, + min: col.min, + initialValue: col.initialValue, + multiline: col.multiline, + }), + }; + }), + [defaultColumns, handleSave, rows], + ); + + return ( + + + + bordered + className="editable-table" + columns={columns as ColumnTypes} + components={{ body: { row: EditableRow, cell: EditableCell } }} + dataSource={rows} + pagination={{ + onChange: setPage, + current: page, + position: ["topRight"], + defaultPageSize: 50, + hideOnSinglePage: hidePagination, + }} + rowClassName={(record) => (hasRecordErrors(record) ? "table-row-error" : "")} + scroll={{ y: "60vh" }} + size="small" + /> + + ); +} diff --git a/application/vue/src/widgets/EditableTable/InlineEditableActions.tsx b/application/vue/src/widgets/EditableTable/InlineEditableActions.tsx index bfd03ddc..e520a0c4 100755 --- a/application/vue/src/widgets/EditableTable/InlineEditableActions.tsx +++ b/application/vue/src/widgets/EditableTable/InlineEditableActions.tsx @@ -16,7 +16,7 @@ export interface ActionCallbacks { * }} props * @return {*} {React.ReactElement} */ -export default function InlineEditableActions({ actions }: { readonly actions: ActionCallbacks }): React.ReactElement { +export default function InlineEditableActions({ actions }: { readonly actions: ActionCallbacks }) { const [open, setOpen] = useState(false); return ( <> diff --git a/application/vue/src/widgets/EditableTable/editableTable.css b/application/vue/src/widgets/EditableTable/editableTable.css index 9e7de303..44a35b6a 100644 --- a/application/vue/src/widgets/EditableTable/editableTable.css +++ b/application/vue/src/widgets/EditableTable/editableTable.css @@ -1,3 +1,7 @@ .editable-table .ant-table.ant-table-small .ant-table-tbody > tr > td { padding: 0 !important; } + +.table-row-error { + background-color: var(--ant-color-error-bg); +}