Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@
},
"extra_fields": {
"tab": "Extra Fields",
"description": "<p>Here you can add extra custom fields to your entities.</p><p>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 choice state. If you remove a field, the associated data for all entities will be deleted.</p><p>The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",
"description": "<p>Here you can add extra custom fields to your entities.</p><p>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 choice state. If you remove a field, the associated data for all entities will be deleted.</p><p>The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.</p><p>Extra fields can be shown and sorted in the table views. Choice and boolean fields expose filter options there as well, and all extra field types support filtering for empty values.</p>",
"params": {
"key": "Key",
"name": "Name",
Expand Down
213 changes: 199 additions & 14 deletions client/src/components/column.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DateField, TextField } from "@refinedev/antd";
import { UseQueryResult } from "@tanstack/react-query";
import { Button, Col, Dropdown, Row, Space, Spin } from "antd";
import { ColumnFilterItem, ColumnType } from "antd/es/table/interface";
import { Button, Col, DatePicker, Dropdown, Input, InputNumber, Row, Space, Spin } from "antd";
import { ColumnFilterItem, ColumnType, FilterDropdownProps } from "antd/es/table/interface";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { AlignType } from "rc-table/lib/interface";
Expand All @@ -27,6 +27,132 @@ const FilterDropdownLoading = () => {
);
};

function TextFilterDropdown({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) {
const hasValue = selectedKeys.length > 0;
return (
<>
<div style={{ padding: "8px" }}>
<Input
placeholder="Search..."
value={selectedKeys[0] as string}
onChange={(e) => setSelectedKeys(e.target.value ? [e.target.value] : [])}
onPressEnter={() => confirm()}
/>
</div>
<div className="ant-table-filter-dropdown-btns">
<Button type="link" size="small" disabled={!hasValue} onClick={() => { clearFilters?.(); confirm(); }}>
Reset
</Button>
<Button type="primary" size="small" onClick={() => confirm()}>
OK
</Button>
</div>
</>
);
}

function NumberRangeFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
precision,
}: FilterDropdownProps & { precision?: number }) {
const current = selectedKeys[0] as string | undefined;
let minVal: number | null = null;
let maxVal: number | null = null;
if (current && current.includes(":")) {
const parts = current.split(":", 2);
minVal = parts[0] ? Number(parts[0]) : null;
maxVal = parts[1] ? Number(parts[1]) : null;
}

const updateKeys = (min: number | null, max: number | null) => {
if (min === null && max === null) {
setSelectedKeys([]);
} else {
setSelectedKeys([`${min !== null ? min : ""}:${max !== null ? max : ""}`]);
}
};

const hasValue = selectedKeys.length > 0;
return (
<>
<div style={{ padding: "8px", display: "flex", flexDirection: "column", gap: 4 }}>
<InputNumber
placeholder="Min..."
value={minVal}
precision={precision ?? 0}
onChange={(value) => updateKeys(value, maxVal)}
/>
<InputNumber
placeholder="Max..."
value={maxVal}
precision={precision ?? 0}
onChange={(value) => updateKeys(minVal, value)}
/>
</div>
<div className="ant-table-filter-dropdown-btns">
<Button type="link" size="small" disabled={!hasValue} onClick={() => { clearFilters?.(); confirm(); }}>
Reset
</Button>
<Button type="primary" size="small" onClick={() => confirm()}>
OK
</Button>
</div>
</>
);
}

