Dynamic Filters

This commit is contained in:
2025-05-13 03:37:06 +02:00
parent 18e4fcea28
commit 5a0327c930
7 changed files with 481 additions and 183 deletions

View File

@@ -1,22 +1,32 @@
import { useEffect, useState } from "react";
import { jsonschemaProvider } from "../providers/jsonschema-provider";
import { CircularProgress } from "@mui/material";
import { QueryBuilderMaterial } from "@react-querybuilder/material";
import { QueryBuilder, type RuleGroupType } from 'react-querybuilder';
import { Accordion, AccordionDetails, AccordionSummary, CircularProgress } from "@mui/material";
import FilterForm from "../../filter-form/components/filter-form";
import TextField from "@mui/material/TextField";
import { useSearchParams } from "react-router";
import { GridExpandMoreIcon } from "@mui/x-data-grid";
export type OnChangeValue = {
search: string | null
filters: {[filter: string]: {[op: string]: string}}
}
type CrudFiltersProps = {
resourceName: string
resourcePath: string
onChange: (value: OnChangeValue) => void
}
const CrudFilters = (props: CrudFiltersProps) => {
const { resourceName, resourcePath } = props
const { resourceName, resourcePath, onChange } = props
const [hasSearch, setHasSearch] = useState(false)
const [filtersSchema, setFiltersSchema] = useState<any[]>([])
const [filtersLoading, setFiltersLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
setHasSearch(await jsonschemaProvider.hasSearch(resourcePath))
const resourceFilters = await jsonschemaProvider.getListFilters(resourceName, resourcePath)
setFiltersSchema(resourceFilters);
console.log(resourceFilters);
@@ -29,20 +39,57 @@ const CrudFilters = (props: CrudFiltersProps) => {
fetchSchema();
}, []);
const [query, setQuery] = useState<RuleGroupType>({ combinator: 'and', rules: [] });
if (filtersLoading) {
return <CircularProgress />
}
let currentValue = {
search: "",
filters: {}
}
return (
<QueryBuilderMaterial>
<QueryBuilder
fields={filtersSchema}
defaultQuery={query}
onQueryChange={setQuery} />
</QueryBuilderMaterial>
<>
{hasSearch &&
<SearchFilter value="" onChange={(value) => {
currentValue.search = value;
onChange(currentValue);
}}
/>
}
<Accordion>
<AccordionSummary
expandIcon={<>Advanced filters<GridExpandMoreIcon /></>}
/>
<AccordionDetails>
<FilterForm fields={filtersSchema} values={{}} onChange={(value) => {
currentValue.filters = value;
onChange(currentValue);
}}
/>
</AccordionDetails>
</Accordion>
</>
)
}
export default CrudFilters;
type SearchFilter = {
value: string
onChange: (value: any) => void
}
const SearchFilter = (props: SearchFilter) => {
const {value, onChange} = props;
const [searchParams, setSearchParams] = useSearchParams();
return (
<TextField
label="schemas.search"
fullWidth={true}
onChange={(event) => onChange(event.target.value)}
defaultValue={value}
/>
)
}

View File

