Extra fields now finished for filaments

This commit is contained in:
Donkie
2024-01-23 18:58:04 +01:00
parent 23818c1005
commit fb23024ed0
18 changed files with 699 additions and 139 deletions

View File

@@ -3,7 +3,7 @@ import { ColumnFilterItem, ColumnType } from "antd/es/table/interface";
import { getFiltersForField, typeFilters } from "../utils/filtering";
import { TableState } from "../utils/saveload";
import { getSortOrderForField, typeSorters } from "../utils/sorting";
import { NumberFieldUnit } from "./numberField";
import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { DateField, TextField } from "@refinedev/antd";
@@ -12,7 +12,8 @@ import SpoolIcon from "../icon_spool.svg?react";
import { useTranslate } from "@refinedev/core";
import { enrichText } from "../utils/parsing";
import { UseQueryResult } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import { Field, FieldType } from "../utils/queryFields";
dayjs.extend(utc);
@@ -39,14 +40,19 @@ export interface Action {
}
interface BaseColumnProps<Obj extends Entity> {
id: keyof Obj & string;
id: string | string[];
dataId?: keyof Obj & string;
i18ncat?: string;
i18nkey?: string;
title?: string;
sorter?: boolean;
t: (key: string) => string;
navigate: (link: string) => void;
dataSource: Obj[];
tableState: TableState;
width?: number;
actions?: (record: Obj) => Action[];
transform?: (value: unknown) => unknown;
}
interface FilteredColumnProps {
@@ -70,24 +76,32 @@ interface CustomColumnProps<Obj> {
function Column<Obj extends Entity>(
props: BaseColumnProps<Obj> & FilteredColumnProps & CustomColumnProps<Obj>
): ColumnType<Obj> | undefined {
const t = useTranslate();
const navigate = useNavigate();
const t = props.t;
const navigate = props.navigate;
// Hide if not in showColumns
if (props.tableState.showColumns && !props.tableState.showColumns.includes(props.id)) {
const id = Array.isArray(props.id) ? props.id.join(".") : props.id;
if (props.tableState.showColumns && !props.tableState.showColumns.includes(id)) {
return undefined;
}
const columnProps: ColumnType<Obj> = {
dataIndex: props.id,
title: t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
sorter: true,
sortOrder: getSortOrderForField(typeSorters<Obj>(props.tableState.sorters), props.dataId ?? props.id),
title: props.title ?? t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
filterMultiple: props.allowMultipleFilters ?? true,
width: props.width ?? undefined,
onCell: props.onCell ?? undefined,
};
// Sorting
if (props.sorter) {
columnProps.sorter = true;
columnProps.sortOrder = getSortOrderForField(
typeSorters<Obj>(props.tableState.sorters),
props.dataId ?? (props.id as keyof Obj)
);
}
// Filter
if (props.filters && props.filteredValue) {
columnProps.filters = props.filters;
@@ -106,7 +120,12 @@ function Column<Obj extends Entity>(
}
// Render
const render = props.render ?? ((value) => <>{value}</>);
const render =
props.render ??
((rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return <>{value}</>;
});
columnProps.render = (value, record, index) => {
if (!props.actions) {
return render(value, record, index);
@@ -144,13 +163,19 @@ function Column<Obj extends Entity>(
}
export function SortedColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column(props);
}
export function RichColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column({
...props,
render: (value: string | undefined) => {
sorter: true,
});
}
export function RichColumn<Obj extends Entity>(
props: Omit<BaseColumnProps<Obj>, "transform"> & { transform?: (value: unknown) => string }
) {
return Column({
...props,
render: (rawValue: string | undefined) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return enrichText(value);
},
});
@@ -182,7 +207,7 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
});
const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const onFilterDropdownOpen = () => {
query.refetch();
@@ -193,14 +218,16 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
unit: string;
decimals?: number;
maxDecimals?: number;
minDecimals?: number;
defaultText?: string;
}
export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) {
return Column({
...props,
render: (value) => {
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
return <TextField value={props.defaultText ?? ""} />;
}
@@ -209,8 +236,8 @@ export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>)
value={value}
unit={props.unit}
options={{
maximumFractionDigits: props.decimals ?? 0,
minimumFractionDigits: props.decimals ?? 0,
maximumFractionDigits: props.maxDecimals ?? 0,
minimumFractionDigits: props.minDecimals ?? props.maxDecimals ?? 0,
}}
/>
);
@@ -221,7 +248,8 @@ export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>)
export function DateColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column({
...props,
render: (value) => {
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return (
<DateField
hidden={!value}
@@ -291,7 +319,7 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
});
const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const onFilterDropdownOpen = () => {
query.refetch();
@@ -312,7 +340,8 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
},
};
},
render: (value, record: Obj) => {
render: (rawValue, record: Obj) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
const colorStr = props.color(record);
return (
<Row wrap={false} justify="space-around" align="middle">
@@ -334,3 +363,121 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
},
});
}
export function NumberRangeColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) {
return Column({
...props,
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
return <TextField value={props.defaultText ?? ""} />;
}
if (!Array.isArray(value) || value.length !== 2) {
return <TextField value={props.defaultText ?? ""} />;
}
return (
<NumberFieldUnitRange
value={value}
unit={props.unit}
options={{
maximumFractionDigits: props.maxDecimals ?? 0,
minimumFractionDigits: props.minDecimals ?? props.maxDecimals ?? 0,
}}
/>
);
},
});
}
export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProps<Obj>, "id"> & { field: Field }) {
const field = props.field;
const commonProps = {
...props,
id: ["extra", field.key],
title: field.name,
sorter: false,
transform: (value: unknown) => {
if (value === null || value === undefined) {
return undefined;
}
return JSON.parse(value as string);
},
};
if (field.field_type === FieldType.integer) {
return NumberColumn({
...commonProps,
unit: field.unit ?? "",
maxDecimals: 0,
});
} else if (field.field_type === FieldType.float) {
return NumberColumn({
...commonProps,
unit: field.unit ?? "",
minDecimals: 0,
maxDecimals: 3,
});
} else if (field.field_type === FieldType.integer_range) {
return NumberRangeColumn({
...commonProps,
unit: field.unit ?? "",
maxDecimals: 0,
});
} else if (field.field_type === FieldType.float_range) {
return NumberRangeColumn({
...commonProps,
unit: field.unit ?? "",
minDecimals: 0,
maxDecimals: 3,
});
} else if (field.field_type === FieldType.text) {
return RichColumn({
...commonProps,
});
} else if (field.field_type === FieldType.datetime) {
return DateColumn({
...commonProps,
});
} else if (field.field_type === FieldType.boolean) {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
let text;
if (value === undefined || value === null) {
text = "";
} else if (value) {
text = props.t("yes");
} else {
text = props.t("no");
}
return <TextField value={text} />;
},
});
} else if (field.field_type === FieldType.choice && !field.multi_choice) {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={value} />;
},
});
} else if (field.field_type === FieldType.choice && field.multi_choice) {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={(value as string[] | undefined)?.join(", ")} />;
},
});
} else {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={value} />;
},
});
}
}

