diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 23d06e0..166a062 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -42,7 +42,8 @@ "editSuccess": "Successfully edited {{resource}}", "editError": "Error when editing {{resource}} (status code: {{statusCode}})", "importProgress": "Importing: {{processed}}/{{total}}", - "saveSuccessful": "Save successful!" + "saveSuccessful": "Save successful!", + "validationError": "Validation error: {{error}}" }, "kofi": "Tip me on Ko-fi", "loading": "Loading", @@ -255,14 +256,39 @@ }, "settings": { "header": "Settings", - "generic": { - "tab": "Generic", + "general": { + "tab": "General", "currency": { "label": "Currency" } }, "extra_fields": { - "tab": "Extra Fields" + "tab": "Extra Fields", + "description": "

Here you can add extra custom fields to your entities.

Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi select state. If you remove a field, the associated data for all entities will be deleted.

Extra fields can not be sorted or filtered in the table views.

", + "params": { + "key": "Key", + "name": "Name", + "field_type": "Type", + "unit": "Unit", + "default_value": "Default Value", + "choices": "Choices", + "multi_choice": "Multi Choice" + }, + "field_type": { + "text": "Text", + "integer": "Integer", + "integer_range": "Integer Range", + "float": "Float", + "float_range": "Float Range", + "datetime": "Datetime", + "boolean": "Boolean", + "choice": "Choice" + }, + "boolean_true": "Yes", + "boolean_false": "No", + "choices_missing_error": "You cannot remove choices. The following choices are missing: {{choices}}", + "non_unique_key_error": "The key must be unique.", + "key_not_changed": "Please change the key to something else." } }, "documentTitle": { diff --git a/client/src/App.tsx b/client/src/App.tsx index 550fbcb..fbcddf3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -217,7 +217,7 @@ function App() { } /> } /> - } /> + } /> } /> } /> diff --git a/client/src/pages/settings/ExtraFieldsSettings.tsx b/client/src/pages/settings/ExtraFieldsSettings.tsx index 95bbec5..10653b4 100644 --- a/client/src/pages/settings/ExtraFieldsSettings.tsx +++ b/client/src/pages/settings/ExtraFieldsSettings.tsx @@ -1,5 +1,554 @@ -import React from "react"; +import { + Button, + Checkbox, + DatePicker, + Flex, + Form, + FormInstance, + Input, + InputNumber, + Select, + Table, + Typography, + message, +} from "antd"; +import { EntityType, Field, FieldType, useGetFields, useSetField } from "./queryFields"; +import { useTranslate } from "@refinedev/core"; +import { useState } from "react"; +import { ColumnType } from "antd/es/table"; +import { Trans } from "react-i18next"; +import { useParams } from "react-router-dom"; +import { FormItemProps, Rule } from "antd/es/form"; +import { PlusOutlined } from "@ant-design/icons"; + +interface EditableCellProps extends React.HTMLAttributes { + record: FieldHolder; + editing: boolean; + dataIndex: string; + form: FormInstance; + children: React.ReactNode; +} + +interface ColumnProps extends ColumnType { + editable: boolean; + required: boolean; +} + +interface FieldHolder { + key: string; + field: Field; + is_new: boolean; +} + +const EditableCell: React.FC = ({ record, editing, dataIndex, children, form, ...restProps }) => { + const t = useTranslate(); + + if (!editing) { + return {children}; + } + + const fieldType = form.getFieldValue("field_type") as FieldType; + const choices = form.getFieldValue("choices") as string[]; + + const title = t(`settings.extra_fields.params.${dataIndex}`); + + let inputNode; + const rules: Rule[] = []; + const formItemProps: FormItemProps = {}; + if (dataIndex === "key") { + inputNode = ; + rules.push({ + required: true, + min: 1, + pattern: /^[a-z0-9_]+$/, + }); + rules.push({ + validator: async (_, value) => { + // Ensure key is not new_field + if (value === "new_field") { + throw new Error(t("settings.extra_fields.key_not_changed")); + } + }, + }); + } else if (dataIndex === "field_type") { + inputNode = ( + ; + rules.push({ + required: true, + min: 1, + }); + } else if (dataIndex === "unit") { + if ( + fieldType === FieldType.integer || + fieldType === FieldType.integer_range || + fieldType === FieldType.float || + fieldType === FieldType.float_range + ) { + inputNode = ; + } else { + inputNode = null; + } + rules.push({ + required: false, + }); + } else if (dataIndex === "default_value") { + if (fieldType === FieldType.boolean) { + inputNode = ; + formItemProps.valuePropName = "checked"; + rules.push({ + type: "boolean", + }); + } else if (fieldType === FieldType.text) { + inputNode = ; + rules.push({ + type: "string", + }); + } else if (fieldType === FieldType.integer) { + inputNode = ; + rules.push({ + type: "integer", + }); + } else if (fieldType === FieldType.float) { + inputNode = ; + rules.push({ + type: "float", + }); + } else if (fieldType === FieldType.integer_range) { + inputNode = ; + rules.push({ + type: "string", + pattern: /^-?\d+\s*-\s*-?\d+$/, + }); + } else if (fieldType === FieldType.float_range) { + inputNode = ; + rules.push({ + type: "string", + pattern: /^-?\d+([.,]\d+)?\s*-\s*-?\d+([.,]\d+)?$/, + }); + } else if (fieldType === FieldType.datetime) { + inputNode = ; + } else if (fieldType === FieldType.choice) { + inputNode = ( + ; + rules.push({ + required: true, + min: 1, + type: "array", + validator: async (_, value) => { + // Verify that all values in record.choices are in value + const recordChoices = record.field.choices || []; + const valueChoices = value || []; + const missingChoices = recordChoices.filter((choice) => !valueChoices.includes(choice)); + if (missingChoices.length > 0) { + throw new Error( + t("settings.extra_fields.choices_missing_error", { + choices: missingChoices.join(", "), + }) + ); + } + }, + }); + } else { + inputNode = null; + } + } else if (dataIndex === "multi_choice") { + if (fieldType === FieldType.choice) { + inputNode = ( + { + // Reset default_value when changing multi_choice + form.setFieldsValue({ + default_value: undefined, + }); + }} + > + {title} + + ); + formItemProps.valuePropName = "checked"; + rules.push({ + type: "boolean", + }); + } else { + inputNode = null; + } + } else { + inputNode = null; + } + + const formItem = inputNode ? ( + + {inputNode} + + ) : null; + + return {formItem}; +}; export function ExtraFieldsSettings() { - return <>Extra Fields Settings Content; + const { entityType } = useParams<{ entityType: EntityType }>(); + const t = useTranslate(); + const [form] = Form.useForm(); + const fields = useGetFields(entityType as EntityType); + const setField = useSetField(entityType as EntityType); + const [isSubmitting, setIsSubmitting] = useState(false); + const [newField, setNewField] = useState(null); + + const [messageApi, contextHolder] = message.useMessage(); + + const [editingKey, setEditingKey] = useState(""); + + const isEditing = (record: FieldHolder) => record.field.key === editingKey; + + const edit = (record: Partial & { key: React.Key }) => { + const values = { ...record }; + console.log(values); + if (values.default_value) { + values.default_value = JSON.parse(values.default_value); + } else { + values.default_value = ""; + } + form.setFieldsValue(values); + setEditingKey(record.key); + }; + + const cancel = () => { + setEditingKey(""); + }; + + const save = async (_key: React.Key) => { + let row; + try { + row = (await form.validateFields()) as Field; + } catch (errInfo) { + // Ignore these errors because they are already handled by the form + return; + } + + // Do some value conversions + try { + // Convert float and integer range to array using the validation regex + if (row.field_type === FieldType.float_range && typeof row.default_value === "string") { + const pattern = /^(-?\d+(?:[.,]\d+)?)\s*-\s*(-?\d+(?:[.,]\d+)?)$/; + const matches = row.default_value.match(pattern); + if (matches) { + const val1 = parseFloat(matches[1].replace(",", ".")); + const val2 = parseFloat(matches[2].replace(",", ".")); + row.default_value = JSON.stringify([Math.min(val1, val2), Math.max(val1, val2)]); + } + } else if (row.field_type === FieldType.integer_range && typeof row.default_value === "string") { + const pattern = /^(-?\d+)\s*-\s*(-?\d+)$/; + const matches = row.default_value.match(pattern); + if (matches) { + const val1 = parseInt(matches[1]); + const val2 = parseInt(matches[2]); + row.default_value = JSON.stringify([Math.min(val1, val2), Math.max(val1, val2)]); + } + } else { + // Just stringify all other values + row.default_value = JSON.stringify(row.default_value); + } + + // Set multi_choice if it's not set and field_type is choice + if (row.field_type === FieldType.choice && row.multi_choice === undefined) { + row.multi_choice = false; + } + + // If unit is an empty string, set it to null instead + if (row.unit === "") { + row.unit = undefined; + } + } catch (errInfo) { + if (errInfo instanceof Error) { + messageApi.error(t("notifications.validationError", { error: errInfo.message })); + // Check if errInfo has the errorFields property, then we should skip the error message + } + return; + } + + // Validate that row.key is unique and not among the other keys + const keys = new Set(fields.data?.map((field) => field.key) || []); + if (keys.has(row.key)) { + messageApi.error(t("settings.extra_fields.non_unique_key_error")); + return; + } + + // Submit it! + try { + console.log(row); + + setIsSubmitting(true); + + setField.reset(); + + await setField.mutateAsync({ + key: row.key, + params: row, + }); + + setEditingKey(""); + setNewField(null); + } catch (errInfo) { + if (errInfo instanceof Error) { + messageApi.error(errInfo.message); + } + } + setIsSubmitting(false); + }; + + const niceName = t(`${entityType}.${entityType}`); + + const columns: ColumnProps[] = [ + { + title: t("settings.extra_fields.params.key"), + dataIndex: ["field", "key"], + key: "key", + editable: false, + required: true, + }, + { + title: t("settings.extra_fields.params.name"), + dataIndex: ["field", "name"], + editable: true, + required: true, + }, + { + title: t("settings.extra_fields.params.field_type"), + dataIndex: ["field", "field_type"], + editable: false, + required: true, + render(value) { + return t(`settings.extra_fields.field_type.${value}`); + }, + }, + { + title: t("settings.extra_fields.params.unit"), + dataIndex: ["field", "unit"], + editable: true, + required: false, + }, + { + title: t("settings.extra_fields.params.default_value"), + dataIndex: ["field", "default_value"], + editable: true, + required: false, + render(value, record) { + const val = JSON.parse(value || "null"); + if (typeof val === "boolean") { + return val ? t("settings.extra_fields.boolean_true") : t("settings.extra_fields.boolean_false"); + } else if (typeof val === "number" || typeof val === "string") { + return val; + } else if (Array.isArray(val) && record.field.field_type === FieldType.choice) { + return val.join(", "); + } else if ( + (Array.isArray(val) && record.field.field_type === FieldType.integer_range) || + record.field.field_type === FieldType.float_range + ) { + return `${val[0]} - ${val[1]}`; + } else { + return null; + } + }, + }, + { + title: t("settings.extra_fields.params.choices"), + dataIndex: ["field", "choices"], + editable: true, + required: false, + render(value, record) { + if (record.field.field_type === FieldType.choice && Array.isArray(value)) { + return value.join(", "); + } else { + return null; + } + }, + }, + { + title: t("settings.extra_fields.params.multi_choice"), + dataIndex: ["field", "multi_choice"], + editable: false, + required: false, + render(value, record) { + if (record.field.field_type === FieldType.choice) { + return value ? t("settings.extra_fields.boolean_true") : t("settings.extra_fields.boolean_false"); + } else { + return null; + } + }, + }, + { + title: "", + dataIndex: "operation", + editable: false, + required: false, + render: (_: unknown, record: FieldHolder) => { + const editable = isEditing(record); + return editable ? ( + + save(record.field.key)} style={{ marginRight: 8 }}> + Save + + cancel()}>Cancel + + ) : ( + edit(record.field)}> + Edit + + ); + }, + }, + ]; + + const mergedColumns = columns.map((col) => { + if (col.dataIndex === "operation") { + return col; + } + return { + ...col, + onCell: function (record: FieldHolder): EditableCellProps { + return { + record, + editing: isEditing(record), + dataIndex: (col.dataIndex?.toString() || "").split(",").pop() || "", + form: form, + children: [], + }; + }, + }; + }); + + const tableFields: FieldHolder[] = [ + ...(fields.data || []).map((field) => ({ + key: field.key, + field, + is_new: false, + })), + ...(newField ? [newField] : []), + ]; + + return ( + <> +

+ {t("settings.extra_fields.tab")} - {niceName} +

+ , + }} + /> +
+ + + {newField == null && ( + +