diff --git a/package.json b/package.json index af9119bd..e3fe9dc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.34", + "version": "5.0.36-beta.1", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { diff --git a/src/components/mui/Dropdown/index.jsx b/src/components/mui/Dropdown/index.jsx index 96bdb834..5d2dedce 100644 --- a/src/components/mui/Dropdown/index.jsx +++ b/src/components/mui/Dropdown/index.jsx @@ -86,7 +86,7 @@ Dropdown.propTypes = { label: PropTypes.string.isRequired, disabled: PropTypes.bool }) - ).isRequired, + ), label: PropTypes.string, placeholder: PropTypes.string, onChange: PropTypes.func.isRequired @@ -95,7 +95,8 @@ Dropdown.propTypes = { Dropdown.defaultProps = { value: null, label: "", - placeholder: "" + placeholder: "", + options: null }; export default Dropdown; diff --git a/src/components/mui/GridFilter/GridFilter.jsx b/src/components/mui/GridFilter/GridFilter.jsx index 67519d20..88282e65 100644 --- a/src/components/mui/GridFilter/GridFilter.jsx +++ b/src/components/mui/GridFilter/GridFilter.jsx @@ -29,7 +29,7 @@ import Filter from "./components/Filter"; import FilterButton from "./components/FilterButton"; import { saveFilters } from "./actions/filter-actions"; import useGridFilter from "./hooks/useGridFilter"; -import { JOIN_OPERATORS, OPERATORS, EMPTY_FILTER } from "./utils"; +import { JOIN_OPERATORS, OPERATORS, EMPTY_FILTER, ASYNC_VALUE_TYPES } from "./utils"; const OPERATOR_VALUES = Object.values(OPERATORS).map((op) => op.value); @@ -55,14 +55,21 @@ const GridFilter = ({ id, criterias, hideJoinOperators = false, onApply, saveFil }, [valuesString, joinOperator, openModal]); const parseFilter = (filter) => { - const parser = criterias.find( - ({ key }) => key === filter.criteria - )?.customParser; + const criteria = criterias.find(({ key }) => key === filter.criteria); + const parser = criteria?.customParser; + + if (!parser && ASYNC_VALUE_TYPES.includes(criteria?.values?.type)) { + console.error( + `GridFilter: criteria "${filter.criteria}" uses async value type "${criteria.values.type}" but defines no customParser — its value will not serialize into the API filter string correctly.` + ); + } if (parser) { return parser(filter); } + // TODO: use escapeFilterValue + const value = Array.isArray(filter.value) ? filter.value.join("||") : filter.value; diff --git a/src/components/mui/GridFilter/components/Filter.jsx b/src/components/mui/GridFilter/components/Filter.jsx index eadb8be7..9996237b 100644 --- a/src/components/mui/GridFilter/components/Filter.jsx +++ b/src/components/mui/GridFilter/components/Filter.jsx @@ -83,32 +83,37 @@ const Filter = ({ id, value, criterias, onChange, onAdd, onDelete }) => { - - - + + + + + + + + + diff --git a/src/components/mui/GridFilter/components/ValueInput/AsyncSelectInput.jsx b/src/components/mui/GridFilter/components/ValueInput/AsyncSelectInput.jsx new file mode 100644 index 00000000..5d50fea9 --- /dev/null +++ b/src/components/mui/GridFilter/components/ValueInput/AsyncSelectInput.jsx @@ -0,0 +1,155 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React, { useEffect, useRef, useState } from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; +import CircularProgress from "@mui/material/CircularProgress"; +import { DEBOUNCE_WAIT_250 } from "../../../../../utils/constants"; + +const defaultFormatOption = (item) => ({ + value: item.id, + label: item.name +}); + +const optionShape = PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + label: PropTypes.string, + raw: PropTypes.object +}); + +const AsyncSelectInput = ({ + id, + value, + label, + placeholder, + disabled, + multiple, + queryFunction, + formatOption, + debounceWait, + minSearchLength, + onChange, + ...rest +}) => { + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const debounceRef = useRef(null); + + // Filter.jsx passes `options` generically to every ValueInput type (meant + // for the sync `select` type); this type fetches its own, so it's stripped + // out here rather than spread onto the Autocomplete below. + const { options: _staleOptions, ...autocompleteProps } = rest; + + const fetchOptions = (searchTerm) => { + if (searchTerm && searchTerm.length < minSearchLength) { + setOptions([]); + return; + } + setLoading(true); + queryFunction(searchTerm, (rawResults) => { + setOptions((rawResults || []).map((item) => ({ ...formatOption(item), raw: item }))); + setLoading(false); + }); + }; + + useEffect(() => { + fetchOptions(""); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleInputChange = (event, newInputValue, reason) => { + if (reason !== "input") return; + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => fetchOptions(newInputValue), debounceWait); + }; + + const handleChange = (event, selected) => { + onChange({ target: { value: multiple ? selected || [] : selected || null } }); + }; + + // Filter.jsx's single-value default is "" (not null); treat it as empty. + const normalizedValue = multiple ? value || [] : value || null; + const finalPlaceholder = + placeholder || T.translate("grid_filter.placeholders.async"); + + return ( + option?.label || ""} + isOptionEqualToValue={(option, val) => option.value === val.value} + renderInput={(params) => ( + + {loading && } + {params.InputProps?.endAdornment} + + ) + } + }} + /> + )} + // eslint-disable-next-line react/jsx-props-no-spreading + {...autocompleteProps} + /> + ); +}; + +AsyncSelectInput.propTypes = { + id: PropTypes.string.isRequired, + value: PropTypes.oneOfType([optionShape, PropTypes.arrayOf(optionShape), PropTypes.string]), + label: PropTypes.string, + placeholder: PropTypes.string, + disabled: PropTypes.bool, + multiple: PropTypes.bool, + queryFunction: PropTypes.func.isRequired, + formatOption: PropTypes.func, + debounceWait: PropTypes.number, + minSearchLength: PropTypes.number, + onChange: PropTypes.func.isRequired +}; + +AsyncSelectInput.defaultProps = { + value: null, + label: "", + placeholder: "", + disabled: false, + multiple: false, + formatOption: defaultFormatOption, + debounceWait: DEBOUNCE_WAIT_250, + minSearchLength: 0 +}; + +export default AsyncSelectInput; diff --git a/src/components/mui/GridFilter/components/ValueInput/CompanySelectInput.jsx b/src/components/mui/GridFilter/components/ValueInput/CompanySelectInput.jsx new file mode 100644 index 00000000..1b60dd79 --- /dev/null +++ b/src/components/mui/GridFilter/components/ValueInput/CompanySelectInput.jsx @@ -0,0 +1,44 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import AsyncSelectInput from "./AsyncSelectInput"; +import { queryCompanies } from "../../../../../utils/query-actions"; + +const defaultFormatOption = (company) => ({ + value: company.id, + label: company.name +}); + +const CompanySelectInput = ({ queryFunction, placeholder, ...rest }) => ( + +); + +CompanySelectInput.propTypes = { + queryFunction: PropTypes.func, + placeholder: PropTypes.string +}; + +CompanySelectInput.defaultProps = { + queryFunction: null, + formatOption: defaultFormatOption +}; + +export default CompanySelectInput; diff --git a/src/components/mui/GridFilter/components/ValueInput/DateTimeInput.jsx b/src/components/mui/GridFilter/components/ValueInput/DateTimeInput.jsx new file mode 100644 index 00000000..39e74131 --- /dev/null +++ b/src/components/mui/GridFilter/components/ValueInput/DateTimeInput.jsx @@ -0,0 +1,99 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import moment from "moment-timezone"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment"; + +// mode controls which views the single DateTimePicker exposes, keeping the +// stored value a unix timestamp (the convention used across this app) in +// every case, regardless of whether the user picks a date, a time, or both. +const MODE_VIEWS = { + date: ["year", "month", "day"], + time: ["hours", "minutes"], + datetime: ["year", "month", "day", "hours", "minutes"] +}; + +const MODE_FORMATS = { + date: "MM/DD/YYYY", + time: "hh:mm A", + datetime: "MM/DD/YYYY hh:mm A" +}; + +const DateTimeInput = ({ + id, + value, + mode, + label, + placeholder, + disabled, + onChange, + ...rest +}) => { + const momentValue = value ? moment.unix(value) : null; + const finalPlaceholder = + placeholder || T.translate("grid_filter.placeholders.date"); + + const handleChange = (newValue) => { + onChange({ + target: { value: newValue?.isValid() ? newValue.unix() : null } + }); + }; + + return ( + + + + ); +}; + +DateTimeInput.propTypes = { + id: PropTypes.string.isRequired, + value: PropTypes.number, + mode: PropTypes.oneOf(["date", "time", "datetime"]), + label: PropTypes.string, + placeholder: PropTypes.string, + disabled: PropTypes.bool, + onChange: PropTypes.func.isRequired +}; + +DateTimeInput.defaultProps = { + value: null, + mode: "datetime", + label: "", + placeholder: "", + disabled: false +}; + +export default DateTimeInput; diff --git a/src/components/mui/GridFilter/components/ValueInput/NumberInput.jsx b/src/components/mui/GridFilter/components/ValueInput/NumberInput.jsx new file mode 100644 index 00000000..54693484 --- /dev/null +++ b/src/components/mui/GridFilter/components/ValueInput/NumberInput.jsx @@ -0,0 +1,108 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import PropTypes from "prop-types"; +import TextField from "@mui/material/TextField"; +import T from "i18n-react/dist/i18n-react"; + +const BLOCKED_KEYS = ["e", "E"]; + +const NumberInput = ({ + id, + value, + label, + placeholder, + disabled, + min, + max, + integer, + onChange, + ...rest +}) => { + const handleChange = (e) => { + const raw = e.target.value; + if (raw === "") { + onChange({ target: { value: null } }); + return; + } + + const parsed = integer ? parseInt(raw, 10) : parseFloat(raw); + // React skips forcing the DOM value of a focused number input back to the + // controlled value when the typed string doesn't parse yet (e.g. "-" or + // "1."), so just wait for more input instead of clobbering it. + if (Number.isNaN(parsed)) return; + + let clamped = parsed; + if (min != null) clamped = Math.max(clamped, min); + if (max != null) clamped = Math.min(clamped, max); + // only force-normalize the DOM when clamping changed the typed value; + // otherwise leave it alone so e.g. a trailing "." isn't stripped mid-typing + if (clamped !== parsed) e.target.value = clamped; + onChange({ target: { value: clamped } }); + }; + + const finalPlaceholder = + placeholder || T.translate("grid_filter.placeholders.number"); + + return ( + { + if (BLOCKED_KEYS.includes(e.key)) e.preventDefault(); + if (integer && (e.key === "." || e.key === ",")) e.preventDefault(); + }} + slotProps={{ + htmlInput: { + ...(min != null ? { min } : {}), + ...(max != null ? { max } : {}), + ...(integer ? { step: 1 } : {}) + } + }} + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + /> + ); +}; + +NumberInput.propTypes = { + id: PropTypes.string.isRequired, + value: PropTypes.number, + label: PropTypes.string, + placeholder: PropTypes.string, + disabled: PropTypes.bool, + min: PropTypes.number, + max: PropTypes.number, + integer: PropTypes.bool, + onChange: PropTypes.func.isRequired +}; + +NumberInput.defaultProps = { + value: null, + label: "", + placeholder: "", + disabled: false, + min: null, + max: null, + integer: false +}; + +export default NumberInput; diff --git a/src/components/mui/GridFilter/components/ValueInput/SpeakerSelectInput.jsx b/src/components/mui/GridFilter/components/ValueInput/SpeakerSelectInput.jsx new file mode 100644 index 00000000..b2ee55ac --- /dev/null +++ b/src/components/mui/GridFilter/components/ValueInput/SpeakerSelectInput.jsx @@ -0,0 +1,46 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import AsyncSelectInput from "./AsyncSelectInput"; +import { querySpeakers } from "../../../../../utils/query-actions"; + +const defaultFormatOption = (speaker) => ({ + value: speaker.id, + label: `${speaker.first_name} ${speaker.last_name} (${speaker.email || speaker.id})` +}); + +const SpeakerSelectInput = ({ summitId, queryFunction, placeholder, ...rest }) => ( + querySpeakers(summitId, input, callback))} + placeholder={placeholder || T.translate("grid_filter.placeholders.speaker")} + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + /> +); + +SpeakerSelectInput.propTypes = { + summitId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + queryFunction: PropTypes.func, + placeholder: PropTypes.string +}; + +SpeakerSelectInput.defaultProps = { + summitId: null, + queryFunction: null, + formatOption: defaultFormatOption +}; + +export default SpeakerSelectInput; diff --git a/src/components/mui/GridFilter/components/ValueInput/index.jsx b/src/components/mui/GridFilter/components/ValueInput/index.jsx index ab4abe92..c565f1b7 100644 --- a/src/components/mui/GridFilter/components/ValueInput/index.jsx +++ b/src/components/mui/GridFilter/components/ValueInput/index.jsx @@ -15,8 +15,21 @@ import React from "react"; import TextField from "@mui/material/TextField"; import PropTypes from "prop-types"; import Dropdown from "../../../Dropdown"; +import DateTimeInput from "./DateTimeInput"; +import NumberInput from "./NumberInput"; +import AsyncSelectInput from "./AsyncSelectInput"; +import SpeakerSelectInput from "./SpeakerSelectInput"; +import CompanySelectInput from "./CompanySelectInput"; -const INPUT_TYPE_MAP = { text: TextField, select: Dropdown }; +const INPUT_TYPE_MAP = { + text: TextField, + select: Dropdown, + datetime: DateTimeInput, + number: NumberInput, + asyncSelect: AsyncSelectInput, + speaker: SpeakerSelectInput, + company: CompanySelectInput +}; const ValueInput = ({ type, ...rest }) => { const Component = type ? INPUT_TYPE_MAP[type] : Dropdown; // use dropdown as a placeholder @@ -26,11 +39,14 @@ const ValueInput = ({ type, ...rest }) => { ValueInput.propTypes = { id: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, + // not required: the trailing "new" filter row has no criteria selected + // yet, so there's no type to pass — ValueInput falls back to Dropdown. + type: PropTypes.string, value: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, - PropTypes.array + PropTypes.array, + PropTypes.object ]), options: PropTypes.arrayOf( PropTypes.shape({ @@ -46,6 +62,7 @@ ValueInput.propTypes = { }; ValueInput.defaultProps = { + type: null, value: null, label: "", placeholder: "", diff --git a/src/components/mui/GridFilter/hooks/useGridFilter.jsx b/src/components/mui/GridFilter/hooks/useGridFilter.jsx index 972b2ac2..d800d71f 100644 --- a/src/components/mui/GridFilter/hooks/useGridFilter.jsx +++ b/src/components/mui/GridFilter/hooks/useGridFilter.jsx @@ -21,13 +21,22 @@ const useGridFilter = (id) => { const resetFilters = () => dispatch(saveFilters(id)); + // Lets the host push filters into the store from outside the dialog — + // e.g. applying a previously saved filter. The shape it expects matches + // what GridFilter persists itself: [{ criteria, operator, value, parsed }], + // so a saved filter's `criteria` array (as returned by the API) can be + // passed through directly. + const setFilters = (filters = [], joinOperator = JOIN_OPERATORS.ALL) => + dispatch(saveFilters(id, filters, joinOperator)); + return { filterValues, filterCount: filterValues.length, joinOperator, parsedFilter, valuesWithIds, - resetFilters + resetFilters, + setFilters }; }; diff --git a/src/components/mui/GridFilter/readme.md b/src/components/mui/GridFilter/readme.md index 2202f506..c57d14b4 100644 --- a/src/components/mui/GridFilter/readme.md +++ b/src/components/mui/GridFilter/readme.md @@ -133,9 +133,30 @@ const { filterValues, parsedFilter, joinOperator, filterCount } = | `joinOperator` | `"all"` or `"any"` | | `filterCount` | Number of active filters (useful for badge counts) | | `resetFilters` | Function — clears all active filters from the store | +| `setFilters` | Function `(filters, joinOperator?) => void` — pushes filters into the store from outside the dialog | The hook reads from `allGridFiltersState` in the Redux store, so it stays in sync with whatever was last applied via the dialog. +# setting filters from the host (e.g. applying a saved filter) + +`setFilters(filters, joinOperator)` lets the host populate a `GridFilter`'s state without going through the dialog UI — for example, when the user picks a previously saved filter from some other "saved filters" feature. It writes straight to the Redux store under the hook's `id`, the same place `GridFilter` itself writes to on Apply. + +`filters` must be an array shaped like `[{ criteria, operator, value, parsed }]` — the same shape `GridFilter` produces internally and the shape returned in `onApply`. `parsed` should already contain the resolved API filter strings (`GridFilter` does not re-run `customParser` for filters set this way, since it has no React component instance to read `criterias`/`customParser` from at that point). + +If your saved filters are persisted via an API that round-trips exactly what `onApply` produced (criteria/operator/value/parsed per row), the saved `criteria` array can be passed to `setFilters` unmodified: + +```js +import useGridFilter from "components/GridFilter/hooks/useGridFilter"; + +const { setFilters } = useGridFilter("speakers-filter"); + +const applySavedFilter = (savedFilter) => { + setFilters(savedFilter.criteria); +}; +``` + +Once set, `GridFilter`'s badge count and dialog rows pick the values up automatically (same as if the user had applied them by hand), and `parsedFilter` from the hook is immediately available to refetch data with — `setFilters` does not call `onApply`, so trigger any necessary refetch yourself after calling it. + # hideJoinOperators By default the dialog shows an **All / Any** toggle that lets the user choose whether filters are ANDed or ORed together. Pass `hideJoinOperators` to hide the toggle UI — but note that this **only removes the control from the dialog; it does not change the active join operator**. The dialog always initializes from the join operator last persisted in the Redux store (which defaults to `"all"` on first load). If the user previously applied filters with `"any"` and that value is still in the store, it will continue to be used when the toggle is hidden — silently producing OR-joined results. @@ -193,6 +214,92 @@ Each option in the returned array may include a `disabled: true` field; the corr A static array still works exactly as before — the function form is opt-in. +# datetime values + +For date/time criteria, use `type: "datetime"`. It renders a single MUI `DateTimePicker` and stores the value as a unix timestamp (seconds), consistent with how dates are represented elsewhere in this app. Use `props.mode` to control which views are shown — the stored value is always a unix timestamp regardless of mode. + +| `mode` | Shows | +| ---------- | ------------- | +| `date` | date only | +| `time` | time only | +| `datetime` | date and time (default) | + +```jsx +{ + key: "created", + label: "Created", + operators: [OPERATORS.BEFORE, OPERATORS.AFTER], + values: { + type: "datetime", + props: { mode: "date" } + } +} +``` + +# numeric values + +For numeric criteria, use `type: "number"`. It renders a `TextField` of `type="number"` and stores/emits the value as an actual `Number` (not a string). Optional props: `min`, `max` (clamped on change), and `integer` (blocks decimal entry and the `e`/`E` exponent keys). + +```jsx +{ + key: "attendees", + label: "Attendees", + operators: [OPERATORS.GREATER, OPERATORS.LESS], + values: { + type: "number", + props: { min: 0, integer: true } + } +} +``` + +# async select values + +For criteria backed by a remote search-as-you-type lookup, use `type: "asyncSelect"` (generic) or one of the preset entity types: `type: "speaker"` or `type: "company"`. These render a MUI `Autocomplete` and **always store the full selected option object** (or array of objects, when `multiple`) as `{ value, label, raw }` — `raw` is the untouched API entity, so a `customParser` can pull whatever field it needs (`.raw.id`, `.raw.name`, etc.). + +**`customParser` is mandatory for these types** — there is no default/shipped parser, even for `speaker`/`company`. The default `parseFilter` only knows how to serialize plain scalars; an object run through it produces `criteria==[object Object]`. If a criteria uses an async type and has no `customParser`, `GridFilter` logs a `console.error` to catch the mistake early — it does not throw or block applying filters. + +Common props (generic `asyncSelect`): + +| Prop | Description | +| --- | --- | +| `queryFunction(input, callback)` | required — fetches results; `callback(rawItems)` receives a plain array of raw API objects | +| `formatOption(item) => {value, label}` | maps a raw item to display shape (default: `{value: item.id, label: item.name}`) | +| `multiple` | allow selecting more than one option | +| `debounceWait` | debounce delay in ms before querying on typing (default: `DEBOUNCE_WAIT_250`) | +| `minSearchLength` | skip querying until the input reaches this length (default: `0`) | + +```jsx +{ + key: "created_by_company", + label: "Submitter Company", + operators: [OPERATORS.IS], + values: { + type: "company", + props: { multiple: true } + }, + customParser: (f) => [ + `created_by_company==${f.value.map((c) => escapeFilterValue(c.raw.name)).join("||")}` + ] +} +``` + +`speaker` additionally accepts `summitId` (scopes the default query to a summit) and `company`/`speaker` both accept a `queryFunction` override for non-default scoped queries (e.g. `querySpeakerCompany`, `queryAllCompanies`): + +```jsx +{ + key: "speaker_id", + label: "Speaker", + operators: [OPERATORS.IS], + values: { + type: "speaker", + props: { summitId: currentSummit.id, multiple: true } + }, + customParser: (f) => [ + `speaker_id==${f.value.map((s) => s.value).join("||")}` + ] +} +``` + # custom parser For criteria that require non-standard API encoding, provide a `customParser` function on the criteria object. It receives the filter and must return an array of API filter strings. See the `selection_status` example in the usage section above. diff --git a/src/components/mui/GridFilter/utils.js b/src/components/mui/GridFilter/utils.js index 9ed88a98..2f3a4f91 100644 --- a/src/components/mui/GridFilter/utils.js +++ b/src/components/mui/GridFilter/utils.js @@ -27,7 +27,9 @@ export const OPERATORS = { BETWEEN_STRICT: { value: "()", label: T.translate("grid_filter.operators.between_strict") - } + }, + BEFORE: { value: "<=", label: T.translate("grid_filter.operators.before") }, + AFTER: { value: ">=", label: T.translate("grid_filter.operators.after") } }; export const JOIN_OPERATORS = { @@ -40,4 +42,9 @@ export const EMPTY_FILTER = { operator: null, value: null, id: "new" -}; \ No newline at end of file +}; + +// ValueInput types whose stored value is an option object (or array of +// option objects), not a plain scalar — these always require a customParser +// since the default parseFilter only knows how to serialize scalars. +export const ASYNC_VALUE_TYPES = ["asyncSelect", "speaker", "company"]; \ No newline at end of file diff --git a/src/components/mui/__tests__/GridFilter.test.jsx b/src/components/mui/__tests__/GridFilter.test.jsx index 4f64c719..df2c36a8 100644 --- a/src/components/mui/__tests__/GridFilter.test.jsx +++ b/src/components/mui/__tests__/GridFilter.test.jsx @@ -9,12 +9,18 @@ import configureStore from "redux-mock-store"; import thunk from "redux-thunk"; import { GridFilter, OPERATORS, JOIN_OPERATORS, SAVE_FILTERS } from "../GridFilter"; import Filter from "../GridFilter/components/Filter"; +import { querySpeakers, queryCompanies } from "../../../utils/query-actions"; jest.mock("i18n-react/dist/i18n-react", () => ({ __esModule: true, default: { translate: (key) => key } })); +jest.mock("../../../utils/query-actions", () => ({ + querySpeakers: jest.fn((summitId, input, callback) => callback([])), + queryCompanies: jest.fn((input, callback) => callback([])) +})); + // MUI Fade never fires its exit callback in jsdom (no CSS transition events), // so dialogs stay in the DOM after close. This makes it synchronous. jest.mock( @@ -24,6 +30,36 @@ jest.mock( inProp ? children : null ); +jest.mock("@mui/x-date-pickers/LocalizationProvider", () => ({ + LocalizationProvider: ({ children }) => children +})); + +jest.mock("@mui/x-date-pickers/AdapterMoment", () => ({ + AdapterMoment: function AdapterMoment() {} +})); + +// stub DateTimePicker as a plain input; clicking it fires onChange with a +// fixed moment so tests can assert the resulting unix timestamp. +jest.mock("@mui/x-date-pickers/DateTimePicker", () => ({ + DateTimePicker: ({ value, onChange, views, format, slotProps }) => { + const React = require("react"); + const moment = require("moment-timezone"); + const tf = slotProps?.textField || {}; + return ( + {}} + onClick={() => onChange(moment.unix(1700000000))} + /> + ); + } +})); + const mockStore = configureStore([thunk]); const makeStore = (filters = []) => @@ -235,6 +271,315 @@ describe("Filter - options as a function", () => { }); }); +describe("Filter - datetime value type", () => { + const dateCriteria = { + key: "created", + label: "Created", + operators: [OPERATORS.BEFORE, OPERATORS.AFTER], + values: { + type: "datetime", + props: { mode: "date" } + } + }; + + const renderFilter = (value) => + render( + + ); + + test("renders the picker with mode-specific views and format", () => { + renderFilter({ id: "0", criteria: "created", operator: "<=", value: null }); + + const input = screen.getByTestId("test-value"); + expect(input).toHaveAttribute("data-views", "year,month,day"); + expect(input).toHaveAttribute("data-format", "MM/DD/YYYY"); + }); + + test("propagates the selected date as a unix timestamp", () => { + const onChange = jest.fn(); + render( + { + renderFilter({ id: "0", criteria: "created", operator: "<=", value: null }); + + expect(screen.getByTestId("test-value")).toHaveAttribute( + "placeholder", + "grid_filter.placeholders.date" + ); + }); +}); + +describe("Filter - number value type", () => { + const numberCriteria = { + key: "attendees", + label: "Attendees", + operators: [OPERATORS.GREATER, OPERATORS.LESS], + values: { + type: "number", + props: { min: 0, max: 100, integer: true } + } + }; + + const renderFilter = (value, onChange = jest.fn()) => { + render( + + ); + return { onChange, input: screen.getByRole("spinbutton") }; + }; + + test("renders with min/max/step attributes from props", () => { + const { input } = renderFilter({ + id: "0", + criteria: "attendees", + operator: ">", + value: null + }); + + expect(input).toHaveAttribute("min", "0"); + expect(input).toHaveAttribute("max", "100"); + expect(input).toHaveAttribute("step", "1"); + }); + + test("propagates a typed value as a Number", () => { + const { input, onChange } = renderFilter({ + id: "0", + criteria: "attendees", + operator: ">", + value: null + }); + + fireEvent.change(input, { target: { value: "42" } }); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ value: 42 })); + }); + + test("clamps the value to max", () => { + const { input, onChange } = renderFilter({ + id: "0", + criteria: "attendees", + operator: ">", + value: null + }); + + fireEvent.change(input, { target: { value: "500" } }); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ value: 100 })); + }); + + test("clears to null when the input is emptied", () => { + const { input, onChange } = renderFilter({ + id: "0", + criteria: "attendees", + operator: ">", + value: 10 + }); + + fireEvent.change(input, { target: { value: "" } }); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ value: null })); + }); + + test("uses the default translated placeholder when none is provided", () => { + const { input } = renderFilter({ + id: "0", + criteria: "attendees", + operator: ">", + value: null + }); + + expect(input).toHaveAttribute("placeholder", "grid_filter.placeholders.number"); + }); +}); + +describe("Filter - asyncSelect value type", () => { + const makeCriteria = (queryFunction, props = {}) => [ + { + key: "tag", + label: "Tag", + operators: [OPERATORS.IS], + values: { + type: "asyncSelect", + props: { queryFunction, multiple: true, ...props } + } + } + ]; + + test("calls queryFunction on mount with an empty search term", () => { + const queryFunction = jest.fn((input, callback) => callback([])); + render( + + ); + expect(queryFunction).toHaveBeenCalledWith("", expect.any(Function)); + }); + + test("renders a preselected value's label without re-fetching it", () => { + const queryFunction = jest.fn((input, callback) => callback([])); + render( + + ); + expect(screen.getByText("Keynote")).toBeInTheDocument(); + }); + + test("uses the default translated placeholder when none is provided", () => { + const queryFunction = jest.fn((input, callback) => callback([])); + render( + + ); + expect( + screen.getByPlaceholderText("grid_filter.placeholders.async") + ).toBeInTheDocument(); + }); +}); + +describe("Filter - speaker value type", () => { + beforeEach(() => querySpeakers.mockClear()); + + const speakerCriteria = (props = {}) => [ + { + key: "speaker_id", + label: "Speaker", + operators: [OPERATORS.IS], + values: { type: "speaker", props: { summitId: 42, multiple: true, ...props } } + } + ]; + + const renderSpeakerFilter = (props) => + render( + + ); + + test("defaults queryFunction to querySpeakers scoped to summitId", () => { + renderSpeakerFilter(); + expect(querySpeakers).toHaveBeenCalledWith(42, "", expect.any(Function)); + }); + + test("a queryFunction override takes precedence over the summitId default", () => { + const queryFunction = jest.fn((input, callback) => callback([])); + renderSpeakerFilter({ queryFunction }); + expect(queryFunction).toHaveBeenCalledWith("", expect.any(Function)); + expect(querySpeakers).not.toHaveBeenCalled(); + }); + + test("uses the speaker-specific default placeholder, not the generic async one", () => { + renderSpeakerFilter(); + expect( + screen.getByPlaceholderText("grid_filter.placeholders.speaker") + ).toBeInTheDocument(); + }); +}); + +describe("Filter - company value type", () => { + beforeEach(() => queryCompanies.mockClear()); + + const companyCriteria = (props = {}) => [ + { + key: "company_id", + label: "Company", + operators: [OPERATORS.IS], + values: { type: "company", props: { multiple: true, ...props } } + } + ]; + + const renderCompanyFilter = (props) => + render( + + ); + + test("defaults queryFunction to queryCompanies", () => { + renderCompanyFilter(); + expect(queryCompanies).toHaveBeenCalledWith("", expect.any(Function)); + }); + + test("a queryFunction override takes precedence over the default", () => { + const queryFunction = jest.fn((input, callback) => callback([])); + renderCompanyFilter({ queryFunction }); + expect(queryFunction).toHaveBeenCalledWith("", expect.any(Function)); + expect(queryCompanies).not.toHaveBeenCalled(); + }); + + test("uses the company-specific default placeholder, not the generic async one", () => { + renderCompanyFilter(); + expect( + screen.getByPlaceholderText("grid_filter.placeholders.company") + ).toBeInTheDocument(); + }); + + test("a criteria-provided placeholder overrides the default", () => { + renderCompanyFilter({ placeholder: "Custom placeholder" }); + expect(screen.getByPlaceholderText("Custom placeholder")).toBeInTheDocument(); + }); +}); + // ─── parseFilter / handleSubmit ────────────────────────────────────────────── // // These are private closures; exercised by seeding the Redux store with filter @@ -299,6 +644,48 @@ describe("GridFilter – parseFilter / handleSubmit", () => { expect(filters[0].parsed).toEqual(["track==1||2||3"]); }); + test("datetime value → unix timestamp in the API string", () => { + const c = [ + { + key: "created", + label: "Created", + operators: [OPERATORS.BEFORE], + values: { type: "datetime", props: { mode: "date" } } + } + ]; + const { onApply } = renderWithFilters( + [{ criteria: "created", operator: "<=", value: 1700000000 }], + { criterias: c } + ); + + openFilterDialog(); + applyFilters(); + + const [filters] = onApply.mock.calls[0]; + expect(filters[0].parsed).toEqual(["created<=1700000000"]); + }); + + test("number value → unquoted numeric string in the API string", () => { + const c = [ + { + key: "attendees", + label: "Attendees", + operators: [OPERATORS.GREATER], + values: { type: "number", props: { min: 0 } } + } + ]; + const { onApply } = renderWithFilters( + [{ criteria: "attendees", operator: ">", value: 10 }], + { criterias: c } + ); + + openFilterDialog(); + applyFilters(); + + const [filters] = onApply.mock.calls[0]; + expect(filters[0].parsed).toEqual(["attendees>10"]); + }); + test("delegates to customParser and uses its return value as parsed", () => { const customParser = jest.fn().mockReturnValue(["custom==result"]); const c = [ @@ -384,4 +771,61 @@ describe("GridFilter – parseFilter / handleSubmit", () => { ); }); }); + + describe("async value types require customParser", () => { + const companyCriteria = (customParser) => [ + { + key: "created_by_company", + label: "Submitter Company", + operators: [OPERATORS.IS], + values: { + type: "company", + props: { multiple: true, queryFunction: jest.fn((i, cb) => cb([])) } + }, + ...(customParser ? { customParser } : {}) + } + ]; + + const companyValue = [ + { value: 1, label: "Acme", raw: { id: 1, name: "Acme" } } + ]; + + afterEach(() => jest.restoreAllMocks()); + + test("logs a console.error when no customParser is provided", () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + renderWithFilters( + [{ criteria: "created_by_company", operator: "==", value: companyValue }], + { criterias: companyCriteria() } + ); + + openFilterDialog(); + applyFilters(); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'criteria "created_by_company" uses async value type "company"' + ) + ); + }); + + test("does not log when a customParser is provided, and uses its return value", () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const customParser = (f) => [ + `created_by_company==${f.value.map((c) => c.raw.name).join("||")}` + ]; + + const { onApply } = renderWithFilters( + [{ criteria: "created_by_company", operator: "==", value: companyValue }], + { criterias: companyCriteria(customParser) } + ); + + openFilterDialog(); + applyFilters(); + + expect(errorSpy).not.toHaveBeenCalled(); + const [filters] = onApply.mock.calls[0]; + expect(filters[0].parsed).toEqual(["created_by_company==Acme"]); + }); + }); }); diff --git a/src/components/mui/__tests__/useGridFilter.test.jsx b/src/components/mui/__tests__/useGridFilter.test.jsx index e9c88e7b..9a3b2351 100644 --- a/src/components/mui/__tests__/useGridFilter.test.jsx +++ b/src/components/mui/__tests__/useGridFilter.test.jsx @@ -172,3 +172,53 @@ describe("useGridFilter – resetFilters", () => { }); }); }); + +// ─── setFilters ──────────────────────────────────────────────────────────── + +describe("useGridFilter – setFilters", () => { + test("dispatches SAVE_FILTERS with the hook's id and the given filters/joinOperator", () => { + const store = storeWith("f", []); + const savedCriteria = [ + { id: "track_id-0", criteria: "track_id", operator: "==", value: [36333], parsed: ["track_id==36333"] } + ]; + + const { current } = renderHookWithStore(() => useGridFilter("f"), store); + current.setFilters(savedCriteria, JOIN_OPERATORS.ANY); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toMatchObject({ + type: SAVE_FILTERS, + payload: { id: "f", filters: savedCriteria, joinOperator: JOIN_OPERATORS.ANY } + }); + }); + + test("defaults joinOperator to JOIN_OPERATORS.ALL when omitted", () => { + const store = storeWith("f", []); + const savedCriteria = [ + { criteria: "selection_status", operator: "==", value: ["accepted"], parsed: ["selection_status==accepted"] } + ]; + + const { current } = renderHookWithStore(() => useGridFilter("f"), store); + current.setFilters(savedCriteria); + + const actions = store.getActions(); + expect(actions[0]).toMatchObject({ + type: SAVE_FILTERS, + payload: { id: "f", filters: savedCriteria, joinOperator: JOIN_OPERATORS.ALL } + }); + }); + + test("defaults filters to [] when called with no arguments", () => { + const store = storeWith("f", []); + + const { current } = renderHookWithStore(() => useGridFilter("f"), store); + current.setFilters(); + + const actions = store.getActions(); + expect(actions[0]).toMatchObject({ + type: SAVE_FILTERS, + payload: { id: "f", filters: [], joinOperator: JOIN_OPERATORS.ALL } + }); + }); +}); diff --git a/src/i18n/en.json b/src/i18n/en.json index b90b191e..6da27fe8 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -183,7 +183,16 @@ "between": "between", "between_strict": "between strict", "all": "all", - "any": "any" + "any": "any", + "before": "before", + "after": "after" + }, + "placeholders": { + "number": "Type a number", + "date": "Select a date", + "speaker": "Type a speaker name", + "company": "Type a company name", + "async": "Type and select" } } }