View File

@@ -0,0 +1,33 @@
import { DatePicker } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import advancedFormat from "dayjs/plugin/advancedFormat";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(advancedFormat);
// Localized date time format with timezone
const dateTimeFormat = "YYYY-MM-DD HH:mm:ss";
export function DateTimePicker<T extends string | dayjs.Dayjs>(props: { value?: T; onChange?: (value?: T) => void }) {
return (
<DatePicker
showTime={{ use12Hours: false }}
format={dateTimeFormat}
value={props.value ? dayjs(props.value) : undefined}
onChange={(value) => {
if (value) {
if (typeof props.value === "string") {
props.onChange?.(value.toISOString() as T);
} else {
props.onChange?.(value as T);
}
} else {
props.onChange?.(undefined);
}
}}
/>
);
}

View File

@@ -0,0 +1,211 @@
import { DateField, TextField } from "@refinedev/antd";
import { Field, FieldType } from "../utils/queryFields";
import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField";
import { enrichText } from "../utils/parsing";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { Checkbox, Form, Input, InputNumber, Select, Typography } from "antd";
import { FormItemProps, Rule } from "antd/es/form";
import { DateTimePicker } from "./dateTimePicker";
import { InputNumberRange } from "./inputNumberRange";
dayjs.extend(utc);
const { Title } = Typography;
/**
* Title and value to display an extra field. Used for the show pages.
* @param props
* @returns
*/
export function ExtraFieldDisplay(props: { field: Field; value: string | undefined }) {
const { field, value } = props;
let item;
if (value !== undefined) {
const parsedValue = JSON.parse(value);
if (field.field_type === FieldType.integer) {
item = (
<NumberFieldUnit
value={parsedValue ?? ""}
unit={field.unit ?? ""}
options={{
maximumFractionDigits: 0,
minimumFractionDigits: 0,
}}
/>
);
} else if (field.field_type === FieldType.float) {
item = (
<NumberFieldUnit
value={parsedValue ?? ""}
unit={field.unit ?? ""}
options={{
maximumFractionDigits: 3,
minimumFractionDigits: 0,
}}
/>
);
} else if (field.field_type === FieldType.integer_range || field.field_type === FieldType.float_range) {
if (!Array.isArray(parsedValue) || parsedValue.length !== 2) {
return <TextField value={parsedValue} />;
}
item = (
<NumberFieldUnitRange
value={parsedValue}
unit={field.unit ?? ""}
options={{
maximumFractionDigits: field.field_type === FieldType.float_range ? 3 : 0,
minimumFractionDigits: 0,
}}
/>
);
} else if (field.field_type === FieldType.text) {
item = <TextField value={enrichText(parsedValue)} />;
} else if (field.field_type === FieldType.datetime) {
item = (
<DateField
value={dayjs.utc(parsedValue).local()}
title={dayjs.utc(parsedValue).local().format()}
format="YYYY-MM-DD HH:mm:ss"
/>
);
} else if (field.field_type === FieldType.boolean) {
item = <TextField value={parsedValue ? "Yes" : "No"} />;
} else if (field.field_type === FieldType.choice && !field.multi_choice) {
item = <TextField value={parsedValue} />;
} else if (field.field_type === FieldType.choice && field.multi_choice) {
item = <TextField value={parsedValue.join(", ")} />;
} else {
throw new Error(`Unknown field type: ${field.field_type}`);
}
} else {
item = <></>;
}
return (
<>
<Title level={5}>{field.name}</Title>
{item}
</>
);
}
/**
* Form item for an extra field. Used for the edit pages.
* @param props
* @returns
*/
export function ExtraFieldFormItem(props: { field: Field; setDefaultValue?: boolean }) {
const { field } = props;
let inputNode;
const rules: Rule[] = [
{
required: false,
},
];
const formItemProps: FormItemProps = {};
if (field.field_type === FieldType.integer) {
inputNode = <InputNumber addonAfter={field.unit} precision={0} />;
rules.push({
type: "integer",
});
} else if (field.field_type === FieldType.float) {
inputNode = <InputNumber addonAfter={field.unit} precision={3} />;
rules.push({
type: "number",
});
} else if (field.field_type === FieldType.integer_range) {
inputNode = <InputNumberRange unit={field.unit} precision={0} />;
} else if (field.field_type === FieldType.float_range) {
inputNode = <InputNumberRange unit={field.unit} precision={3} />;
} else if (field.field_type === FieldType.text) {
inputNode = <Input />;
rules.push({
type: "string",
});
} else if (field.field_type === FieldType.datetime) {
inputNode = <DateTimePicker />;
} else if (field.field_type === FieldType.boolean) {
inputNode = <Checkbox />;
formItemProps.valuePropName = "checked";
rules.push({
type: "boolean",
});
} else if (field.field_type === FieldType.choice) {
inputNode = (
<Select
mode={field.multi_choice ? "multiple" : undefined}
options={field.choices?.map((choice) => ({ label: choice, value: choice }))}
/>
);
rules.push({
type: field.multi_choice ? "array" : "string",
});
} else {
throw new Error(`Unknown field type: ${field.field_type}`);
}
if (props.setDefaultValue) {
formItemProps.initialValue = field.default_value;
}
return (
<Form.Item label={field.name} name={["extra", field.key]} rules={rules} {...formItemProps}>
{inputNode}
</Form.Item>
);
}
/**
* Convert the string-based value extra key-values of an entity to their JSON-parsed values.
* @param obj
* @returns
*/
export function ParsedExtras<T extends { extra?: { [key: string]: string } }>(
obj: T
): Omit<T, "extra"> & { extra?: { [key: string]: unknown } } {
if (obj.extra) {
const newExtra: { [key: string]: unknown } = {};
Object.entries(obj.extra).forEach(([key, value]) => {
try {
newExtra[key] = JSON.parse(value);
} catch (e) {
newExtra[key] = value;
}
});
return {
...obj,
extra: newExtra,
};
} else {
return obj;
}
}
/**
* Convert the JSON-parsed value extra key-values of an entity to their string values.
* @param obj
* @returns
*/
export function StringifiedExtras<T extends { extra?: { [key: string]: unknown } }>(
obj: T
): Omit<T, "extra"> & { extra?: { [key: string]: string } } {
if (obj.extra) {
const newExtra: { [key: string]: string } = {};
Object.entries(obj.extra).forEach(([key, value]) => {
newExtra[key] = JSON.stringify(value);
});
return {
...obj,
extra: newExtra,
};
} else {
return {
...obj,
extra: undefined,
};
}
}

