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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
TextField
} from "@firecms/ui";
import { EnumValueConfig } from "../../../types";
import { filterValueToText, parseFilterTextInput } from "./filter_text_input";

interface StringNumberFilterFieldProps {
name: string,
Expand Down Expand Up @@ -60,8 +61,15 @@ export function StringNumberFilterField({
const [fieldOperation, fieldValue] = value || [possibleOperations[0], undefined];
const [operation, setOperation] = useState<VirtualTableWhereFilterOp | "is-null">(fieldOperation === "==" && fieldValue === null ? "is-null" : fieldOperation);
const [internalValue, setInternalValue] = useState<string | number | string[] | number[] | null | undefined>(fieldValue);
// Raw text of the text input, kept apart from `internalValue` so that intermediate
// inputs like "-", "0." or ".0" are not destroyed while the user is typing.
const [inputText, setInputText] = useState<string>(filterValueToText(fieldValue));
// Value the current `inputText` was built from, used to tell updates coming from
// outside apart from the ones triggered by the user typing.
const syncedValueRef = React.useRef(fieldValue);

React.useEffect(() => {
const newValue = value ? value[1] : undefined;
if (value) {
const [op, val] = value;
setOperation(op === "==" && val === null ? "is-null" : op);
Expand All @@ -70,15 +78,25 @@ export function StringNumberFilterField({
setOperation(possibleOperations[0]);
setInternalValue(undefined);
}
// only reset the text when the value was changed somewhere else, so we don't
// interfere with what the user is currently typing
if (syncedValueRef.current !== newValue) {
syncedValueRef.current = newValue;
setInputText(filterValueToText(newValue));
}
}, [value, possibleOperations[0]]);

const isNullOperation = operation === "is-null";

function updateFilter(op: VirtualTableWhereFilterOp | "is-null", val: string | number | string[] | number[] | null | undefined) {
// `keepInputText` is used when the update originates from the text input, where the
// text typed by the user is authoritative and must not be overwritten with `val`
function updateFilter(op: VirtualTableWhereFilterOp | "is-null", val: string | number | string[] | number[] | null | undefined, keepInputText = false) {
// Handle "is null" operation
if (op === "is-null") {
setOperation(op);
setInternalValue(null);
syncedValueRef.current = null;
setInputText("");
setValue(["==", null]);
return;
}
Expand All @@ -96,6 +114,9 @@ export function StringNumberFilterField({

setOperation(op);
setInternalValue(newValue);
syncedValueRef.current = newValue;
if (!keepInputText)
setInputText(filterValueToText(newValue));

const hasNewValue = newValue !== null && Array.isArray(newValue)
? newValue.length > 0
Expand All @@ -111,6 +132,14 @@ export function StringNumberFilterField({
}
}

function updateFilterFromText(text: string) {
setInputText(text);
const { updateValue, value: parsedValue } = parseFilterTextInput(text, dataType);
// intermediate inputs like "-" or "0." keep the filter as it is
if (updateValue)
updateFilter(operation, parsedValue, true);
}

const multiple = multipleSelectOperations.includes(operation);

return (
Expand Down Expand Up @@ -138,16 +167,13 @@ export function StringNumberFilterField({
{!enumValues && <TextField
size={"medium"}
type={dataType === "number" ? "number" : undefined}
value={internalValue !== undefined && internalValue != null ? String(internalValue) : ""}
value={inputText}
disabled={isNullOperation}
placeholder={isNullOperation ? "null" : undefined}
onChange={(evt) => {
const val = dataType === "number"
? parseFloat(evt.target.value)
: evt.target.value;
updateFilter(operation, val);
updateFilterFromText(evt.target.value);
}}
endAdornment={internalValue !== undefined && internalValue != null && <IconButton
endAdornment={inputText !== "" && <IconButton
onClick={(e) => updateFilter(operation, undefined)}>
<CloseIcon />
</IconButton>}
Comment on lines +176 to 179
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { describe, expect, it } from "@jest/globals";
import { filterValueToText, parseFilterTextInput } from "./filter_text_input";

/**
* Type `text` one character at a time, the way the filter field does, and keep track
* of the filter value left after every keystroke.
*/
function typeCharByChar(text: string, dataType: "string" | "number" = "number") {
let value: string | number | undefined;
const valueAfterEachKeystroke: (string | number | undefined)[] = [];
for (let i = 1; i <= text.length; i++) {
const result = parseFilterTextInput(text.slice(0, i), dataType);
if (result.updateValue)
value = result.value;
valueAfterEachKeystroke.push(value);
}
return {
value,
valueAfterEachKeystroke
};
}

describe("parseFilterTextInput with numbers", () => {

it("should parse complete numbers", () => {
expect(parseFilterTextInput("0.01", "number")).toEqual({
updateValue: true,
value: 0.01
});
expect(parseFilterTextInput("1", "number")).toEqual({
updateValue: true,
value: 1
});
expect(parseFilterTextInput("1.5", "number")).toEqual({
updateValue: true,
value: 1.5
});
expect(parseFilterTextInput("-0.5", "number")).toEqual({
updateValue: true,
value: -0.5
});
expect(parseFilterTextInput(".5", "number")).toEqual({
updateValue: true,
value: 0.5
});
expect(parseFilterTextInput("1e5", "number")).toEqual({
updateValue: true,
value: 100000
});
expect(parseFilterTextInput("0.0100", "number")).toEqual({
updateValue: true,
value: 0.01
});
});

it("should keep 0 as a valid filter value", () => {
expect(parseFilterTextInput("0", "number")).toEqual({
updateValue: true,
value: 0
});
expect(parseFilterTextInput("0.0", "number")).toEqual({
updateValue: true,
value: 0
});
expect(parseFilterTextInput("00", "number")).toEqual({
updateValue: true,
value: 0
});
expect(parseFilterTextInput("-0", "number").updateValue).toBe(true);
});

it("should clear the filter for an empty input", () => {
expect(parseFilterTextInput("", "number")).toEqual({
updateValue: true,
value: undefined
});
expect(parseFilterTextInput(" ", "number")).toEqual({
updateValue: true,
value: undefined
});
});

it("should not touch the filter value for intermediate inputs", () => {
// these are states the user goes through while typing, the text must be kept
// in the input without the filter value being changed
expect(parseFilterTextInput("0.", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput(".", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("1.", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("-", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("-.", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("+", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("-0.", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("1e", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("1e-", "number")).toEqual({ updateValue: false });
});

it("should not touch the filter value for text that is not a number", () => {
expect(parseFilterTextInput("abc", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("12abc", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("1.2.3", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("--1", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("0x10", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("Infinity", "number")).toEqual({ updateValue: false });
expect(parseFilterTextInput("1e999", "number")).toEqual({ updateValue: false });
});

it("should allow typing 0.01 character by character", () => {
const { value, valueAfterEachKeystroke } = typeCharByChar("0.01");
expect(value).toBe(0.01);
// "0" -> 0, "0." -> unchanged, "0.0" -> 0, "0.01" -> 0.01
expect(valueAfterEachKeystroke).toEqual([0, 0, 0, 0.01]);
});

it("should allow typing other decimals character by character", () => {
expect(typeCharByChar("0.001").value).toBe(0.001);
expect(typeCharByChar("-0.5").value).toBe(-0.5);
expect(typeCharByChar(".5").value).toBe(0.5);
expect(typeCharByChar(".01").value).toBe(0.01);
expect(typeCharByChar("1.05").value).toBe(1.05);
expect(typeCharByChar("10.00001").value).toBe(10.00001);
expect(typeCharByChar("-.5").value).toBe(-0.5);
});

it("should keep the previous value while typing a trailing decimal separator", () => {
// typing "1." leaves the value at 1, the text "1." stays in the input
expect(typeCharByChar("1.").value).toBe(1);
expect(typeCharByChar("1.").valueAfterEachKeystroke).toEqual([1, 1]);
});

it("should not commit a value until a leading sign is followed by digits", () => {
expect(typeCharByChar("-").value).toBeUndefined();
expect(typeCharByChar("-1").valueAfterEachKeystroke).toEqual([undefined, -1]);
});

it("should clear the value when the input is emptied", () => {
// deleting "0.01" one character at a time, from the end
const texts = ["0.0", "0.", "0", ""];
let value: string | number | undefined = 0.01;
for (const text of texts) {
const result = parseFilterTextInput(text, "number");
if (result.updateValue)
value = result.value;
}
expect(value).toBeUndefined();
});
});

describe("parseFilterTextInput with strings", () => {

it("should use the text as the value", () => {
expect(parseFilterTextInput("abc", "string")).toEqual({
updateValue: true,
value: "abc"
});
expect(parseFilterTextInput("0.", "string")).toEqual({
updateValue: true,
value: "0."
});
expect(parseFilterTextInput("", "string")).toEqual({
updateValue: true,
value: ""
});
expect(parseFilterTextInput(" spaced ", "string")).toEqual({
updateValue: true,
value: " spaced "
});
});
});

describe("filterValueToText", () => {

it("should render values as text", () => {
expect(filterValueToText(0)).toBe("0");
expect(filterValueToText(0.01)).toBe("0.01");
expect(filterValueToText(-0.5)).toBe("-0.5");
expect(filterValueToText("abc")).toBe("abc");
expect(filterValueToText("")).toBe("");
});

it("should render empty text for no value", () => {
expect(filterValueToText(undefined)).toBe("");
expect(filterValueToText(null)).toBe("");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Matches text that is a complete number, as opposed to an intermediate state
* that the user may be in the middle of typing, like "-", "0." or ".".
* A decimal separator must be followed by at least one digit.
*/
const completeNumberRegex = /^[+-]?(?:\d+|\d*\.\d+)(?:[eE][+-]?\d+)?$/;

export type FilterTextInputParseResult = {
/**
* Whether the filter value should be updated with `value`.
* It is `false` for intermediate inputs that are not a valid value yet,
* like "-" or "0.", so that the text typed by the user can be kept as it is
* while the current filter value is left untouched.
*/
updateValue: boolean;

/**
* Parsed value, only relevant when `updateValue` is `true`.
* `undefined` means the filter should be cleared.
*/
value?: string | number;
};

/**
* Turn the raw text of a filter input into the value used by the filter.
*
* The text shown to the user is never derived back from the parsed value, so
* intermediate states like "0.", ".0" or "-" survive while typing. Numbers are
* only committed to the filter once the text is a complete number, which makes
* values such as 0.01 reachable by typing them character by character.
*
* Note that `0` is a perfectly valid filter value, only an empty input clears
* the filter.
*/
export function parseFilterTextInput(text: string, dataType: "string" | "number"): FilterTextInputParseResult {

if (dataType !== "number")
return {
updateValue: true,
value: text
};

const trimmedText = text.trim();

if (trimmedText === "")
return {
updateValue: true,
value: undefined
};

if (!completeNumberRegex.test(trimmedText))
return { updateValue: false };

const parsedValue = Number(trimmedText);
if (!isFinite(parsedValue))
return { updateValue: false };

return {
updateValue: true,
value: parsedValue
};
}

/**
* Text a filter input should display for a given filter value.
*/
export function filterValueToText(value: unknown): string {
if (value === undefined || value === null)
return "";
return String(value);
}
Loading