+ );
+}
+
function AddFilterPopover({
availableColumns,
control,
@@ -138,7 +193,8 @@ function AddFilterPopover({
);
const canSubmit =
- selectedColumn != null && isAddFilterDraftValueValid(selectedColumn.filter.type, value);
+ selectedColumn != null &&
+ isAddFilterDraftValueValid(selectedColumn.filter.type, operator, value);
const initDraft = useCallback((column: FilterableColumn | null) => {
if (!column) {
@@ -180,12 +236,12 @@ function AddFilterPopover({
const handleSubmit = useCallback(() => {
if (!selectedColumn) return;
- if (!isAddFilterDraftValueValid(selectedColumn.filter.type, value)) return;
+ if (!isAddFilterDraftValueValid(selectedColumn.filter.type, operator, value)) return;
control.addFilter(
selectedColumn.filter.field,
operator,
- toAddFilterSubmittedValue(selectedColumn.filter.type, value),
+ toAddFilterSubmittedValue(selectedColumn.filter.type, operator, value),
);
setOpen(false);
}, [selectedColumn, value, operator, control]);
@@ -256,6 +312,19 @@ function AddFilterPopover({
}
if (isTemporalFilterType(config.type)) {
+ if (operator === "between") {
+ const [min, max] = Array.isArray(value) ? value : ["", ""];
+ return (
+ setValue([v, max])}
+ onChangeMax={(v) => setValue([min, v])}
+ onSubmit={handleSubmit}
+ inputProps={getTemporalInputProps(config.type)}
+ />
+ );
+ }
return (
setValue([v, max])}
+ onChangeMax={(v) => setValue([min, v])}
+ onSubmit={handleSubmit}
+ inputProps={{ type: "number" }}
+ />
+ );
+ }
return (
@@ -336,7 +418,13 @@ function AddFilterPopover({
items={operatorItems}
value={operator}
onValueChange={(nextOp) => {
- if (nextOp) setOperator(nextOp);
+ if (!nextOp) return;
+ const wasBetween = operator === "between";
+ const isBetween = nextOp === "between";
+ setOperator(nextOp);
+ if (wasBetween !== isBetween) {
+ setValue(isBetween ? ["", ""] : "");
+ }
}}
mapItem={(op) => ({
value: op,
@@ -747,17 +835,59 @@ function NumericFilterEditor({
? (filter.operator as NumericTemporalOperator)
: "eq",
);
- const [localValue, setLocalValue] = useState(String(filter.value ?? ""));
+ const [localValue, setLocalValue] = useState(() => {
+ if (filter.operator === "between" && typeof filter.value === "object" && filter.value != null) {
+ const range = filter.value as { min?: unknown; max?: unknown };
+ return String(range.min ?? "");
+ }
+ return String(filter.value ?? "");
+ });
+ const [localValueMax, setLocalValueMax] = useState(() => {
+ if (filter.operator === "between" && typeof filter.value === "object" && filter.value != null) {
+ const range = filter.value as { min?: unknown; max?: unknown };
+ return String(range.max ?? "");
+ }
+ return "";
+ });
+
+ const canCommit = (() => {
+ if (localOp === "between") {
+ const minEmpty = localValue.trim() === "";
+ const maxEmpty = localValueMax.trim() === "";
+ if (minEmpty && maxEmpty) return true; // will removeFilter
+ if (minEmpty || maxEmpty) return false; // both required
+ return !Number.isNaN(Number(localValue)) && !Number.isNaN(Number(localValueMax));
+ }
+ return localValue.trim() === "" || !Number.isNaN(Number(localValue));
+ })();
const handleCommit = useCallback(() => {
- const num = Number(localValue);
- if (localValue.trim() === "" || Number.isNaN(num)) {
- control.removeFilter(config.field);
+ if (localOp === "between") {
+ const minEmpty = localValue.trim() === "";
+ const maxEmpty = localValueMax.trim() === "";
+ if (minEmpty && maxEmpty) {
+ control.removeFilter(config.field);
+ } else if (!minEmpty && !maxEmpty) {
+ const min = Number(localValue);
+ const max = Number(localValueMax);
+ if (!Number.isNaN(min) && !Number.isNaN(max)) {
+ control.addFilter(config.field, localOp, { min, max });
+ } else {
+ return;
+ }
+ } else {
+ return;
+ }
} else {
- control.addFilter(config.field, localOp, num);
+ const num = Number(localValue);
+ if (localValue.trim() === "" || Number.isNaN(num)) {
+ control.removeFilter(config.field);
+ } else {
+ control.addFilter(config.field, localOp, num);
+ }
}
onClose();
- }, [localValue, localOp, control, config.field, onClose]);
+ }, [localValue, localValueMax, localOp, control, config.field, onClose]);
return (
({ value: op, label: t(`filterOperator_${op}`) })}
className="astw:h-8 astw:text-sm"
/>
- setLocalValue(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") handleCommit();
- }}
- className="astw:h-8 astw:text-sm"
- />
-
@@ -810,19 +951,60 @@ function TemporalFilterEditor({
? (filter.operator as NumericTemporalOperator)
: "eq",
);
- const [localValue, setLocalValue] = useState(String(filter.value ?? ""));
- const canCommit = localValue.trim() === "" || isTemporalFilterValueValid(config.type, localValue);
+ const [localValue, setLocalValue] = useState(() => {
+ if (filter.operator === "between" && typeof filter.value === "object" && filter.value != null) {
+ const range = filter.value as { min?: unknown; max?: unknown };
+ return String(range.min ?? "");
+ }
+ return String(filter.value ?? "");
+ });
+ const [localValueMax, setLocalValueMax] = useState(() => {
+ if (filter.operator === "between" && typeof filter.value === "object" && filter.value != null) {
+ const range = filter.value as { min?: unknown; max?: unknown };
+ return String(range.max ?? "");
+ }
+ return "";
+ });
+
+ const canCommit = (() => {
+ if (localOp === "between") {
+ const minEmpty = localValue.trim() === "";
+ const maxEmpty = localValueMax.trim() === "";
+ if (minEmpty && maxEmpty) return true; // will removeFilter
+ if (minEmpty || maxEmpty) return false; // both required
+ return (
+ isTemporalFilterValueValid(config.type, localValue) &&
+ isTemporalFilterValueValid(config.type, localValueMax)
+ );
+ }
+ return localValue.trim() === "" || isTemporalFilterValueValid(config.type, localValue);
+ })();
const handleCommit = useCallback(() => {
- if (localValue.trim() === "") {
- control.removeFilter(config.field);
- } else if (isTemporalFilterValueValid(config.type, localValue)) {
- control.addFilter(config.field, localOp, localValue);
+ if (localOp === "between") {
+ const minEmpty = localValue.trim() === "";
+ const maxEmpty = localValueMax.trim() === "";
+ if (minEmpty && maxEmpty) {
+ control.removeFilter(config.field);
+ } else if (!minEmpty && !maxEmpty) {
+ const minValid = isTemporalFilterValueValid(config.type, localValue);
+ const maxValid = isTemporalFilterValueValid(config.type, localValueMax);
+ if (!minValid || !maxValid) return;
+ control.addFilter(config.field, localOp, { min: localValue, max: localValueMax });
+ } else {
+ return;
+ }
} else {
- return;
+ if (localValue.trim() === "") {
+ control.removeFilter(config.field);
+ } else if (isTemporalFilterValueValid(config.type, localValue)) {
+ control.addFilter(config.field, localOp, localValue);
+ } else {
+ return;
+ }
}
onClose();
- }, [localValue, localOp, control, config.field, config.type, onClose]);
+ }, [localValue, localValueMax, localOp, control, config.field, config.type, onClose]);
return (
({ value: op, label: t(`filterOperator_${op}`) })}
className="astw:h-8 astw:text-sm"
/>
- {
- setLocalValue(e.target.value);
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- handleCommit();
- }
- }}
- className="astw:h-8 astw:text-sm"
- />
+ {localOp === "between" ? (
+
+ ) : (
+ {
+ setLocalValue(e.target.value);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleCommit();
+ }
+ }}
+ className="astw:h-8 astw:text-sm"
+ />
+ )}
{t("applyFilter")}
@@ -888,6 +1081,7 @@ function getInitialAddFilterDraftValue(type: FilterConfig["type"]): AddFilterDra
function isAddFilterDraftValueValid(
type: FilterConfig["type"],
+ operator: FilterOperator,
value: AddFilterDraftValue,
): boolean {
if (type === "enum") {
@@ -897,6 +1091,21 @@ function isAddFilterDraftValueValid(
return value === "true" || value === "false";
}
+ if (operator === "between") {
+ if (!Array.isArray(value)) return false;
+ const [min, max] = value;
+ const minEmpty = !min || min.trim() === "";
+ const maxEmpty = !max || max.trim() === "";
+ if (minEmpty || maxEmpty) return false; // both required
+ if (type === "number") {
+ return !Number.isNaN(Number(min)) && !Number.isNaN(Number(max));
+ }
+ if (isTemporalFilterType(type)) {
+ return isTemporalFilterValueValid(type, min) && isTemporalFilterValueValid(type, max);
+ }
+ return true;
+ }
+
if (typeof value !== "string") return false;
if (type === "number") {
if (value.trim() === "") return false;
@@ -910,6 +1119,7 @@ function isAddFilterDraftValueValid(
function toAddFilterSubmittedValue(
type: FilterConfig["type"],
+ operator: FilterOperator,
value: AddFilterDraftValue,
): unknown {
if (type === "enum") {
@@ -918,6 +1128,28 @@ function toAddFilterSubmittedValue(
if (type === "boolean") {
return value === "true";
}
+
+ if (operator === "between" && Array.isArray(value)) {
+ const [min, max] = value;
+ const trimmedMin = typeof min === "string" ? min.trim() : "";
+ const trimmedMax = typeof max === "string" ? max.trim() : "";
+
+ if (type === "number") {
+ if (trimmedMin === "" || trimmedMax === "") return undefined;
+
+ const parsedMin = Number(trimmedMin);
+ const parsedMax = Number(trimmedMax);
+ if (Number.isNaN(parsedMin) || Number.isNaN(parsedMax)) return undefined;
+
+ return { min: parsedMin, max: parsedMax };
+ }
+
+ if (trimmedMin === "" || trimmedMax === "") return undefined;
+
+ // temporal types
+ return { min: trimmedMin, max: trimmedMax };
+ }
+
if (type === "number") {
return Number(value);
}
@@ -1024,6 +1256,14 @@ function formatFilterValue(
return [min, max].filter(Boolean).join(" - ");
}
+ if (isTemporalFilterType(config.type) && filter.operator === "between") {
+ const range = filter.value as { min?: unknown; max?: unknown } | null;
+ if (!range || typeof range !== "object") return "";
+ const min = range.min != null ? String(range.min) : "";
+ const max = range.max != null ? String(range.max) : "";
+ return [min, max].filter(Boolean).join(" - ");
+ }
+
if (Array.isArray(filter.value)) {
return filter.value.map((v) => String(v)).join(", ");
}