@@ -3,7 +3,6 @@ import i18n from '../../../i18n'
import { JSONSchema7Definition } from "json-schema";
import { GridColDef } from "@mui/x-data-grid";
import { GridColType } from "@mui/x-data-grid/models/colDef/gridColType";
import { Field as FilterField } from "react-querybuilder";
const API_URL = "/api/v1";
@@ -72,6 +71,10 @@ export const jsonschemaProvider = {
getListFilters: async (resourceName: string, resourcePath: string): Promise<FilterField[]> => {
return getFilters(resourceName, resourcePath);
},
hasSearch: async (resourcePath: string): Promise<boolean> => {
return hasSearch(resourcePath);
}
}
@@ -88,36 +91,30 @@ function shortenResourceName(resourceName: string) {
return convertCamelToSnake(resourceName.replace(/(-Input|-Output|Create|Update|Read)$/g, ""));
}
async function getFilters(resourceName: string, resourcePath: string): Promise<FilterField[]> {
const shortResourceName = shortenResourceName(resourceName);
const rawSchemas = await getJsonschema()
const resourceParts = `/${resourcePath}/`.split("/")
let pathSchema: PathSchema|undefined;
for (const path in rawSchemas.paths) {
if (rawSchemas.paths[path].hasOwnProperty("get")) {
const pathParts = path.split("/")
if (pathParts.length == resourceParts.length) {
for (let i = 0; i < pathParts.length; i++) {
const isVariable = pathParts[i].slice(0,1) == "{" && pathParts[i].slice(-1) == "}";
if (! isVariable && pathParts[i] != resourceParts[i] ) {
break;
}
type FilterField = {
name: string,
label: string,
type: string,
operators: { name: string, label: string }[]
}
if (i == pathParts.length - 1) {
pathSchema = rawSchemas.paths[path].get
}
}
}
if (pathSchema !== undefined) {
break
}
async function hasSearch(resourcePath: string): Promise<boolean> {
const jst = new JsonSchemaTraverser(await getJsonschema());
const pathSchema = jst.getPath(resourcePath);
for (const param of pathSchema.parameters) {
if (param.name == "search") {
return true
}
}
if (pathSchema === undefined) {
throw ("Path not found in schema");
}
const jst = new JsonSchemaTraverser(rawSchemas)
return false
}
async function getFilters(resourceName: string, resourcePath: string): Promise<FilterField[]> {
const shortResourceName = shortenResourceName(resourceName);
const jst = new JsonSchemaTraverser(await getJsonschema());
const pathSchema = jst.getPath(resourcePath);
const seen: { [k: string]: number } = {};
let filters: FilterField[] = []
for (const param of pathSchema.parameters) {
@@ -126,10 +123,10 @@ async function getFilters(resourceName: string, resourcePath: string): Promise<F
if (! seen.hasOwnProperty(fieldName)) {
seen[fieldName] = filters.length;
const field = jst.getPropertyByPath(jst.getResource(`${resourceName}Read`), fieldName)
filters.push({
name: fieldName,
label: i18n.t(`schemas.${shortResourceName}.${convertCamelToSnake(fieldName)}`),
type: getFieldType(fieldName, field),
operators: [{ name: operator, label: operator }]
});
} else {
@@ -157,6 +154,32 @@ function processParamName(param: PathParameter): Operator {
}
}
const getFieldType = (fieldName: string, field: RJSFSchema): string => {
if (fieldName == "created_by" || fieldName == "updated_by") {
return "author";
} else if (Array.isArray(field)) {
let enumValues = [];
for (const f of field) {
enumValues.push(f.const)
}
return `enum(${enumValues.join("|")})`
} else if (field.hasOwnProperty('type')) {
if (field.type == "string" && field.format == "date-time") {
return "dateTime";
}
return field.type as string;
} else if (field.hasOwnProperty('anyOf') && field.anyOf) {
for (const prop of field.anyOf) {
if (typeof prop != "boolean" && prop.type != "null") {
return prop.type as string;
}
}
return "null";
}
throw "Unimplemented field type"
}
const getColumns = async (resourceName: string): Promise<GridColDef[]> => {
return buildColumns(await getJsonschema(), resourceName)
}
@@ -383,6 +406,35 @@ const JsonSchemaTraverser = class {
return this.rawSchemas.components.schemas[resourceName]
}
public getPath = (resourcePath: string) => {
const resourceParts = `/${resourcePath}/`.split("/");
let pathSchema: PathSchema|undefined;
for (const path in this.rawSchemas.paths) {
if (this.rawSchemas.paths[path].hasOwnProperty("get")) {
const pathParts = path.split("/")
if (pathParts.length == resourceParts.length) {
for (let i = 0; i < pathParts.length; i++) {
const isVariable = pathParts[i].slice(0,1) == "{" && pathParts[i].slice(-1) == "}";
if (! isVariable && pathParts[i] != resourceParts[i] ) {
break;
}
if (i == pathParts.length - 1) {
pathSchema = this.rawSchemas.paths[path].get
}
}
}
if (pathSchema !== undefined) {
break
}
}
}
if (pathSchema === undefined) {
throw ("Path not found in schema");
}
return pathSchema
}
public hasDescendant = (resource: RJSFSchema, property_name: string): boolean => {
if (is_array(resource)) {
return property_name == 'items';
@@ -406,7 +458,7 @@ const JsonSchemaTraverser = class {
throw new Error("Jsonschema format not implemented in property finder");
}
public getDescendant = (resource: RJSFSchema, property_name: string): RJSFSchema => {
public getDescendant = (resource: RJSFSchema, property_name: string): RJSFSchema | RJSFSchema[] => {
if (is_array(resource) && property_name == 'items') {
return resource.items as RJSFSchema;
} else if (is_object(resource) && resource.properties !== undefined && property_name in resource.properties!) {
@@ -416,11 +468,15 @@ const JsonSchemaTraverser = class {
let subresource = this.getResource(subresourceName);
return this.getDescendant(subresource, property_name);
} else if (is_union(resource)) {
let descendants: RJSFSchema[] = [];
for (const ref of resource.oneOf!) {
if (this.hasDescendant(ref as RJSFSchema, property_name)) {
return this.getDescendant(ref as RJSFSchema, property_name);
descendants.push(this.getDescendant(ref as RJSFSchema, property_name));
}
}
if (descendants.length > 0) {
return descendants;
}
} else if (is_enum(resource)) {
for (const ref of resource.allOf!) {
if (this.hasDescendant(ref as RJSFSchema, property_name)) {

View File

@@ -0,0 +1,184 @@
import TextField from "@mui/material/TextField";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import Autocomplete from "@mui/material/Autocomplete";
import { FirmContext } from "../../../contexts/FirmContext";
import { useContext } from "react";
import { Box, Grid2, styled } from "@mui/material";
import Stack from "@mui/material/Stack";
import dayjs from "dayjs";
export type FilterField = {
name: string
label: string
type: string
}
type FilterFormProps = {
values: {[field_name: string]: { [operator: string]: string }}
fields: FilterField[]
onChange: (value: any) => void
}
const FilterForm = (props: FilterFormProps) => {
const { fields, values, onChange } = props;
let currentValue = values
const formField = fields.filter(f => f.name != "search").map((f, index) =>
<>
{ f.name == "created_at" && <Box width="100%" key={`created_at_break-${index}`} /> }
<Grid2 size={6} key={`${f.name}-${index}`} >
<FilterFormField
field={f}
value={values.hasOwnProperty(f.name) ? values[f.name] : {}}
onChange={(value) => {
for (const op in value) {
if (value[op] == null || value[op] == "") {
if (currentValue.hasOwnProperty(f.name)) {
if (currentValue[f.name].hasOwnProperty(op)) {
delete currentValue[f.name][op];
}
if (Object.entries(currentValue[f.name]).length == 0) {
delete currentValue[f.name];
}
}
} else {
if (! currentValue.hasOwnProperty(f.name)) {
currentValue[f.name] = {};
}
currentValue[f.name][op] = value[op];
}
}
onChange(currentValue);
}}
/>
</Grid2>
</>
);
return (
<form>
<Grid2 container spacing={2}>
{formField}
</Grid2>
</form>
);
}
type OperatorValue = { [operator: string]: string }
type FilterFormFieldProps = {
field: FilterField
value: OperatorValue
onChange: (value: { [operator: string]: string | null }) => void
}
const FilterFormField = (props: FilterFormFieldProps) => {
const { field, value, onChange } = props;
if (field.type == "string") {
const defaultValue = value.hasOwnProperty('ilike') ? value['ilike'] : undefined
return (
<TextField
name={field.name}
label={field.label}
defaultValue={defaultValue}
fullWidth={true}
onChange={(event) => onChange({"ilike": event.target.value})}
/>
)
} else if (field.type == "dateTime") {
return (
<FilterFieldDateRange field={field} value={value} onChange={onChange} />
)
} else if (field.type == "author") {
return (
<FilterFieldAuthor field={field} value={value} onChange={onChange} />
);
} else if (field.type.slice(0, 4) == "enum") {
const values = field.type.slice(5,-1).split("|");
const defaultValue = value.hasOwnProperty("in") ? [{
value: value["in"],
label: value["in"]
}] : undefined;
return (
<Autocomplete
multiple
options={values.map(opt => {
return {
value: opt,
label: opt
}
})}
onChange={(event, value) => onChange({ "in": value.map(v => v.value).join(",") })}
getOptionLabel={(option) => option.label}
defaultValue={defaultValue}
renderInput={(params) => (
<TextField
{...params}
label={field.label}
/>
)}
/>
)
}
throw("Unsupported field filter type");
}
type FilterFieldAuthorProp = FilterFormFieldProps
const FilterFieldAuthor = (props: FilterFieldAuthorProp) => {
const { field, onChange } = props;
const { partnerMap } = useContext(FirmContext)
if (partnerMap == undefined) {
throw "Can't use author filter outside of the context of a firm";
}
let options = []
for(let key of Array.from(partnerMap.keys()) ) {
options.push({
id: key,
label: partnerMap.get(key)
});
}
return (
<Autocomplete
multiple
renderInput={(params) => <TextField {...params} label={field.label} />}
options={options}
onChange={(event, value) => onChange({ "in": value.length == 0 ? null : value.map(v => v.id).join(",") })}
/>
)
}
const FilterFieldDateRange = (props: FilterFormFieldProps) => {
const { field, value, onChange } = props;
const defaultAfterValue = value.hasOwnProperty('gte') ? dayjs(value['gte']) : undefined;
const defaultBeforeValue = value.hasOwnProperty('lte') ? dayjs(value['lte']) : undefined;
return (
<Stack direction="row">
<DateTimePicker
name={field.name}
label={`${field.label} After:`}
slotProps={{ textField: { fullWidth: true }, field: { clearable: true }}}
defaultValue={defaultAfterValue}
onChange={(value) => onChange({'gte': value === null ? null : value.toJSON()})}
/>
<DateTimePicker
name={field.name}
label={`${field.label} Before:`}
slotProps={{ textField: { fullWidth: true }, field: { clearable: true } }}
defaultValue={defaultBeforeValue}
onChange={(value) => onChange({'lte': value === null ? null : value.toJSON()})}
/>
</Stack>
);
}
export default FilterForm;