View File

@@ -0,0 +1,57 @@
import { InputNumber } from "antd";
function parseValue(value?: (number | null)[]): [number | null, number | null] {
if (value === undefined) {
return [null, null];
}
if (!Array.isArray(value) || value.length !== 2) {
return [null, null];
}
const [min, max] = value;
return [min, max];
}
/**
* An Ant Design compatible form input for a number range.
* @param props
* @returns
*/
export function InputNumberRange(props: {
/** Decimal precision */ precision?: number;
/** Unit to display */ unit?: string;
/** Value state */ value?: (number | null)[] | undefined;
/** Called when input changes. Used to update value state. */ onChange?: (value: (number | null)[]) => void;
}) {
const [min, max] = parseValue(props.value);
return (
<>
<InputNumber
value={min}
precision={props.precision ?? 0}
addonAfter={props.unit}
style={{ maxWidth: 110 }}
onChange={(value) => {
if (props.onChange) {
props.onChange([value, max]);
}
}}
/>
{" \u2013 "}
<InputNumber
value={max}
precision={props.precision ?? 0}
addonAfter={props.unit}
style={{ maxWidth: 110 }}
onChange={(value) => {
if (props.onChange) {
props.onChange([min, value]);
}
}}
/>
</>
);
}

View File

@@ -26,3 +26,36 @@ export const NumberFieldUnit: React.FC<Props> = ({ value, locale, options, ...re
</Text>
);
};
/**
* Like a {@link NumberFieldUnit} but for a range of numbers.
* @param props
* @returns
*/
export function NumberFieldUnitRange(props: {
value: (number | null)[] | undefined;
unit?: string;
options?: Intl.NumberFormatOptions;
}) {
const { value, unit, options } = props;
if (value === undefined) {
console.warn("NumberFieldUnitRange received undefined value");
return <></>;
}
if (!Array.isArray(value) || value.length !== 2) {
console.warn("NumberFieldUnitRange received invalid value", value);
return <></>;
}
const [min, max] = value;
return (
<>
{min === null ? <></> : <NumberFieldUnit value={min} unit={unit ?? ""} options={options} />}
{" \u2013 "}
{max === null ? <></> : <NumberFieldUnit value={max} unit={unit ?? ""} options={options} />}
</>
);
}