Skip to content

Commit

Permalink
Add support
Browse files Browse the repository at this point in the history
Fix single quotes not being escaped in strings
  • Loading branch information
jamerst committed May 7, 2022
1 parent 0255352 commit 76d8528
Show file tree
Hide file tree
Showing 17 changed files with 296 additions and 122 deletions.
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,17 @@ _* = not applicable to collection fields_
| `collectionFields` | `ODataGridColDef` | | Column definitions for the subfields of the collection. Any properties marked with * are not supported. |
| `datePickerProps` | [`DatePickerProps`](https://mui.com/api/date-picker/) | | Props to pass to the `DatePicker` component for columns with type `date` |
| `dateTimePickerProps` | [`DateTimePickerProps`](https://mui.com/api/date-time-picker/) | | Props to pass to the `DateTimePicker` component for columns with type `datetime` |
| `expand` | `Expand` | | Include related entities using the `$expand` clause. |
| `filterable` | `boolean` | | Hides the field and does not allow filtering in the FilterBuilder when set to `false`. |
| `filterField` | `string` | | If the field name is different to the field which should be used for filtering, provide the field for filtering here. See also: `filterType`. |
| `filterOnly` | `boolean` | `false` | Set to true if the field is for filtering only and cannot be displayed as a column in the datagrid. |
| `filterOperators` | `Operation[]` | `["eq", "ne", "gt", "lt", "ge", "le", "contains", "null", "notnull"]` | Array of supported filter operations for the field. |
| `filterType` | `string` | | If the type of the field to be filtered is different to that of the displayed field, provide the type here. See also: `filterField`. |
| `getCustomFilterString` | `(op: Operation, value: any) => string` | | Function to generate a custom filter string for use in the `$filter` clause. |
| `getCustomQueryString` | `(op: Operation, value: any) => ({ [key: string]: string })` | | Function to generate a custom set of query string values to add to the OData request. Allows custom filtering to be performed which is not supported by OData. |
| `getCustomFilterString` | `(op: Operation, value: any) => string \| FilterCompute \| boolean` | | Function to generate a custom filter string for use in the `$filter` clause. Return `false` to skip and not add it to the `$filter` clause.<br/><br/>Also supports the use of the `$compute` clause by returning a `FilterCompute`. The computed property/properties can also be added to `$select` by returning a `ComputeSelect`. |
| `getCustomQueryString` | `(op: Operation, value: any) => ({ [key: string]: string })` | | Function to generate a custom set of query string values to add to the OData request. |
| `label` | `string` | Defaults to the same value as `headerName` or `field` | Text to be displayed in the field selection dropdown. |
| `nullable` | `boolean` | | Adds an "Unknown" option to the value dropdown for columns with type `boolean` if set to `true`. |
| `select` | `string` | | Additional fields to add to the `$select` clause. |
| `selectProps` | `{ selectProps?: SelectProps, formControlProps?: FormControlProps, label?: string }` | | Props to pass to the `Select`, `FormControl` and `Label` components for this column in the filter. See also: `textFieldProps`. |
| `sortField` | `string` | | If the name of the field to sort by is different to that of the displayed field, provide the name for sorting by here. |
| `textFieldProps` | [`TextFieldProps`](https://mui.com/api/text-field/) | | Props to pass to the `TextField` component in the filter for this column. See also: `selectProps`. |
Expand All @@ -129,8 +132,8 @@ _* = not applicable to collection fields_
| `filter` | `SerialisedGroup` | | Allows setting the state of the FilterBuilder using a `SerialisedGroup`. You could use this to implement filter saving and restoring.<br/><br/>Changing the value of this property will cause `restoreState` to be called, but with the `state` property undefined. |
| `localeText` | [`FilterBuilderLocaleText`](#FilterBuilderLocaleText) | | Localization strings for `FilterBuilder` (see [Localization](#localization) section) |
| `localizationProviderProps` | [`LocalizationProviderProps`](https://mui.com/components/date-picker/#localization) | | Props to pass to the `LocalizationProvider` component for the `DatePicker` and `DateTimePicker` components |
| `onSubmit` | `(filter: string, serialised: SerialisedGroup \| undefined, queryString: QueryStringCollection \| undefined) => (void \| any)` | | Function called when FilterBuilder is submitted (e.g. when the search button is clicked). You should use this to trigger the OData request.<br/><br/>`filter` is the OData filter string, `serialised` is a serialised form of the query which can be used to load the query back into the filter builder. |
| `onRestoreState` | `(filter: string, serialised: SerialisedGroup \| undefined, queryString: QueryStringCollection \| undefined, state?: any) => void` | | Function called when the state of the FilterBuilder is restored (e.g. from history navigation). You should also use this to trigger the OData request alongside the `onSubmit` callback.<br/><br/>`state` is the the value of `history.state` that the query was restored from. `state` will be undefined if the call is as a result of the `filter` property changing. |
| `onSubmit` | `(params: FilterParameters) => (void \| any)` | | Function called when FilterBuilder is submitted (e.g. when the search button is clicked). You should use this to trigger the OData request.<br/><br/>`params.filter` is the OData filter string, `params.serialised` is a serialised form of the query which can be used to load the query back into the filter builder. |
| `onRestoreState` | `(params: FilterParameters, state?: any) => void` | | Function called when the state of the FilterBuilder is restored (e.g. from history navigation). You should also use this to trigger the OData request alongside the `onSubmit` callback.<br/><br/>`state` is the the value of `history.state` that the query was restored from. `state` will be undefined if the call is as a result of the `filter` property changing. |
| `searchMenuItems` | `({ label: string, onClick: () => void })[]` | | Array of entries to add to the dropdown menu next to the Search button of the `FilterBuilder` |

## Localization
Expand Down
29 changes: 13 additions & 16 deletions packages/base/FilterBuilder/components/FilterRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { Fragment, useCallback, useEffect, useRef, useState } from "react"
import React, { Fragment, useCallback, useEffect, useState } from "react"
import { useSetRecoilState } from "recoil";
import { ArrowDropDown } from "@mui/icons-material";
import { Button, ButtonGroup, Grid, MenuItem, MenuList, Paper, Popover } from "@mui/material";
Expand Down Expand Up @@ -26,7 +26,6 @@ const FilterRoot = ({ props }: FilterRootProps) => {

const odataFilter = UseODataFilter();
const odataFilterWithState = UseODataFilterWithState();
const currentFilter = useRef("");

const [anchor, setAnchor] = useState<null | HTMLElement>(null);

Expand All @@ -37,9 +36,7 @@ const FilterRoot = ({ props }: FilterRootProps) => {
const result = odataFilter();

if (result.filter) {
currentFilter.current = result.filter;

const returned = onSubmit(result.filter, result.serialised, result.queryString);
const returned = onSubmit({ ...result, filter: result.filter });

if (disableHistory !== true) {
window.history.pushState(
Expand All @@ -48,6 +45,8 @@ const FilterRoot = ({ props }: FilterRootProps) => {
...returned,
filterBuilder: {
filter: result.filter,
compute: result.compute,
select: result.select,
serialised: result.serialised,
queryString: result.queryString
}
Expand All @@ -60,13 +59,11 @@ const FilterRoot = ({ props }: FilterRootProps) => {
}, [onSubmit, odataFilter, disableHistory]);

const reset = useCallback(() => {
currentFilter.current = "";

setClauses(initialClauses.update(rootConditionUuid, (c) => ({ ...c as ConditionClause, field: props.schema[0].field })));
setTree(initialTree);

if (onSubmit) {
onSubmit("", undefined, undefined);
onSubmit({ filter: "" });
}

if (disableHistory !== true) {
Expand All @@ -91,31 +88,31 @@ const FilterRoot = ({ props }: FilterRootProps) => {
}, [props.schema, setClauses, setTree]);

const restoreState = useCallback((state: any, isPopstate: boolean) => {
let filter = "", obj, queryString;
let filter = "", serialised, queryString, compute, select;

if (state?.filterBuilder) {
if (state.filterBuilder.reset === true && isPopstate === true) {
restoreDefault();
}

compute = state.filterBuilder.compute as string;
filter = state.filterBuilder.filter as string;
obj = state.filterBuilder.serialised as SerialisedGroup;
select = state.filterBuilder.select as string[];
serialised = state.filterBuilder.serialised as SerialisedGroup;
queryString = state.filterBuilder.queryString as QueryStringCollection;
} else {
restoreDefault();
}

if (filter && obj) {
currentFilter.current = filter;

const [tree, clauses] = deserialise(obj);
if (filter && serialised) {
const [tree, clauses] = deserialise(serialised);

setClauses(clauses);
setTree(tree);
}

if (onRestoreState) {
onRestoreState(filter, obj, queryString, state);
onRestoreState({ compute, filter, queryString, select, serialised}, state);
}
}, [onRestoreState, restoreDefault, setClauses, setTree]);

Expand All @@ -128,7 +125,7 @@ const FilterRoot = ({ props }: FilterRootProps) => {
if (onRestoreState) {
const result = odataFilterWithState(clauses, tree);

onRestoreState(result.filter ?? "", result.serialised, result.queryString);
onRestoreState({ ...result, filter: result.filter ?? "" });
}
}, [setClauses, setTree, onRestoreState, odataFilterWithState]);

Expand Down
90 changes: 54 additions & 36 deletions packages/base/FilterBuilder/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { useCallback } from "react";
import { useRecoilValue, waitForAll } from "recoil"
import { rootGroupUuid } from "./constants";
import { clauseState, schemaState, treeState } from "./state"
import { BaseFieldDef, SerialisedCondition, ConditionClause, FieldDef, SerialisedGroup, GroupClause, Operation, QueryStringCollection, StateClause, StateTree, TreeGroup } from "./types";
import { defaultTranslators } from "./translation";
import { BaseFieldDef, SerialisedCondition, ConditionClause, FieldDef, SerialisedGroup, GroupClause, Operation, QueryStringCollection, StateClause, StateTree, TreeGroup, FilterCompute, FilterTranslator } from "./types";

export const UseODataFilter = () => {
const schema = useRecoilValue(schemaState);
Expand All @@ -23,6 +24,8 @@ export const UseODataFilterWithState = () => {

type BuiltInnerQuery = {
filter?: string,
compute?: string,
select?: string[],
queryString?: QueryStringCollection
}

Expand Down Expand Up @@ -53,12 +56,16 @@ const buildGroup = (schema: FieldDef[], clauses: StateClause, tree: StateTree, i
if (childClauses.length > 1) {
return {
filter: `(${childClauses.filter(c => c.filter).map(c => c.filter).join(` ${clause.connective} `)})`,
compute: `${childClauses.filter(c => c.compute).map(c => c.compute).join(",")}`,
select: childClauses.filter(c => c.select).flatMap(c => c.select!),
serialised: { connective: clause.connective, children: childClauses.map(c => c.serialised) },
queryString: childClauses.reduce((x, c) => ({ ...x, ...c.queryString }), {})
};
} else if (childClauses.length === 1) {
return {
filter: childClauses[0].filter,
compute: childClauses[0].compute,
select: childClauses[0].select,
serialised: { connective: clause.connective, children: [childClauses[0].serialised] },
queryString: childClauses[0].queryString
}
Expand Down Expand Up @@ -109,60 +116,71 @@ const buildCondition = (schema: FieldDef[], clauses: StateClause, id: string): (
}

if (typeof innerResult !== "boolean") {
if (typeof innerResult === "string") {
if (innerResult.filter) {
return {
filter: innerResult,
filter: innerResult.filter,
compute: innerResult.compute,
select: innerResult.select,
serialised: condition
};
}
} else {
return {
serialised: condition,
queryString: innerResult
}
queryString: innerResult.queryString
};
}
} else {
return false;
}
}

const buildInnerCondition = (schema: BaseFieldDef, field: string, op: Operation, value: any): string | QueryStringCollection | boolean => {
const buildInnerCondition = (schema: BaseFieldDef, field: string, op: Operation, value: any): BuiltInnerQuery | boolean => {
if (schema.getCustomQueryString) {
return schema.getCustomQueryString(op, value);
return {
queryString: schema.getCustomQueryString(op, value)
};
}

if (schema.getCustomFilterString) {
return schema.getCustomFilterString(op, value)
}
const result = schema.getCustomFilterString(op, value);

if (op === "contains") {
if ((schema.type && schema.type !== "string") || typeof value !== "string") {
console.warn(`Warning: operation "contains" is only supported for fields of type "string"`);
return false;
}
if (schema.caseSensitive === true) {
return `contains(${field}, '${value}')`;
} else {
return `contains(tolower(${field}), tolower('${value}'))`;
}
} else if (op === "null") {
return `${field} eq null`;
} else if (op === "notnull") {
return `${field} ne null`;
} else {
if (schema.type === "date") {
return `date(${field}) ${op} ${value}`;
} else if (schema.type === "datetime") {
return `${field} ${op} ${value}`;
} else if (schema.type === "boolean") {
return `${field} ${op} ${value}`;
} else if (!schema.type || schema.type === "string" || typeof value === "string") {
if (schema.caseSensitive === true) {
return `${field} ${op} '${value}'`;
if (typeof result === "string") {
return {
filter: result
}
} else if (typeof result !== "boolean") {
const compute = result.compute;
if (typeof compute === "string") {
return {
filter: result.filter,
compute: compute
};
} else {
return `tolower(${field}) ${op} tolower('${value}')`;
return {
filter: result.filter,
compute: compute.compute,
select: compute.select
};
}
} else {
return `${field} ${op} ${value}`;
return result;
}
}

let translator: FilterTranslator;
if (op in defaultTranslators) {
translator = defaultTranslators[op]!;
} else {
translator = defaultTranslators["default"]!;
}

const result = translator(schema, field, op, value);

if (typeof result === "string") {
return {
filter: result
};
} else {
return result;
}
}
42 changes: 42 additions & 0 deletions packages/base/FilterBuilder/translation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { FilterTranslatorCollection } from "./types";
import { escapeODataString } from "./utils";

export const defaultTranslators: FilterTranslatorCollection = {
"contains": (schema, field, op, value) => {
if ((schema.type && schema.type !== "string") || typeof value !== "string") {
console.warn(`Warning: operation "contains" is only supported for fields of type "string"`);
return false;
}
if (schema.caseSensitive === true) {
return `contains(${field}, '${escapeODataString(value)}')`;
} else {
return `contains(tolower(${field}), tolower('${escapeODataString(value)}'))`;
}
},

"null": (schema, field, op, value) => {
return `${field} eq null`;
},

"notnull": (schema, field, op, value) => {
return `${field} ne null`;
},

"default": (schema, field, op, value) => {
if (schema.type === "date") {
return `date(${field}) ${op} ${value}`;
} else if (schema.type === "datetime") {
return `${field} ${op} ${value}`;
} else if (schema.type === "boolean") {
return `${field} ${op} ${value}`;
} else if (!schema.type || schema.type === "string" || typeof value === "string") {
if (schema.caseSensitive === true) {
return `${field} ${op} '${escapeODataString(value)}'`;
} else {
return `tolower(${field}) ${op} tolower('${escapeODataString(value)}')`;
}
} else {
return `${field} ${op} ${value}`;
}
}
}
30 changes: 27 additions & 3 deletions packages/base/FilterBuilder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { ValueOption } from "../types";

export type ExternalBuilderProps = {
searchMenuItems?: ({ label: string, onClick: () => void })[],
onSubmit?: (filter: string, serialised: SerialisedGroup | undefined, queryString: QueryStringCollection | undefined) => (void | any),
onRestoreState?: (filter: string, serialised: SerialisedGroup | undefined, queryString: QueryStringCollection | undefined, state?: any) => void,
onSubmit?: (params: FilterParameters) => (void | any),
onRestoreState?: (params: FilterParameters, state?: any) => void,
localeText?: FilterBuilderLocaleText,

autocompleteGroups?: string[],
Expand All @@ -24,6 +24,14 @@ export type ExternalBuilderProps = {
filter?: SerialisedGroup
}

export type FilterParameters = {
compute?: string,
filter: string,
queryString?: QueryStringCollection,
select?: string[],
serialised?: SerialisedGroup,
}

export type FilterBuilderLocaleText = {
and?: string,
or?: string,
Expand Down Expand Up @@ -68,7 +76,7 @@ export type BaseFieldDef = {
filterOperators?: Operation[],
filterType?: string,

getCustomFilterString?: (op: Operation, value: any) => string,
getCustomFilterString?: (op: Operation, value: any) => string | FilterCompute | boolean,
getCustomQueryString?: (op: Operation, value: any) => QueryStringCollection,

label?: string,
Expand All @@ -93,10 +101,26 @@ export type FieldDef = BaseFieldDef & {

export type CollectionFieldDef = BaseFieldDef;

export type FilterCompute = {
filter: string,
compute: string | ComputeSelect
}

export type ComputeSelect = {
compute: string,
select: string[]
}

export type QueryStringCollection = {
[key: string]: string
}

export type FilterTranslatorCollection = {
[key in Operation | "default"]?: FilterTranslator
}

export type FilterTranslator = (schema: BaseFieldDef, field: string, op: Operation, value: any) => string | boolean;

export type Connective = "and" | "or"

export type Operation = "eq" | "ne" | "gt" | "lt" | "ge" | "le" | "contains" | "null" | "notnull"
Expand Down
4 changes: 3 additions & 1 deletion packages/base/FilterBuilder/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,6 @@ const groupObjToMap = (obj: SerialisedGroup, id: string, clauses?: StateClause):
});

return [{ id: id, children: children }, clauses]
}
}

export const escapeODataString = (val: string) => val.replace("'", "''");
Loading

0 comments on commit 76d8528

Please sign in to comment.