function DateTimeRangeFilterDropdown({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) {
const current = selectedKeys[0] as string | undefined;
let fromVal: dayjs.Dayjs | null = null;
let toVal: dayjs.Dayjs | null = null;
if (current && current.includes("|")) {
const parts = current.split("|", 2);
fromVal = parts[0] ? dayjs(parts[0]) : null;
toVal = parts[1] ? dayjs(parts[1]) : null;
}

const updateKeys = (from: dayjs.Dayjs | null, to: dayjs.Dayjs | null) => {
if (from === null && to === null) {
setSelectedKeys([]);
} else {
setSelectedKeys([`${from ? from.utc().toISOString() : ""}|${to ? to.utc().toISOString() : ""}`]);
}
};

const hasValue = selectedKeys.length > 0;
return (
<>
<div style={{ padding: "8px", display: "flex", flexDirection: "column", gap: 4 }}>
<DatePicker
showTime={{ use12Hours: false }}
format="YYYY-MM-DD HH:mm:ss"
placeholder="From..."
value={fromVal}
onChange={(date) => updateKeys(date, toVal)}
/>
<DatePicker
showTime={{ use12Hours: false }}
format="YYYY-MM-DD HH:mm:ss"
placeholder="To..."
value={toVal}
onChange={(date) => updateKeys(fromVal, date)}
/>
</div>
<div className="ant-table-filter-dropdown-btns">
<Button type="link" size="small" disabled={!hasValue} onClick={() => { clearFilters?.(); confirm(); }}>
Reset
</Button>
<Button type="primary" size="small" onClick={() => confirm()}>
OK
</Button>
</div>
</>
);
}

interface Entity {
id: number;
}
Expand All @@ -40,7 +166,7 @@ export interface Action {

interface BaseColumnProps<Obj extends Entity> {
id: string | string[];
dataId?: keyof Obj & string;
dataId?: (keyof Obj & string) | string; // Allow string values for custom fields
i18ncat?: string;
i18nkey?: string;
title?: string;
Expand All @@ -62,6 +188,7 @@ interface FilteredColumnProps {
allowMultipleFilters?: boolean;
onFilterDropdownOpen?: () => void;
loadingFilters?: boolean;
filterDropdown?: (props: FilterDropdownProps) => React.ReactNode;
}

interface CustomColumnProps<Obj> {
Expand Down Expand Up @@ -98,10 +225,8 @@ function Column<Obj extends Entity>(
// Sorting
if (props.sorter) {
columnProps.sorter = true;
columnProps.sortOrder = getSortOrderForField(
typeSorters<Obj>(props.tableState.sorters),
props.dataId ?? (props.id as keyof Obj),
);
const sortField = props.dataId ?? (Array.isArray(props.id) ? props.id.join(".") : props.id);
columnProps.sortOrder = getSortOrderForField(typeSorters<Obj>(props.tableState.sorters), sortField);
}

// Filter
Expand All @@ -121,6 +246,13 @@ function Column<Obj extends Entity>(
if (props.dataId) {
columnProps.key = props.dataId;
}
} else if (props.filterDropdown) {
columnProps.filterDropdown = props.filterDropdown;
columnProps.filteredValue = props.filteredValue;
columnProps.filterMultiple = false;
if (props.dataId) {
columnProps.key = props.dataId;
}
}

// Render
Expand Down Expand Up @@ -174,7 +306,7 @@ export function SortedColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
}

export function RichColumn<Obj extends Entity>(
props: Omit<BaseColumnProps<Obj>, "transform"> & { transform?: (value: unknown) => string },
props: Omit<BaseColumnProps<Obj>, "transform"> & FilteredColumnProps & { transform?: (value: unknown) => string },
) {
return Column({
...props,
Expand Down Expand Up @@ -207,11 +339,12 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
}
filters.push({
text: "<empty>",
value: "<empty>",
value: "",
});

const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const filterField = props.dataId ?? (Array.isArray(props.id) ? props.id.join(".") : props.id);
const filteredValue = getFiltersForField(typedFilters, filterField);

const onFilterDropdownOpen = () => {
query.refetch();
Expand All @@ -220,7 +353,7 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading });
}

interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj>, FilteredColumnProps {
unit: string;
maxDecimals?: number;
minDecimals?: number;
Expand Down Expand Up @@ -250,7 +383,7 @@ export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>)
});
}

export function DateColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
export function DateColumn<Obj extends Entity>(props: BaseColumnProps<Obj> & FilteredColumnProps) {
return Column({
...props,
render: (rawValue) => {
Expand Down Expand Up @@ -325,7 +458,8 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
});

const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const filterField = props.dataId ?? (Array.isArray(props.id) ? props.id.join(".") : props.id);
const filteredValue = getFiltersForField(typedFilters, filterField);

const onFilterDropdownOpen = () => {
query.refetch();
Expand Down Expand Up @@ -389,13 +523,46 @@ export function NumberRangeColumn<Obj extends Entity>(props: NumberColumnProps<O
});
}

// Helper function to create filter items for custom fields (boolean and choice only)
function createCustomFieldFilters(field: Field): ColumnFilterItem[] {
const filters: ColumnFilterItem[] = [];

if (field.field_type === FieldType.boolean) {
// "No" maps to empty ("") because the backend treats unset and false as equivalent
return [
{ text: "Yes", value: "true" },
{ text: "No / Not set", value: "" },
];
}

// For choice fields, add each choice as a filter option
if (field.field_type === FieldType.choice && field.choices) {
field.choices.forEach((choice) => {
filters.push({
text: choice,
value: `"${choice}"`,
});
});
}

filters.push({ text: "<empty>", value: "" });

return filters;
}

export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProps<Obj>, "id"> & { field: Field }) {
const field = props.field;
const fieldId = `extra.${field.key}`;

const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, fieldId);

const commonProps = {
...props,
id: ["extra", field.key],
title: field.name,
sorter: false,
sorter: true,
dataId: fieldId,
transform: (value: unknown) => {
if (value === null || value === undefined) {
return undefined;
Expand All @@ -407,40 +574,54 @@ export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProp
if (field.field_type === FieldType.integer) {
return NumberColumn({
...commonProps,
filterDropdown: (p: FilterDropdownProps) => <NumberRangeFilterDropdown {...p} precision={0} />,
filteredValue,
unit: field.unit ?? "",
maxDecimals: 0,
});
} else if (field.field_type === FieldType.float) {
return NumberColumn({
...commonProps,
filterDropdown: (p: FilterDropdownProps) => <NumberRangeFilterDropdown {...p} precision={3} />,
filteredValue,
unit: field.unit ?? "",
minDecimals: 0,
maxDecimals: 3,
});
} else if (field.field_type === FieldType.integer_range) {
return NumberRangeColumn({
...commonProps,
filterDropdown: (p: FilterDropdownProps) => <NumberRangeFilterDropdown {...p} precision={0} />,
filteredValue,
unit: field.unit ?? "",
maxDecimals: 0,
});
} else if (field.field_type === FieldType.float_range) {
return NumberRangeColumn({
...commonProps,
filterDropdown: (p: FilterDropdownProps) => <NumberRangeFilterDropdown {...p} precision={3} />,
filteredValue,
unit: field.unit ?? "",
minDecimals: 0,
maxDecimals: 3,
});
} else if (field.field_type === FieldType.text) {
return RichColumn({
...commonProps,
filterDropdown: TextFilterDropdown,
filteredValue,
});
} else if (field.field_type === FieldType.datetime) {
return DateColumn({
...commonProps,
filterDropdown: DateTimeRangeFilterDropdown,
filteredValue,
});
} else if (field.field_type === FieldType.boolean) {
return Column({
...commonProps,
filters: createCustomFieldFilters(field),
filteredValue,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
let text;
Expand All @@ -457,6 +638,8 @@ export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProp
} else if (field.field_type === FieldType.choice && !field.multi_choice) {
return Column({
...commonProps,
filters: createCustomFieldFilters(field),
filteredValue,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={value} />;
Expand All @@ -465,6 +648,8 @@ export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProp
} else if (field.field_type === FieldType.choice && field.multi_choice) {
return Column({
...commonProps,
filters: createCustomFieldFilters(field),
filteredValue,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={(value as string[] | undefined)?.join(", ")} />;
Expand Down
Loading