diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json new file mode 100644 index 000000000..bd6d1f8de --- /dev/null +++ b/.codesandbox/ci.json @@ -0,0 +1,19 @@ +{ + "packages": [ + "packages/core", + "packages/ui", + "packages/antd", + "packages/mui", + "packages/material", + "packages/bootstrap", + "packages/fluent" + ], + "sandboxes": [ + "/packages/examples", + "/packages/sandbox", + "/packages/sandbox_simple", + "/packages/sandbox_next" + ], + "node": "18", + "buildCommand": "build-libs" +} \ No newline at end of file diff --git a/.deepsource.toml b/.deepsource.toml index 41bc81f4c..37167d37e 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -5,6 +5,7 @@ test_patterns = ["tests/**"] exclude_patterns = [ "packages/sandbox/**", "packages/sandbox_simple/**", + "packages/sandbox_next/**", "scripts/**" ] diff --git a/.github/workflows/codecov-report.yml b/.github/workflows/codecov-report.yml index aebc9bd28..2a4758a03 100644 --- a/.github/workflows/codecov-report.yml +++ b/.github/workflows/codecov-report.yml @@ -2,7 +2,9 @@ name: CodeCov Report on: release: - types: [published] + types: [ published ] + pull_request: + branches: [ master ] workflow_dispatch: jobs: diff --git a/.gitignore b/.gitignore index 9d2ec813b..4e9afa50b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ coverage/ junit/ /package/ *.log +.next/ package-lock.json yarn.lock diff --git a/.npmignore b/.npmignore index 90ccd39b8..ac6179c36 100644 --- a/.npmignore +++ b/.npmignore @@ -6,6 +6,7 @@ coverage/ junit/ /package/ *.log +.next/ package-lock.json yarn.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a202fd84..9e80ec13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # Changelog +- 6.3.0 + - Allow saving and loading config from server (PR #866) (issue #817) + - New utils: `compressConfig()`, `decompressConfig()` + - New `settings.useConfigCompress` + - Config now has `ctx` property + - Added new test app `sandbox_next` to demonstrate new server-side features + - Export utils (like `mongoFormatOp1`, `mongoFormatOp2`) in `CoreConfig.ctx.utils` (PR #866) (issue #890) + - 2nd parameter of `onChange` callback now equals original config, not extended config (PR #866) (issue #364) - 6.2.0 - Fixed type `Config`: should have render settings like `renderSize` (PR #909) (issue #879) - Fixed type for `renderBeforeWidget`: `RuleProps` instead of wrong `FieldProps` (PR #909) (issue #879) diff --git a/CONFIG.adoc b/CONFIG.adoc index c3a856664..4788a2a17 100644 --- a/CONFIG.adoc +++ b/CONFIG.adoc @@ -10,15 +10,21 @@ ifdef::env-github[] :warning-caption: :warning: endif::[] - +:queryString: https://github.com/ukrbublik/react-awesome-query-builder/#querystring-immutablevalue-config-isfordisplay\--false---string +:config1: https://github.com/ukrbublik/react-awesome-query-builder/tree/master/packages/sandbox_simple/src/demo/config.jsx +:config2: https://github.com/ukrbublik/react-awesome-query-builder/tree/master/packages/sandbox/src/demo/config.tsx +:config3: https://github.com/ukrbublik/react-awesome-query-builder/tree/master/packages/examples/demo/config.tsx +:funcs: https://github.com/ukrbublik/react-awesome-query-builder/tree/master/packages/core/modules/config/funcs.js +:renderSwitch: https://github.com/ukrbublik/react-awesome-query-builder/blob/master/packages/antd/modules/config/index.jsx#L54 +:config_ser: https://github.com/ukrbublik/react-awesome-query-builder/blob/master/packages/sandbox_next/lib/config_ser.js = Config format -Has 7 sections: +Has 8 sections: [source,javascript] ---- -{conjunctions, operators, widgets, types, funcs, settings, fields} +{conjunctions, operators, widgets, types, funcs, settings, fields, ctx} ---- Each section is described below. @@ -27,17 +33,19 @@ Usually it's enough to just reuse link:#basic-config[basic config], provide your Optionally you can override some options in basic config or add your own types/widgets/operators (or even conjunctions like XOR or NOR). There are functions for building query string: `formatConj`, `formatValue`, `formatOp`, `formatField`, `formatFunc` which are used for `QbUtils.queryString()`. + -They have common param `isForDisplay` - false by default, true will be used for (https://github.com/ukrbublik/react-awesome-query-builder/#querystring-immutablevalue-config-isfordisplay--false---string)[`QbUtils.queryString(immutableTree, config, true)`] (see 3rd param true). + +They have common param `isForDisplay` - false by default, true will be used for {queryString}[`QbUtils.queryString(immutableTree, config, true)`] (see 3rd param true). + Also there are similar `mongoConj`, `mongoFormatOp`, `mongoFormatValue`, `mongoFunc`, `mongoFormatFunc`, `mongoArgsAsObject` for building MongoDb query with `QbUtils.mongodbFormat()`. + And `sqlFormatConj`, `sqlOp`, `sqlFormatOp`, `sqlFormatValue`, `sqlFormatReverse`, `formatSpelField`, `sqlFunc`, `sqlFormatFunc` for building SQL where query with `QbUtils.sqlFormat()`. + And `spelFormatConj`, `spelOp`, `spelFormatOp`, `spelFormatValue`, `spelFormatReverse`, `spelFunc`, `spelFormatFunc` for building query in (https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/expressions.html)[Spring Expression Language (SpEL)] with `QbUtils.spelFormat()`. + And `jsonLogic` for building http://jsonlogic.com[JsonLogic] with `QbUtils.jsonLogicFormat()`. + -TIP: Example: https://github.com/ukrbublik/react-awesome-query-builder/tree/master/packages/examples/demo/config.tsx[`demo config`] +TIP: Example 1: {config1}[`config for sandbox_simple`] +TIP: Example 2: {config2}[`config for sandbox`] {nbsp} + +[[basicconfig]] == Basic config - Use `BasicConfig` for simple vanilla UI @@ -73,67 +81,68 @@ What is in basic config? [source,javascript] ---- const { - conjunctions: { - AND, - OR - }, - operators: { - equal, - not_equal, - less, - less_or_equal, - greater, - greater_or_equal, - like, - not_like, - starts_with, - ends_with, - between, - not_between, - is_null, - is_not_null, - is_empty, - is_not_empty, - select_equals, // like `equal`, but for select - select_not_equals, - select_any_in, - select_not_any_in, - multiselect_contains, - multiselect_not_contains, - multiselect_equals, // like `equal`, but for multiselect - multiselect_not_equals, - proximity, // complex operator with options - }, - widgets: { - text, - textarea, - number, - slider, - rangeslider, // missing in `BasicConfig`, `BootstrapConfig`, `FluentUIConfig` - select, - multiselect, - treeselect, // present only in `AntdConfig` - treemultiselect, // present only in `AntdConfig` - date, - time, - datetime, - boolean, - field, // to compare field with another field of same type - func, // to compare field with result of function - }, - types: { - text, - number, - date, - time, - datetime, - select, - multiselect, - treeselect, - treemultiselect, - boolean, - }, - settings + conjunctions: { + AND, + OR + }, + operators: { + equal, + not_equal, + less, + less_or_equal, + greater, + greater_or_equal, + like, + not_like, + starts_with, + ends_with, + between, + not_between, + is_null, + is_not_null, + is_empty, + is_not_empty, + select_equals, // like `equal`, but for select + select_not_equals, + select_any_in, + select_not_any_in, + multiselect_contains, + multiselect_not_contains, + multiselect_equals, // like `equal`, but for multiselect + multiselect_not_equals, + proximity, // complex operator with options + }, + widgets: { + text, + textarea, + number, + slider, + rangeslider, // missing in `BasicConfig`, `BootstrapConfig`, `FluentUIConfig` + select, + multiselect, + treeselect, // present only in `AntdConfig` + treemultiselect, // present only in `AntdConfig` + date, + time, + datetime, + boolean, + field, // to compare field with another field of same type + func, // to compare field with result of function + }, + types: { + text, + number, + date, + time, + datetime, + select, + multiselect, + treeselect, + treemultiselect, + boolean, + }, + settings, + ctx, } = AntdConfig; ---- @@ -195,6 +204,7 @@ Example: By default, if nested field is selected (eg. `name` of `user` in example above), `` component will show `name`. + Just `name` can be confusing, so can be overriden by setting `label2` to something like `User name`. + As alternative, you can use `` component which handles nested fields right. See `renderField` in link:#configsettings[settings]. +|fieldName | | |By default field name for export is constructed from current feild key and ancestors keys joined by `settings.fieldSeparator`. You can override this by specifying `fieldName` |tooltip | | |Optional tooltip to be displayed in field list by hovering on item |fieldSettings | | |Settings for widgets, will be passed as props. Example: `{min: 1, max: 10}` + Available settings for Number and Slider widgets: `min`, `max`, `step`. Slider also supports `marks` (example: `{ 0: "0%", 100: "100%" }`). + @@ -219,7 +229,7 @@ Example: |fieldSettings.useAsyncSearch |- for `select` widget |false |If true, `asyncFetch` supports search. |fieldSettings.useLoadMore |- for `select` widget |false |If true, `asyncFetch` supports pagination. |defaultValue | | |Default value -|preferWidgets | | |See usecase at https://github.com/ukrbublik/react-awesome-query-builder/tree/master/packages/examples/demo/config.tsx[`examples/demo`] for `slider` field. + +|preferWidgets | | |See usecase at {config3}[`examples/demo`] for `slider` field. + Its type is `number`. There are 3 widgets defined for number type: `number`, `slider`, `rangeslider`. + So setting `preferWidgets: ['slider', 'rangeslider']` will force rendering slider, and setting `preferWidgets: ['number']` will render number input. |operators, defaultOperator, widgets, valueSources | | |You can override config of corresponding type (see below at section link:#configtypes[config.types]) @@ -324,6 +334,7 @@ Behaviour settings: |removeEmptyGroupsOnLoad |true |Remove empty groups during validation in `Utils.checkTree()`? |removeInvalidMultiSelectValuesOnLoad |true |Remove values that are not in `listValues` during validation in `Utils.checkTree()`? + By default `true`, but `false` for AntDesign as can be removed manually +|useConfigCompress |false |Set to `true` if you use `Utils.decompressConfig()` |=== TIP: For fully read-only mode use these settings: @@ -350,7 +361,7 @@ Render settings: Available widgets for AntDesign: `FieldSelect`, `FieldDropdown` |renderFunc |`(props) => ` |Render functions list + Available widgets for AntDesign: `FieldSelect`, `FieldDropdown` -|renderConjs, renderButton, renderButtonGroup, renderSwitch, renderProvider, renderValueSources, renderConfirm, useConfirm, renderRuleError | |Other internal render functions you can override if using another UI framework (https://github.com/ukrbublik/react-awesome-query-builder/blob/master/packages/antd/modules/config/index.jsx#L54[example]) +|renderConjs, renderButton, renderButtonGroup, renderSwitch, renderProvider, renderValueSources, renderConfirm, useConfirm, renderRuleError | |Other internal render functions you can override if using another UI framework ({renderSwitch}[example]) |renderItem | |Render Item + Able to Customize Render behavior for rule/group items. |showLabels |false |Show labels above all fields? @@ -536,7 +547,7 @@ There is also special `proximity` operator, its options are rendered with `` |timeFormat | |`HH:mm:ss` |Option for ``, `` to display time in widget. Example: `'HH:mm'` |use12Hours | |`false` |Option for `` -|useKeyboard | |`true` |Option for Material-UI date/time pickers, `false` disables input with keyboard, only picker use is allowed |dateFormat | |`YYYY-MM-DD` |Option for ``, `` to display date in widget. Example: `YYYY-MM-DD` |valueFormat | | |Option for ``, ``, `` to prepare string representation of value to be stored. Example: `YYYY-MM-DD HH:mm` |labelYes, labelNo | | |Option for `` @@ -681,7 +691,7 @@ To enable this feature set `valueSources` of type to `['value', 'func']` (see be |valueSources | |keys of `valueSourcesInfo` at link:#configsettings[config.settings] |Array with values `'value'`, `'field'`, `'func'`. If `'value'` is included, you can compare field with values. If `'field'` is included, you can compare field with another field of same type. If `'func'` is included, you can compare field with result of function (see link:#configfuncs[config.funcs]). |defaultOperator | | |If specified, it will be auto selected when user selects field |widgets.* |+ | |Available widgets for current type and their config. + - Normally there is only 1 widget per type. But see type `number` at https://github.com/ukrbublik/react-awesome-query-builder/tree/master/packages/examples/demo/config.tsx[`examples/demo`] - it has 3 widgets `number`, `slider`, `rangeslider`. + + Normally there is only 1 widget per type. But see type `number` at {config3}[`examples/demo`] - it has 3 widgets `number`, `slider`, `rangeslider`. + Or see type `select` - it has widget `select` for operator `=` and widget `multiselect` for operator `IN`. |widgets..operators | | |List of operators for widget, described in link:#configoperators[config.operators] |widgets..widgetProps | | |Can be used to override config of corresponding widget specified in link:#configwidgets[config.widgets]. Example: `{timeFormat: 'h:mm:ss A'}` for time field with AM/PM. @@ -695,6 +705,7 @@ To enable this feature set `valueSources` of type to `['value', 'func']` (see be [[configfuncs]] === config.funcs + [source,javascript] ---- { @@ -714,6 +725,7 @@ To enable this feature set `valueSources` of type to `['value', 'func']` (see be } ---- + [cols="1m,1,1,5a",options="header",] |=== |key |required |default |meaning @@ -749,3 +761,231 @@ To enable this feature set `valueSources` of type to `['value', 'func']` (see be |renderSeps | |`[', ']` |Can render custom arguments separators in UI (other than `,`). |allowSelfNesting | |false |Allows the function to be used within its own arguments. |=== + + +See the collection of {funcs}[basic funcstions]. +You can copy them to `config.funcs`: +[source,javascript] +---- +import { BasicFuncs } from '@react-awesome-query-builder/ui'; +const config = { + //... + funcs: { + LINEAR_REGRESSION: BasicFuncs.LINEAR_REGRESSION, + LOWER: BasicFuncs.LOWER, + } +}; +---- + + +{nbsp} + +{nbsp} + +[[configctx]] +=== config.ctx + +Required starting from version 6.3.0 + +It is a collection of JS functions and React components to be used in other sections of config by reference to `ctx` rather than by reference to imported modules. + +TIP: The purpose of `ctx` is to isolate non-serializable part of config. + +Typically you just need to copy it from link:#basicconfig[basic config] - `AntdConfig.ctx` for AntDesign, `MuiConfig.ctx` for MUI, `BasicConfig.ctx` for vanilla UI etc. + +But if you use advanced server-side config, you may need to add your custom functions (eg. `validateValue`) to `ctx` and refer to them in other config sections by name. + +[source,javascript] +---- +import {BasicConfig} from '@react-awesome-query-builder/ui'; + +const fields = { + firstName: { + type: "text", + fieldSettings: { + // use function `validateFirstName` from `ctx` by name + validateValue: "validateFirstName", + } + }, +}; + +const ctx = { + ...BasicConfig.ctx, + validateFirstName: (val: string) => { + return (val.length < 10); + }, +}; + +// `zipConfig` can be passed to backend as JSON +const zipConfig = { + fields, + settings: { + useConfigCompress: true, // this is required to use Utils.decompressConfig() + }, + // you can add here other sections like `widgets` or `types`, but don't add `ctx` +}; + +// Config can be loaded from backend with providing `ctx` +const config = Utils.decompressConfig(zipConfig, BasicConfig, ctx); +---- + +You _can't_ just pass JS function to `validateValue` in `fieldSettings` because functions can't be serialized to JSON. + +{nbsp} + +The shape of `ctx`: +[source,javascript] +---- +const ctx = { + // provided in BasicConfig: + RCE: React.createElement, + W: { + VanillaButton, + // ... other widgets provieded with the lib + }, + utils: { + moment, // used in `formatValue` + SqlString, // used in `sqlFormatValue` + // ... other utils + }, + // your custom extensions: + components: { + MyLabel, // used in `labelYes` and `labelNo` below + // ... other custom components used in JSXs in your config + }, + validateFirstName: (val: string) => { + return (val.length < 10); + }, + myRenderField: (props: FieldProps, _ctx: ConfigContext) => { + if (props?.customProps["showSearch"]) { + return ; + } else { + return ; + } + }, + autocompleteFetch, // see implementation in `/packages/sandbox_next/components/demo/config_ctx.tsx` +} +---- + +Referring to `ctx` in `zipConfig`: +[source,javascript] +---- +const zipConfig = { + fields: { + firstName: { + type: "text", + fieldSettings: { + validateValue: "validateFirstName", + } + }, + in_stock: { + type: "boolean", + mainWidgetProps: { + labelYes: Yes, + labelNo: No, + } + }, + autocomplete: { + type: "select", + fieldSettings: { + asyncFetch: "autocompleteFetch", + }, + }, + }, + settings: { + renderField: "myRenderField", + renderButton: "W.VanillaButton", + useConfigCompress: true, // this is required + }, +}; +---- + +To build zip config from full config you can use this util: +[source,javascript] +---- +const zipConfig = Utils.compressConfig(config, BasicConfig); +---- +In order to generate zip config corretly (to JSON-serializable object), you should put your custom functions to `ctx` and refer to them by names as in examples above. +[source,javascript] +---- +import merge from "lodash/merge"; +const ctx = { + validateFirstName: (val) => { + return (val.length < 10); + }, +}; +const config = merge({}, BasicConfig, { + fields: { + firstName: { + type: "text", + fieldSettings: { + validateValue: "validateFirstName", + } + }, + }, + ctx, +}); +const zipConfig = Utils.compressConfig(config, BasicConfig); +const config2 = Utils.decompressConfig(zipConfig, BasicConfig, ctx); // should be same as `config` +---- + +NOTE: `settings.useConfigCompress` should be `true` if you use `Utils.decompressConfig()` + + +{nbsp} + +{nbsp} + +[[configserialize]] +=== Serialize entire config to string + +Section link:#configctx[config.ctx] demonstrates the concept of `zipConfig` which is a special config format that contains _only changes_ against full config and can be serialized to JSON. +Base configs provided by this library (`BasicConfig`, `AntdConfig` etc.) still has JS functions (at least for the moment of writing, in version 6.3.0). +If you want to serialize the _entire_ config (not only changes), you can't do it to JSON as it contains JS functions. But you can do it to string with a help of https://www.npmjs.com/package/serialize-javascript[serialize-javascript] and deserialize back with `eval()`. Yes, it's *unsecure*, but can be used for some purposes. + +CAUTION: Using `eval()` is not secure + +To achieve this ability, JS functions in config (like `factory`, `formatValue`, `validateValue` etc.) should be pure functions, they should not use imported modules like this: +[source,javascript] +---- +import { VanillaWidgets } from '@react-awesome-query-builder/ui'; +const { VanillaButton } = VanillaWidgets; +import moment from "moment"; + +const config = { + settings: { + renderButton: (props) => , + }, + widgets: { + date: { + jsonLogic: (val, fieldDef, wgtDef) => moment(val, wgtDef.valueFormat).toDate(), + }, + }, +}; +---- +If you try to serialize this config and deserialize back with `eval()`, you will get +`ReferenceError: react\__WEBPACK_IMPORTED_MODULE_0___default is not defined`. + +Instead of this all imported modules should be included in `config.ctx`. All render functions in config have `ctx` as 2nd argument. In format functions you can refer to `ctx` via `this`. +[source,javascript] +---- +const config = { + settings: { + renderButton: (props, {RCE, W: {VanillaButton}}) => RCE(VanillaButton, props), + }, + widgets: { + date: { + jsonLogic: function (val, fieldDef, wgtDef) { + return this.utils.moment(val, wgtDef.valueFormat).toDate(); + }, + }, + }, + + ctx: { + RCE: React.createElement, + W: { + VanillaButton, + }, + utils: { + moment, + }, + } +}; +---- +Now entire config (without `ctx`) can be serialized to a string with https://www.npmjs.com/package/serialize-javascript[serialize-javascript] and then deserialized back with `eval()` and appending `ctx`. +See {config_ser}[example]. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a823b8832..3d64a7be9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,7 @@ You will see demo app with hot reload of demo code and local library code. - [`demo_switch`](/packages/examples/demo_switch) - Demo of ternary mode (switch/case) for SpEL - [`packages/sandbox`](/packages/sandbox) - Demo app with hot reload of only demo code (uses latest version of library from npm), uses TS, uses AntDesign widgets. - [`packages/sandbox_simple`](/packages/sandbox_simple) - Demo app with hot reload of only demo code (uses latest version of library from npm), not uses TS, uses vanilla widgets. +- [`packages/sandbox_next`](/packages/sandbox_next) - Demo app on Next.js with SSR, simple server-side query storage and export - [`packages/tests`](/packages/tests) - All tests are here. Uses Karma, Mocha, Chai, Enzyme ### Scripts diff --git a/README.md b/README.md index 98798572a..5cb0266e1 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ [![bootstrap](https://img.shields.io/badge/skin-Bootstrap-blue?logo=Bootstrap)](https://reactstrap.github.io/) [![fluent](https://img.shields.io/badge/skin-Fluent%20UI-blue?logo=Microsoft%20Office)](https://developer.microsoft.com/en-us/fluentui) [![demo](https://img.shields.io/badge/demo-blue)](https://ukrbublik.github.io/react-awesome-query-builder/) -[![sandbox TS](https://img.shields.io/badge/sandbox-TS-blue)](https://codesandbox.io/s/github/ukrbublik/react-awesome-query-builder/tree/master/packages/sandbox?file=/src/demo/config_simple.tsx) -[![sandbox JS](https://img.shields.io/badge/sandbox-JS-blue)](https://codesandbox.io/s/github/ukrbublik/react-awesome-query-builder/tree/master/packages/sandbox_simple?file=/src/demo/config_simple.js) +[![sandbox TS](https://img.shields.io/badge/sandbox-TS-blue)](https://codesandbox.io/s/github/ukrbublik/react-awesome-query-builder/tree/master/packages/sandbox?file=/src/demo/config.tsx) +[![sandbox JS](https://img.shields.io/badge/sandbox-JS-blue)](https://codesandbox.io/s/github/ukrbublik/react-awesome-query-builder/tree/master/packages/sandbox_simple?file=/src/demo/config.jsx) User-friendly React component to build queries (filters). @@ -37,9 +37,16 @@ See [live demo](https://ukrbublik.github.io/react-awesome-query-builder) * [Minimal JavaScript example with class component](#minimal-javascript-example-with-class-component) * [Minimal TypeScript example with function component](#minimal-typescript-example-with-function-component) * [API](#api) + * [](#query-) + * [](#builder-) + * [Utils](#utils) * [Config format](#config-format) +* [SSR](#ssr) + * [ctx](#ctx) * [Versions](#versions) * [Changelog](#changelog) + * [Migration to 6.3.0](#migration-to-630) + * [Migration to 6.2.0](#migration-to-620) * [Migration to 6.0.0](#migration-to-600) * [Migration to 5.2.0](#migration-to-520) * [Migration to 4.9.0](#migration-to-490) @@ -76,6 +83,7 @@ See [live demo](https://ukrbublik.github.io/react-awesome-query-builder) - Export to MongoDb, SQL, [JsonLogic](http://jsonlogic.com), [SpEL](https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/expressions.html), ElasticSearch or your custom format - Import from [JsonLogic](http://jsonlogic.com), [SpEL](https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/expressions.html) - TypeScript support (see [types](/packages/core/modules/index.d.ts) and [demo in TS](/packages/examples)) +- Query value and config can be saved/loaded from server ## Getting started @@ -122,9 +130,10 @@ See [API](#api) and [config](#config-format) for documentation. ## Demo apps -- [`pnpm start`](/packages/examples) - demo app with hot reload of demo code and local library code, uses TS, uses complex config to demonstrate anvanced usage. -- [`pnpm sandbox-ts`](/packages/sandbox) - simple demo app, built with Vite, uses TS, uses AntDesign widgets. -- [`pnpm sandbox-js`](/packages/sandbox_simple) - simple demo app, built with Vite, not uses TS, uses vanilla widgets. +- [`pnpm start`](/packages/examples) - demo app with hot reload of demo code and local library code, uses TS, uses complex config to demonstrate anvanced usage, uses all supported UI frameworks. +- [`pnpm sandbox-ts`](/packages/sandbox) - simple demo app, built with Vite, uses TS, uses MUI widgets. +- [`pnpm sandbox-js`](/packages/sandbox_simple) - simplest demo app, built with Vite, not uses TS, uses vanilla widgets. +- [`pnpm sandbox-next`](/packages/sandbox_next) - advanced demo app with server side, built with Next.js, uses TS, uses MUI widgets, has API to save/load query value and query config from storage. ## Usage @@ -406,7 +415,7 @@ Wrapping in `div.query-builder-container` is necessary if you put query builder Convert query value from internal Immutable format to JS object. You can use it to save value on backend in `onChange` callback of ``. Tip: Use `light = false` in case if you want to store query value in your state in JS format and pass it as `value` of `` after applying `loadTree()` (which is not recommended because of double conversion). See issue [#190](https://github.com/ukrbublik/react-awesome-query-builder/issues/190) - #### loadTree (jsValue, config) -> Immutable + #### loadTree (jsValue) -> Immutable Convert query value from JS format to internal Immutable format. You can use it to load saved value from backend and pass as `value` prop to `` (don't forget to also apply `checkTree()`). #### checkTree (immutableValue, config) -> Immutable @@ -435,7 +444,19 @@ Wrapping in `div.query-builder-container` is necessary if you put query builder #### _loadFromJsonLogic (jsonLogicObject, config) -> [Immutable, errors] #### loadFromSpel (string, config) -> [Immutable, errors] Convert query value from [Spring Expression Language (SpEL)](https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/expressions.html) format to internal Immutable format. - +- Save/load config from server: + #### compressConfig(config, baseConfig) -> ZipConfig + Returns compressed config that can be serialized to JSON and saved on server. + `ZipConfig` is a special format that contains only changes agains `baseConfig`. + `baseConfig` is a config you used as a base for constructing `config`, like `InitialConfig` in examples above. + It depends on UI framework you choose - eg. if you use `@react-awesome-query-builder/mui`, please provide `MuiConfig` to `baseConfig`. + #### decompressConfig(zipConfig, baseConfig, ctx?) -> Config + Converts `zipConfig` (compressed config you receive from server) to a full config that can be passed to ``. + `baseConfig` is a config to be used as a base for constructing your config, like `InitialConfig` in examples above. + [`ctx`](#ctx) is optional and can contain your custom functions and custom React components used in your config. + If `ctx` is provided in 3rd argument, it will inject it to result config, otherwise will copy from basic config in 2nd argument. + See [SSR](#ssr) for more info. + Note that you should set `config.settings.useConfigCompress = true` in order for this function to work. ### Config format @@ -445,6 +466,26 @@ At minimum, you need to provide your own set of fields as in [basic usage](#usag See [`CONFIG`](/CONFIG.adoc) for full documentation. +## SSR +You can save and load config from server with help of utils: +- [Utils.compressConfig()](#compressconfigconfig-baseconfig---zipconfig) +- [Utils.decompressConfig()](#decompressconfigzipconfig-baseconfig-ctx---config) + +You need these utils because you can't just send config *as-is* to server, as it contains functions that can't be serialized to JSON. +Note that you need to set `config.settings.useConfigCompress = true` to enable this feature. + +To put it simple: +- `ZipConfig` type is a JSON that contains only changes against basic config (differences). At minimum it contains your `fields`. It does not contain [`ctx`](#ctx). +- `Utils.decompressConfig()` will merge `ZipConfig` to basic config (and add `ctx` if passed). + +See [sandbox_next demo app](/packages/sandbox_next) that demonstrates server-side features. + +### ctx +Config context is an obligatory part of config starting from version 6.3.0 +It is a collection of functions and React components to be used in other parts of config by reference to `ctx` rather than by reference to imported modules. +The purpose of `ctx` is to isolate non-serializable part of config. +See [config.ctx](/CONFIG.adoc#configctx). + ## Versions @@ -466,6 +507,43 @@ It's recommended to update your version to 6.x. You just need to change your imp ### Changelog See [`CHANGELOG`](/CHANGELOG.md) +### Migration to 6.3.0 + +Now config has new [`ctx`](#ctx) property. Make sure you add it to your config. + +Typically you just need to copy it from basic config. +So if you create config like this, you don't need to make any changes: +```js +import { MuiConfig } from "@react-awesome-query-builder/mui"; +const config = { + ...MuiConfig, + fields: { + // your fields + }, +}; +``` + +But if you create config without destructuring of basic config, please add `ctx`: +```js +import { MuiConfig } from "@react-awesome-query-builder/mui"; + +const config = { + ctx: MuiConfig.ctx, // needs to be added for 6.3.0+ + conjunctions, + operators, + widgets, + types, + settings, + fields, + funcs +}; +export default config; +``` + +### Migration to 6.2.0 + +If you use `treeselect` or `treemultiselect` type (for AntDesign), please rename `listValues` to `treeValues` + ### Migration to 6.0.0 From version 6 library is divided into [packages](/packages). diff --git a/package.json b/package.json index 88a52c83b..0b91e7619 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "packages/tests", "packages/examples", "packages/sandbox", - "packages/sandbox_simple" + "packages/sandbox_simple", + "packages/sandbox_next" ], "scripts": { "build": "pnpm run build-libs && pnpm run build-examples", @@ -68,6 +69,7 @@ "examples": "pnpm --filter @react-awesome-query-builder/examples run start", "sandbox-js": "pnpm --filter @react-awesome-query-builder/sandbox-simple run start", "sandbox-ts": "pnpm --filter @react-awesome-query-builder/sandbox run start", + "sandbox-next": "pnpm --filter @react-awesome-query-builder/sandbox-next run start", "sandbox": "pnpm run sandbox-ts", "start": "pnpm run examples", "resmoke": "pnpm run clean && pnpm i && pnpm run smoke", diff --git a/packages/antd/modules/config/index.jsx b/packages/antd/modules/config/index.jsx index a1c653d66..d7f4baf73 100644 --- a/packages/antd/modules/config/index.jsx +++ b/packages/antd/modules/config/index.jsx @@ -1,71 +1,39 @@ +import React from "react"; import en_US from "antd/es/locale/en_US"; import AntdWidgets from "../widgets"; import { normalizeListValues } from "../utils/stuff"; -import { Utils, BasicConfig } from "@react-awesome-query-builder/ui"; -import React from "react"; - -const { getTitleInListValues } = Utils.ListUtils; -const { SqlString, spelEscape, stringifyForDisplay } = Utils.ExportUtils; +import { BasicConfig, Utils } from "@react-awesome-query-builder/ui"; -const { - FieldSelect, - FieldDropdown, - FieldCascader, - FieldTreeSelect, - Button, - ButtonGroup, - Conjs, - Switch, - ValueSources, - - Provider, - confirm, -} = AntdWidgets; -const { - TextWidget, - TextAreaWidget, - NumberWidget, - SliderWidget, - RangeWidget, - SelectWidget, - MultiSelectWidget, - AutocompleteWidget, - TreeSelectWidget, - DateWidget, - BooleanWidget, - TimeWidget, - DateTimeWidget, -} = AntdWidgets; const settings = { ...BasicConfig.settings, - renderField: (props) => , - // renderField: (props) => , - // renderField: (props) => , - // renderField: (props) => , + renderField: (props, {RCE, W: {FieldSelect}}) => RCE(FieldSelect, props), + // renderField: (props, {RCE, W: {FieldDropdown}}) => RCE(FieldSelect, props), + // renderField: (props, {RCE, W: {FieldCascader}}) => RCE(FieldSelect, props), + // renderField: (props, {RCE, W: {FieldTreeSelect}}) => RCE(FieldSelect, props), - renderOperator: (props) => , - // renderOperator: (props) => , + renderOperator: (props, {RCE, W: {FieldSelect}}) => RCE(FieldSelect, props), + // renderOperator: (props, {RCE, W: {FieldDropdown}}) => RCE(FieldDropdown, props), - renderFunc: (props) => , - renderConjs: (props) => , - renderSwitch: (props) => , - renderButton: (props) => + + + + +
+ {this.renderResult(this.state)} +
+ + ); + }; + + resetValue = () => { + (async () => { + const response = await fetch("/api/tree" + "?initial=true"); + const result = await response.json() as GetTreeResult; + this.setState({ + tree: loadTree(result.jsonTree), + }); + })(); + }; + + clearValue = () => { + const emptyInitValue: JsonTree = {"id": uuid(), "type": "group"}; + this.setState({ + tree: loadTree(emptyInitValue), + }); + }; + + // It's just a test to show ability to serialize an entire config to string and deserialize back + stringifyConfig = () => { + const strConfig = UNSAFE_serializeConfig(this.state.config) as string; + const config = UNSAFE_deserializeConfig(strConfig, ctx) as Config; + console.log("Deserialized config (click to view):", config.conjunctions.AND.formatConj); + const spel = Utils.spelFormat(this.state.tree, config); + const jl = Utils.jsonLogicFormat(this.state.tree, config); + const mongo = Utils.mongodbFormat(this.state.tree, config); + const res = { + spel, + jl, + mongo, + }; + console.log("Format result:", res); + // this.setState({ + // tree: checkTree(this.state.tree, config), + // config, + // }); + }; + + updateConfig = () => { + (async () => { + const config = updateConfigWithSomeChanges(this.state.config); + const zipConfig = Utils.compressConfig(config, MuiConfig); + const response = await fetch("/api/config", { + method: "POST", + body: JSON.stringify({ + zipConfig, + } as PostConfigBody), + }); + const _result = await response.json() as PostConfigResult; + + this.setState({ + tree: checkTree(this.state.tree, config), + config, + }); + })(); + }; + + renderBuilder = (props: BuilderProps) => ( +
+
+ +
+
+ ); + + onChange = (tree: ImmutableTree, config: Config) => { + this.setState({ + tree + }, () => { + this.updateResult({ saveTree: true }); + }); + }; + + _updateResult = async ({ saveTree } = { saveTree: true }) => { + const response = await fetch(`/api/tree?saveTree=${saveTree ? "true" : "false"}`, { + method: "POST", + body: JSON.stringify({ + jsonTree: getTree(this.state.tree), + } as PostTreeBody), + }); + const result = await response.json() as PostTreeResult; + this.setState({ + result + }); + }; + + updateResult = throttle(this._updateResult, 200); + + renderResult = ({result: {jl, qs, qsh, sql, mongo}, tree: immutableTree} : {result: PostTreeResult, tree: ImmutableTree}) => { + if(!jl) return null; + const {logic, data, errors} = jl; + return ( +
+
+
+ stringFormat: +
+            {stringify(qs, undefined, 2)}
+          
+
+
+
+ humanStringFormat: +
+            {stringify(qsh, undefined, 2)}
+          
+
+
+
+ sqlFormat: +
+            {stringify(sql, undefined, 2)}
+          
+
+
+
+ mongodbFormat: +
+            {stringify(mongo, undefined, 2)}
+          
+
+
+
+ jsonLogicFormat: + { errors.length > 0 + &&
+                {stringify(errors, undefined, 2)}
+              
+ } + { !!logic + &&
+                {"// Rule"}:
+ {stringify(logic, undefined, 2)} +
+
+ {"// Data"}:
+ {stringify(data, undefined, 2)} +
+ } +
+
+
+ Tree: +
+            {stringify(getTree(immutableTree), undefined, 2)}
+          
+
+
+ ); + }; + +} diff --git a/packages/sandbox_next/data/autocomplete.js b/packages/sandbox_next/data/autocomplete.js new file mode 100644 index 000000000..4b7d58b09 --- /dev/null +++ b/packages/sandbox_next/data/autocomplete.js @@ -0,0 +1,17 @@ +const listValues = +[ + { title: "A", value: "a" }, + { title: "AA", value: "aa" }, + { title: "AAA1", value: "aaa1" }, + { title: "AAA2", value: "aaa2" }, + { title: "B", value: "b" }, + { title: "C", value: "c" }, + { title: "D", value: "d" }, + { title: "E", value: "e" }, + { title: "F", value: "f" }, + { title: "G", value: "g" }, + { title: "H", value: "h" }, + { title: "I", value: "i" }, + { title: "J", value: "j" }, +]; +export default listValues; diff --git a/packages/sandbox_next/data/init_logic.js b/packages/sandbox_next/data/init_logic.js new file mode 100644 index 000000000..2787aa120 --- /dev/null +++ b/packages/sandbox_next/data/init_logic.js @@ -0,0 +1,30 @@ +const value += { + "and": [ + { + "==": [ + { + "var": "user.login" + }, + "batman" + ] + }, + { + "==": [ + { + "var": "stock" + }, + false + ] + }, + { + "==": [ + { + "var": "slider" + }, + 35 + ] + } + ] +}; +export default value; diff --git a/packages/sandbox_next/data/init_value.js b/packages/sandbox_next/data/init_value.js new file mode 100644 index 000000000..6671a82b7 --- /dev/null +++ b/packages/sandbox_next/data/init_value.js @@ -0,0 +1,60 @@ +const value += { + "type": "group", + "id": "9a99988a-0123-4456-b89a-b1607f326fd8", + "children1": { + "a98ab9b9-cdef-4012-b456-71607f326fd9": { + "type": "rule", + "properties": { + "field": "user.login", + "operator": "equal", + "value": [ + "batman" + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "text" + ] + } + }, + "98a8a9ba-0123-4456-b89a-b16e721c8cd0": { + "type": "rule", + "properties": { + "field": "stock", + "operator": "equal", + "value": [ + false + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "boolean" + ] + } + }, + "aabbab8a-cdef-4012-b456-716e85c65e9c": { + "type": "rule", + "properties": { + "field": "slider", + "operator": "equal", + "value": [ + 35 + ], + "valueSrc": [ + "value" + ], + "valueType": [ + "number" + ] + } + } + }, + "properties": { + "conjunction": "AND", + "not": false + } +}; +export default value; diff --git a/packages/sandbox_next/lib/config.tsx b/packages/sandbox_next/lib/config.tsx new file mode 100644 index 000000000..aa90a0027 --- /dev/null +++ b/packages/sandbox_next/lib/config.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import type { + Config, FieldOrGroup, Operator, Settings, Widget, ConfigMixin +} from "@react-awesome-query-builder/ui"; +import merge from "lodash/merge"; +import pureServerConfig from "./config_base"; + +// Adds UI mixins to config created in `./config_base` - adds `asyncFetch`, custom React components, `factory` overrides. +// Exported config is used to generate initial zip config on server-side (see `serverConfig` in `pages/api/config`). +// On browser it can be decompressed to a full-featured config with a proper `ctx`. +// `ctx` should contain used funcs (like `autocompleteFetch`), React components (like `SliderMark`) - see `components/demo/config_ctx` +// +// ! Important ! +// Don't add JS functions to config, since it can't be used with SSR. +// Instead add functions to `ctx` and reference them with name in other sections of config (see `autocompleteFetch` or `myRenderField`). +// Or use JsonLogic functions instead, see `factory` (advanced usage, but doesn't change `ctx`). +// Add custom React components (like `SliderMark`) to `ctx.components` + + +// It's dummy implementation +// Just to show how you can include JSX in config and it will be serialized correctly with ConfigUtils.compressConfig() +// Real implementation in `components/demo/config_ctx` +const SliderMark: React.FC<{ pct: number }> = () => null; + +const fieldsMixin: Record> = { + slider: { + fieldSettings: { + marks: { + 0: , + 50: {50}%, + 100: , + }, + }, + }, + autocomplete: { + fieldSettings: { + // Real implementation of `autocompleteFetch` should be in `ctx` + asyncFetch: "autocompleteFetch", + }, + }, + autocompleteMultiple: { + fieldSettings: { + asyncFetch: "autocompleteFetch", + }, + }, +}; + +// (Advanced) Demostrates how you can use JsonLogic function to customize `factory` with some logic +const widgetsMixin: Record> = { + multiselect: { + factory: { + if: [ + { or: [ { var: "props.asyncFetch" }, { var: "props.showSearch" } ] }, + { JSX: ["MuiAutocompleteWidget", { mergeObjects: [ + { var: "props" }, + { fromEntries: [ [ [ "multiple", true ] ] ] } + ]}] }, + { JSX: ["MuiMultiSelectWidget", {var: "props"}] } + ] + } + }, + select: { + factory: { + if: [ + { or: [ { var: "props.asyncFetch" }, { var: "props.showSearch" } ] }, + { JSX: ["MuiAutocompleteWidget", {var: "props"}] }, + { JSX: ["MuiSelectWidget", {var: "props"}] } + ] + } + }, +}; + +const operatorsMixin: Record> = { + between: { + valueLabels: [ + "Value from", + "Value to" + ], + textSeparators: [ + from, + to, + ], + }, +}; + +const renderSettings: Partial = { + renderField: "myRenderField", + renderConfirm: "W.MuiConfirm", + useConfirm: "W.MuiUseConfirm", +}; + + +const configMixin: ConfigMixin = { + fields: fieldsMixin, + widgets: widgetsMixin, + operators: operatorsMixin, + settings: { + ...renderSettings, + locale: { + mui: { var: "ctx.ukUA" }, + }, + } +}; + + +const mixinConfig = (baseConfig: Config) => { + return merge( + {}, + baseConfig, + configMixin, + ) as Config; +}; + +export default mixinConfig(pureServerConfig); diff --git a/packages/sandbox_next/lib/config_base.ts b/packages/sandbox_next/lib/config_base.ts new file mode 100644 index 000000000..708dcdd9d --- /dev/null +++ b/packages/sandbox_next/lib/config_base.ts @@ -0,0 +1,398 @@ +/*eslint @typescript-eslint/no-unused-vars: ["off", {"varsIgnorePattern": "^_"}]*/ +import merge from "lodash/merge"; +import { + BasicFuncs, CoreConfig, + // types: + Settings, Operators, Widgets, Fields, Config, Types, Conjunctions, LocaleSettings, Funcs, OperatorProximity, Func, +} from "@react-awesome-query-builder/core"; + +// Create a config for demo app based on CoreConfig - add fields, funcs, some overrides. +// Additional UI modifications are done in `./config` (like `asyncFetch`, `marks`, `factory`) +// +// ! Important ! +// Don't use JS functions in config, since it can't be used with SSR. +// Instead add function to `ctx` and refer to it with a name, see `validateFirstName`. +// Or use JsonLogic functions, see `validateValue` for `login` field (advanced usage, but doesn't change `ctx`). + +function createConfig(InitialConfig: CoreConfig): Config { + + const fields: Fields = { + user: { + label: "User", + tooltip: "Group of fields", + type: "!struct", + subfields: { + firstName: { + label2: "Username", + type: "text", + excludeOperators: ["proximity"], + mainWidgetProps: { + valueLabel: "Name", + valuePlaceholder: "Enter name", + }, + fieldSettings: { + validateValue: "validateFirstName", + // -or- + // validateValue: { + // "<": [ {strlen: {var: "val"}}, 10 ] + // }, + // -incorrect- + // validateValue: (val: string) => { + // return (val.length < 10); + // }, + }, + }, + login: { + type: "text", + excludeOperators: ["proximity"], + fieldSettings: { + validateValue: { + and: [ + { "<": [ {strlen: {var: "val"}}, 10 ] }, + { or: [ + { "===": [ {var: "val"}, "" ] }, + { regexTest: [ {var: "val"}, "^[A-Za-z0-9_-]+$" ] } + ]} + ] + } + // -incorrect- + // (val: string) => { + // return (val.length < 10 && (val === "" || val.match(/^[A-Za-z0-9_-]+$/) !== null)); + // }, + }, + mainWidgetProps: { + valueLabel: "Login", + valuePlaceholder: "Enter login", + }, + } + } + }, + prox1: { + label: "prox", + tooltip: "Proximity search", + type: "text", + operators: ["proximity"], + }, + num: { + label: "Number", + type: "number", + preferWidgets: ["number"], + fieldSettings: { + min: -1, + max: 5 + }, + funcs: ["LINEAR_REGRESSION"], + }, + slider: { + label: "Slider", + type: "number", + preferWidgets: ["slider", "rangeslider"], + valueSources: ["value", "field"], + fieldSettings: { + min: 0, + max: 100, + step: 1, + }, + //overrides + widgets: { + slider: { + widgetProps: { + valuePlaceholder: "..Slider", + } + } + }, + }, + date: { + label: "Date", + type: "date", + valueSources: ["value"], + }, + time: { + label: "Time", + type: "time", + valueSources: ["value"], + operators: ["greater_or_equal", "less_or_equal", "between"], + defaultOperator: "between", + }, + datetime: { + label: "DateTime", + type: "datetime", + valueSources: ["value"] + }, + datetime2: { + label: "DateTime2", + type: "datetime", + valueSources: ["field"] + }, + color: { + label: "Color", + type: "select", + valueSources: ["value"], + fieldSettings: { + listValues: [ + { value: "yellow", title: "Yellow" }, + { value: "green", title: "Green" }, + { value: "orange", title: "Orange" } + ], + } + }, + color2: { + label: "Color2", + type: "select", + fieldSettings: { + listValues: { + yellow: "Yellow", + green: "Green", + orange: "Orange", + purple: "Purple" + }, + } + }, + multicolor: { + label: "Colors", + type: "multiselect", + fieldSettings: { + listValues: { + yellow: "Yellow", + green: "Green", + orange: "Orange" + }, + allowCustomValues: true + }, + }, + stock: { + label: "In stock", + type: "boolean", + defaultValue: true, + mainWidgetProps: { + labelYes: "+", + labelNo: "-" + } + }, + autocomplete: { + label: "Autocomplete", + type: "select", + valueSources: ["value"], + fieldSettings: { + useAsyncSearch: true, + useLoadMore: true, + forceAsyncSearch: false, + allowCustomValues: false + }, + }, + autocompleteMultiple: { + label: "AutocompleteMultiple", + type: "multiselect", + valueSources: ["value"], + fieldSettings: { + useAsyncSearch: true, + useLoadMore: true, + forceAsyncSearch: false, + allowCustomValues: false + }, + }, + }; + + + ////////////////////////////////////////////////////////////////////// + + const conjunctions: Conjunctions = { + AND: InitialConfig.conjunctions.AND, + OR: InitialConfig.conjunctions.OR, + }; + + + const proximity: OperatorProximity = { + ...InitialConfig.operators.proximity, + valueLabels: [ + { label: "Word 1", placeholder: "Enter first word" }, + { label: "Word 2", placeholder: "Enter second word" }, + ], + textSeparators: [ + //'Word 1', + //'Word 2' + ], + options: { + ...InitialConfig.operators.proximity.options, + optionLabel: "Near", // label on top of "near" selectbox (for config.settings.showLabels==true) + optionTextBefore: "Near", // label before "near" selectbox (for config.settings.showLabels==false) + optionPlaceholder: "Select words between", // placeholder for "near" selectbox + minProximity: 2, + maxProximity: 10, + defaults: { + proximity: 2 + }, + customProps: {} + } + }; + + const operators: Operators = { + ...InitialConfig.operators, + // examples of overriding + between: { + ...InitialConfig.operators.between, + valueLabels: [ + "Value from", + "Value to" + ], + textSeparators: [ + "from", + "to" + ], + }, + proximity, + }; + + const widgets: Widgets = { + ...InitialConfig.widgets, + // examples of overriding + text: { + ...InitialConfig.widgets.text, + }, + slider: { + ...InitialConfig.widgets.slider, + customProps: { + width: "300px" + } + }, + rangeslider: { + ...InitialConfig.widgets.rangeslider, + customProps: { + width: "300px" + } + }, + date: { + ...InitialConfig.widgets.date, + dateFormat: "DD.MM.YYYY", + valueFormat: "YYYY-MM-DD", + }, + time: { + ...InitialConfig.widgets.time, + timeFormat: "HH:mm", + valueFormat: "HH:mm:ss", + }, + datetime: { + ...InitialConfig.widgets.datetime, + timeFormat: "HH:mm", + dateFormat: "DD.MM.YYYY", + valueFormat: "YYYY-MM-DD HH:mm:ss", + }, + func: { + ...InitialConfig.widgets.func, + customProps: { + showSearch: true + } + }, + treeselect: { + ...InitialConfig.widgets.treeselect, + customProps: { + showSearch: true + } + }, + }; + + + const types: Types = { + ...InitialConfig.types, + // examples of overriding + boolean: merge({}, InitialConfig.types.boolean, { + widgets: { + boolean: { + widgetProps: { + hideOperator: true, + operatorInlineLabel: "is", + } + }, + }, + }), + }; + + + const localeSettings: LocaleSettings = { + locale: { + moment: "ru", + }, + valueLabel: "Value", + valuePlaceholder: "Value", + fieldLabel: "Field", + operatorLabel: "Operator", + fieldPlaceholder: "Select field", + operatorPlaceholder: "Select operator", + deleteLabel: null, + addGroupLabel: "Add group", + addRuleLabel: "Add rule", + addSubRuleLabel: "Add sub rule", + delGroupLabel: null, + notLabel: "Not", + valueSourcesPopupTitle: "Select value source", + removeRuleConfirmOptions: { + title: "Are you sure delete this rule?", + okText: "Yes", + okType: "danger", + }, + removeGroupConfirmOptions: { + title: "Are you sure delete this group?", + okText: "Yes", + okType: "danger", + }, + }; + + const settings: Settings = { + ...InitialConfig.settings, + ...localeSettings, + + useConfigCompress: true, + valueSourcesInfo: { + value: { + label: "Value" + }, + field: { + label: "Field", + widget: "field", + }, + func: { + label: "Function", + widget: "func", + } + }, + // canReorder: false, + // canRegroup: false, + // showNot: false, + // showLabels: true, + maxNesting: 3, + canLeaveEmptyGroup: true, //after deletion + }; + + + const funcs: Funcs = { + LINEAR_REGRESSION: BasicFuncs.LINEAR_REGRESSION, + LOWER: BasicFuncs.LOWER, + }; + + // ! Important ! + // Context is not saved to compressed config (zipConfig). + // You must provide `ctx` to `ConfigUtils.decompressConfig()`. + // `validateFirstName` should be defined in `components/demo/config_ctx` for using on client-side. + // Implementation here is used for server-side validation. + const ctx = { + ...InitialConfig.ctx, + validateFirstName: (val: string) => { + return (val.length < 10); + }, + }; + + const config: Config = { + conjunctions, + operators, + widgets, + types, + settings, + fields, + funcs, + ctx + }; + + return config; +} + +export default createConfig(CoreConfig); diff --git a/packages/sandbox_next/lib/config_ser.js b/packages/sandbox_next/lib/config_ser.js new file mode 100644 index 000000000..deb872f8a --- /dev/null +++ b/packages/sandbox_next/lib/config_ser.js @@ -0,0 +1,31 @@ +import serializeJs from "serialize-javascript"; +import { Utils } from "@react-awesome-query-builder/core"; +import mergeWith from "lodash/mergeWith"; +import omit from "lodash/omit"; + +// It's just a test to show ability to serialize an entire config to string and deserialize back + +const mergeCustomizerCleanJSX = (_objValue, srcValue, _key, _object, _source, _stack) => { + const { isDirtyJSX, cleanJSX } = Utils.ConfigUtils; + if (isDirtyJSX(srcValue)) { + return cleanJSX(srcValue); + } +}; + +export const UNSAFE_serializeConfig = (config) => { + const sanitizedConfig = mergeWith({}, omit(config, ["ctx"]), mergeCustomizerCleanJSX); + const strConfig = serializeJs(sanitizedConfig, { + space: 2, + unsafe: true, + }); + if (strConfig.includes("__WEBPACK_IMPORTED_MODULE_")) { + throw new Error("Serialized config should not have references to modules imported from webpack."); + } + return strConfig; +}; + +export const UNSAFE_deserializeConfig = (strConfig, ctx) => { + let config = eval("("+strConfig+")"); + config.ctx = ctx; + return config; +}; diff --git a/packages/sandbox_next/lib/config_update.ts b/packages/sandbox_next/lib/config_update.ts new file mode 100644 index 000000000..258f056a3 --- /dev/null +++ b/packages/sandbox_next/lib/config_update.ts @@ -0,0 +1,52 @@ +import type { Config } from "@react-awesome-query-builder/core"; +import merge from "lodash/merge"; + +function randomColor() { + const hex = Math.floor(Math.random() * 0xFFFFFF); + const color = "#" + hex.toString(16); + return color; +} + +function randomName() { + return Math.random().toString(36).slice(2, 7); +} + +export default (baseConfig: Config) => { + const newFieldName = "custom_" + randomName(); + return merge( + {}, + baseConfig, + { + // Update MUI colors + settings: { + theme: { + mui: { + palette: { + primary: { main: randomColor() }, + secondary: { main: randomColor() }, + }, + } + } + }, + // Add new field + fields: { + [newFieldName]: { + type: "date", + label: `${newFieldName.toUpperCase()}`, + }, + }, + // Reset boolean widget to basic one + types: { + boolean: { + widgets: { + boolean: { + widgetProps: { + factory: "VanillaBooleanWidget", + } + } + } + } + } + }, + ); +}; diff --git a/packages/sandbox_next/lib/withSession.ts b/packages/sandbox_next/lib/withSession.ts new file mode 100644 index 000000000..a29207c8e --- /dev/null +++ b/packages/sandbox_next/lib/withSession.ts @@ -0,0 +1,102 @@ +import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next"; +import { + GetServerSidePropsContext, + GetServerSidePropsResult, + NextApiHandler, +} from "next"; +import { IronSession, IronSessionOptions } from "iron-session"; +import { + JsonTree, ZipConfig +} from "@react-awesome-query-builder/core"; +import { nanoid } from "nanoid"; +import { IncomingMessage } from "http"; +import { Redis } from "@upstash/redis"; +import jsonfile from "jsonfile"; +import { existsSync } from "node:fs"; + +// API to manage session data + wrappers to enable session for routes and SSR + +export type SessionData = { + jsonTree?: JsonTree; + zipConfig?: ZipConfig; +}; +export type Session = IronSession & { + id: string; +}; + +// Wrappers + +export const sessionOptions: IronSessionOptions = { + cookieName: "raqb_sandbox", + password: "complex_password_at_least_32_characters_long", + // secure: true should be used in production (HTTPS) but can't be used in development (HTTP) + cookieOptions: { + secure: process.env.NODE_ENV === "production", + }, +}; + +export function withSessionRoute(handler: NextApiHandler) { + return withIronSessionApiRoute(handler, sessionOptions); +} + +export function withSessionSsr< + P extends { [key: string]: unknown } = { [key: string]: unknown }, +>( + handler: ( + context: GetServerSidePropsContext, + ) => GetServerSidePropsResult

| Promise>, +) { + return withIronSessionSsr(handler, sessionOptions); +} + +declare module "next" { + interface NextApiRequest { + session: Session; + } +} + + +// Manage session data storage - Redis or local tmp file + +const redis = process.env.UPSTASH_REDIS_REST_URL ? new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL, + token: process.env.UPSTASH_REDIS_REST_TOKEN, +}) : null; + +export const saveSessionData = async (req: IncomingMessage, newData: SessionData) => { + const session = (req.session as Session); + if (!session.id) { + session.id = nanoid(); + await session.save(); + } + const sid = session.id; + + if (redis) { + let data = await getSessionData(req); + if (!data) { + data = {}; + await redis.json.set(`sessions.${sid}`, "$", data); + } + if (newData.jsonTree) { + await redis.json.set(`sessions.${sid}`, "$.jsonTree", newData.jsonTree); + } + if (newData.zipConfig) { + await redis.json.set(`sessions.${sid}`, "$.zipConfig", newData.zipConfig); + } + } else { + const data = { + ...(await getSessionData(req)), + ...newData, + }; + jsonfile.writeFileSync(`/tmp/sessions_${sid}`, data); + } +}; + +export const getSessionData = async (req: IncomingMessage): Promise => { + const sid = (req.session as Session).id; + if (redis) { + return await redis.json.get(`sessions.${sid}`) as SessionData; + } else { + return existsSync(`/tmp/sessions_${sid}`) ? jsonfile.readFileSync(`/tmp/sessions_${sid}`) as SessionData : {}; + } +}; diff --git a/packages/sandbox_next/next-env.d.ts b/packages/sandbox_next/next-env.d.ts new file mode 100644 index 000000000..4f11a03dc --- /dev/null +++ b/packages/sandbox_next/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/sandbox_next/next.config.js b/packages/sandbox_next/next.config.js new file mode 100644 index 000000000..a351d6684 --- /dev/null +++ b/packages/sandbox_next/next.config.js @@ -0,0 +1,32 @@ +const babel_options = { + presets: [ + '@babel/preset-env', + '@babel/preset-react', + '@babel/preset-typescript', + ] +}; + +module.exports = { + experimental: { esmExternals: "loose", }, + webpack: (config) => { + ['.scss', '.tsx', '.jsx', '.ts', '.js'].map(ext => { + if (!config.resolve.extensions.includes(ext)) { + config.resolve.extensions.push(ext); + } + }); + config.module.rules.push( + { + test: /\.[jt]sx?$/, + exclude: /(node_modules)/, + use: [{ + loader: 'babel-loader', + options: { + ...babel_options, + cacheDirectory: true + } + }], + } + ); + return config; + } +}; diff --git a/packages/sandbox_next/package.json b/packages/sandbox_next/package.json new file mode 100644 index 000000000..a33c0736d --- /dev/null +++ b/packages/sandbox_next/package.json @@ -0,0 +1,71 @@ +{ + "name": "@react-awesome-query-builder/sandbox-next", + "version": "6.1.1", + "description": "Demo for @react-awesome-query-builder", + "repository": { + "type": "git", + "url": "https://github.com/ukrbublik/react-awesome-query-builder.git", + "directory": "packages/sandbox_next" + }, + "license": "MIT", + "author": "Denis Oblogin (https://github.com/ukrbublik)", + "main": "pages/index.tsx", + "scripts": { + "build": "tsc && next build", + "eslint": "eslint --ext .jsx --ext .js --ext .tsx --ext .ts ./pages/ ./components/ ./lib/", + "lint": "npm run eslint && npm run tsc && next lint", + "lint-fix": "eslint --ext .jsx --ext .js --ext .tsx --ext .ts --fix ./pages/ ./components/ ./lib/", + "tsc": "tsc -p . --noEmit", + "preinstall": "node ./scripts/patch-versions.js", + "start": "PORT=3002 next dev", + "preview": "PORT=3002 next start" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "dependencies": { + "@emotion/react": "^11.7.1", + "@emotion/styled": "^11.6.0", + "@mui/icons-material": "^5.11.16", + "@mui/material": "^5.12.3", + "@mui/system": "^5.12.3", + "@mui/x-date-pickers": "^5.0.20", + "@react-awesome-query-builder/core": "workspace:^", + "@react-awesome-query-builder/mui": "workspace:^", + "lodash": "^4.17.21", + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "devDependencies": { + "@babel/core": "^7.14.5", + "@babel/preset-env": "^7.14.5", + "@babel/preset-react": "^7.14.5", + "@babel/preset-typescript": "^7.14.5", + "@types/jsonfile": "^6.1.1", + "@types/lodash": "^4.14.170", + "@types/react": "^17.0.39", + "@types/react-dom": "^17.0.18", + "@upstash/redis": "^1.20.1", + "babel-loader": "^8.2.2", + "babel-plugin-import": "^1.13.3", + "next": "^12.3.2", + "@next/eslint-plugin-next": "^13.1.2", + "iron-session": "^6.3.1", + "nanoid": "^4.0.1", + "sass": "^1.35.0", + "serialize-javascript": "^6.0.1", + "typescript": "~4.9.4", + "webpack": "^5.61.0", + "jsonfile": "^6.1.0" + }, + "readme": "README.md" +} diff --git a/packages/sandbox_next/pages/_app.jsx b/packages/sandbox_next/pages/_app.jsx new file mode 100644 index 000000000..6d7e0ff9a --- /dev/null +++ b/packages/sandbox_next/pages/_app.jsx @@ -0,0 +1,6 @@ +import React from "react"; +import "@react-awesome-query-builder/mui/css/styles.scss"; + +export default function MyApp({ Component, pageProps }) { + return ; +} diff --git a/packages/sandbox_next/pages/api/autocomplete.ts b/packages/sandbox_next/pages/api/autocomplete.ts new file mode 100644 index 000000000..3947e3955 --- /dev/null +++ b/packages/sandbox_next/pages/api/autocomplete.ts @@ -0,0 +1,36 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { Utils, AsyncFetchListValuesResult, AsyncFetchListValuesFn } from "@react-awesome-query-builder/core"; +import demoListValues from "../../data/autocomplete"; + +// API to return portion of `demoListValues`, requested by `search` and `offset`, limited by `PAGE_SIZE` + +const PAGE_SIZE = 3; + +export type GetAutocompleteResult = AsyncFetchListValuesResult; +export type GetAutocompleteQuery = { + search?: string; + offset?: string; +}; + +const filter: AsyncFetchListValuesFn = Utils.Autocomplete.simulateAsyncFetch(demoListValues, PAGE_SIZE, 0); + +async function get(req: NextApiRequest, res: NextApiResponse) { + const query = req.query as GetAutocompleteQuery; + const search = query.search ? query.search : null; + let offset = parseInt(query.offset); + if (isNaN(offset)) { + offset = null; + } + const result: GetAutocompleteResult = await filter(search, offset); + return res.status(200).json(result); +} + +async function route(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") { + return get(req, res); + } else { + return res.status(400).end(); + } +} + +export default route; diff --git a/packages/sandbox_next/pages/api/config.ts b/packages/sandbox_next/pages/api/config.ts new file mode 100644 index 000000000..e520f0ce5 --- /dev/null +++ b/packages/sandbox_next/pages/api/config.ts @@ -0,0 +1,69 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { + Utils, CoreConfig, + //types: + ZipConfig, + Config +} from "@react-awesome-query-builder/core"; +import { withSessionRoute, getSessionData, saveSessionData } from "../../lib/withSession"; +import serverConfig from "../../lib/config"; + +// API to get/save `zipConfig` to session +// Initial config is created in `lib/config` and compressed with `Utils.compressConfig()` + +export type GetConfigQuery = { + initial?: string; +}; +export interface PostConfigBody { + zipConfig: ZipConfig; +} +export interface PostConfigResult { +} +export interface GetConfigResult { + zipConfig: ZipConfig; +} + +export async function decompressSavedConfig(req: NextApiRequest): Promise { + const zipConfig = await getSavedZipConfig(req); + const config = Utils.decompressConfig(zipConfig, serverConfig); + return config; +} + +export async function getSavedZipConfig(req: NextApiRequest): Promise { + return (await getSessionData(req))?.zipConfig || getInitialZipConfig(); +} + +export function getInitialZipConfig() { + return Utils.compressConfig(serverConfig, CoreConfig); +} + +async function saveZipConfig(req: NextApiRequest, zipConfig: ZipConfig) { + await saveSessionData(req, { zipConfig }); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const { zipConfig } = JSON.parse(req.body as string) as PostConfigBody; + await saveZipConfig(req, zipConfig); + const result: PostConfigResult = {}; + return res.status(200).json(result); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const zipConfig = (req.query as GetConfigQuery).initial ? getInitialZipConfig() : await getSavedZipConfig(req); + const result: GetConfigResult = { + zipConfig + }; + return res.status(200).json(result); +} + +async function route(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") { + return await post(req, res); + } else if (req.method === "GET") { + return await get(req, res); + } else { + return res.status(400).end(); + } +} + +export default withSessionRoute(route); diff --git a/packages/sandbox_next/pages/api/tree.ts b/packages/sandbox_next/pages/api/tree.ts new file mode 100644 index 000000000..fad754845 --- /dev/null +++ b/packages/sandbox_next/pages/api/tree.ts @@ -0,0 +1,123 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { + Utils, + //types: + ImmutableTree, Config, JsonTree, JsonLogicTree, JsonLogicResult +} from "@react-awesome-query-builder/core"; +import { withSessionRoute, getSessionData, saveSessionData } from "../../lib/withSession"; +import serverConfig from "../../lib/config"; +import loadedInitValue from "../../data/init_value"; +import loadedInitLogic from "../../data/init_logic"; +import { decompressSavedConfig } from "./config"; +const { + uuid, checkTree, loadFromJsonLogic, loadTree, + jsonLogicFormat, queryString, sqlFormat, mongodbFormat, getTree +} = Utils; + +// API to get/save `jsonTree` to session +// Initial tree is loaded from `data` dir (by default from `init_logic.js`, or from `init_value.js` if `fromLogic = false`) +// After saving `jsonTree` is exported to multiple formats on server side and returned in response + +interface ConvertResult { + jl?: JsonLogicResult; + qs?: string; + qsh?: string; + sql?: string; + mongo?: Object; +} +export type PostTreeQuery = { + saveTree?: string; +}; +export type PostTreeBody = { + jsonTree: JsonTree, +}; +export type PostTreeResult = ConvertResult; +export type GetTreeQuery = { + initial?: string; +}; +export type GetTreeResult = ConvertResult & { + jsonTree: JsonTree; +} + +function getEmptyTree(): JsonTree { + return {"id": uuid(), "type": "group"}; +} + +export function getInitialTree(fromLogic = true): JsonTree { + const config = serverConfig; + let tree: JsonTree; + if (fromLogic) { + const logicTree: JsonLogicTree = loadedInitLogic && Object.keys(loadedInitLogic).length > 0 ? loadedInitLogic : undefined; + const immTree = logicTree && loadFromJsonLogic(logicTree, config); + tree = immTree && getTree(immTree); + } else { + tree = loadedInitValue && Object.keys(loadedInitValue).length > 0 ? loadedInitValue as JsonTree : undefined; + } + if (!tree) { + tree = getEmptyTree(); + } + return tree; +} + +async function getSavedTree(req: NextApiRequest): Promise { + return (await getSessionData(req))?.jsonTree || getInitialTree(); +} + + +async function saveTree(req: NextApiRequest, jsonTree: JsonTree) { + await saveSessionData(req, { jsonTree }); +} + +function convertTree(immutableTree: ImmutableTree, config: Config): ConvertResult { + const jl = jsonLogicFormat(immutableTree, config); + const qs = queryString(immutableTree, config); + const qsh = queryString(immutableTree, config, true); + const sql = sqlFormat(immutableTree, config); + const mongo = mongodbFormat(immutableTree, config); + + const result = { + jl, + qs, + qsh, + sql, + mongo, + }; + return result; +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const { jsonTree } = JSON.parse(req.body as string) as PostTreeBody; + const doSaveTree = (req.query as PostTreeQuery).saveTree === "true"; + const immutableTree: ImmutableTree = loadTree(jsonTree); + const config = await decompressSavedConfig(req); + const convertResult = convertTree(immutableTree, config); + const result: PostTreeResult = convertResult; + if (doSaveTree) { + await saveTree(req, jsonTree); + } + return res.status(200).json(result); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const jsonTree: JsonTree = (req.query as GetTreeQuery).initial ? getInitialTree() : await getSavedTree(req); + const immutableTree: ImmutableTree = loadTree(jsonTree); + const config = await decompressSavedConfig(req); + const convertResult = convertTree(immutableTree, config); + const result: GetTreeResult = { + jsonTree, + ...convertResult + }; + return res.status(200).json(result); +} + +async function route(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") { + return await post(req, res); + } else if (req.method === "GET") { + return await get(req, res); + } else { + return res.status(400).end(); + } +} + +export default withSessionRoute(route); diff --git a/packages/sandbox_next/pages/index.tsx b/packages/sandbox_next/pages/index.tsx new file mode 100644 index 000000000..ea9cb0869 --- /dev/null +++ b/packages/sandbox_next/pages/index.tsx @@ -0,0 +1,22 @@ + +import { withSessionSsr, getSessionData } from "../lib/withSession"; +import Demo, { DemoQueryBuilderProps } from "../components/demo/index"; +import { getInitialTree } from "../pages/api/tree"; +import { getInitialZipConfig } from "../pages/api/config"; + +export default Demo; + +// Get current `jsonTree` and `zipConfig` from session +// If `jsonTree` is missing, will be loaded from `data` dir +// If `zipConfig` is missing, will be created in `lib/config` and compressed with `Utils.compressConfig()` +export const getServerSideProps = withSessionSsr( + async function getServerSideProps({ req }) { + const sessionData = await getSessionData(req); + return { + props: { + jsonTree: sessionData?.jsonTree || getInitialTree(), + zipConfig: sessionData?.zipConfig || getInitialZipConfig(), + } + }; + } +); diff --git a/packages/sandbox_next/sandbox.config.json b/packages/sandbox_next/sandbox.config.json new file mode 100644 index 000000000..c8ccf69fa --- /dev/null +++ b/packages/sandbox_next/sandbox.config.json @@ -0,0 +1,3 @@ +{ + "port": 3002 +} \ No newline at end of file diff --git a/packages/sandbox_next/scripts/patch-versions.js b/packages/sandbox_next/scripts/patch-versions.js new file mode 100644 index 000000000..db1d4531b --- /dev/null +++ b/packages/sandbox_next/scripts/patch-versions.js @@ -0,0 +1,26 @@ +const fs = require('fs'); +const path = require('path'); +const SCRIPTS = __dirname; +const PACKAGE = path.resolve(SCRIPTS, '../'); +const PACKAGES = path.resolve(PACKAGE, '../'); +const isInWorkspace = path.basename(PACKAGES) === 'packages'; +const isInSandbox = process.env['HOME'].indexOf('/sandbox') !== -1; + +if (isInSandbox || !isInWorkspace) { + const ESLINT_RC = path.resolve(PACKAGE, './.eslintrc.js'); + const PACKAGE_JSON = path.resolve(PACKAGE, './package.json'); + const pjson = require(PACKAGE_JSON); + for (const k in pjson['dependencies']) { + if (k.indexOf('@react-awesome-query-builder/') === 0) { + const curr = pjson['dependencies'][k]; + if (curr.indexOf('workspace:') !== -1) { + pjson['dependencies'][k] = pjson['version']; + } + } + } + const pjsonStr = JSON.stringify(pjson, null, 2); + fs.writeFileSync(PACKAGE_JSON, pjsonStr); + if (fs.existsSync(ESLINT_RC)) { + fs.unlinkSync(ESLINT_RC); + } +} diff --git a/packages/sandbox_next/tsconfig.json b/packages/sandbox_next/tsconfig.json new file mode 100644 index 000000000..045e5b301 --- /dev/null +++ b/packages/sandbox_next/tsconfig.json @@ -0,0 +1,48 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "esnext", + "jsx": "preserve", + "lib": [ + "es2015", + "dom", + "dom.iterable" + ], + "allowJs": true, + "outDir": "ts_out", + "declaration": true, + "strict": true, + "alwaysStrict": true, + "strictNullChecks": false, + "noImplicitReturns": true, + "esModuleInterop": true, + "moduleResolution": "node", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@react-awesome-query-builder/core": [ + "../core/modules" + ], + "@react-awesome-query-builder/ui": [ + "../ui/modules" + ], + "@react-awesome-query-builder/mui": [ + "../mui/modules" + ], + }, + "incremental": true + }, + "include": [ + "pages/**/*", + "components/**/*", + "lib/**/*" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/sandbox_simple/README.md b/packages/sandbox_simple/README.md index 70f222ec8..604c37f97 100644 --- a/packages/sandbox_simple/README.md +++ b/packages/sandbox_simple/README.md @@ -29,9 +29,9 @@ Then open `http://localhost:5174` in a browser. Feel free to play with code in `src/demo` dir. ### Run in sandbox -[![Open in codesandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/ukrbublik/react-awesome-query-builder/tree/master/packages/sandbox_simple?file=/src/demo/config_simple.js) +[![Open in codesandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/ukrbublik/react-awesome-query-builder/tree/master/packages/sandbox_simple?file=/src/demo/config.jsx) (if it freezes on "Initializing Sandbox Container" please click "Fork") -[![Open in stackblitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/ukrbublik/react-awesome-query-builder/tree/master?file=packages%2Fsandbox_simple%2Fsrc%2Fdemo%2Fconfig_simple.jsx&terminal=sandbox-js) +[![Open in stackblitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/ukrbublik/react-awesome-query-builder/tree/master?file=packages%2Fsandbox_simple%2Fsrc%2Fdemo%2Fconfig.jsx&terminal=sandbox-js) (installing dependencies can take a while) diff --git a/packages/sandbox_simple/scripts/patch-versions.js b/packages/sandbox_simple/scripts/patch-versions.js index d68623244..db2e189a0 100644 --- a/packages/sandbox_simple/scripts/patch-versions.js +++ b/packages/sandbox_simple/scripts/patch-versions.js @@ -11,7 +11,10 @@ if (isInSandbox || !isInWorkspace) { const pjson = require(PACKAGE_JSON); for (const k in pjson['dependencies']) { if (k.indexOf('@react-awesome-query-builder/') === 0) { - pjson['dependencies'][k] = pjson['version']; + const curr = pjson['dependencies'][k]; + if (curr.indexOf('workspace:') !== -1) { + pjson['dependencies'][k] = pjson['version']; + } } } const pjsonStr = JSON.stringify(pjson, null, 2); diff --git a/packages/sandbox_simple/src/demo/config_simple.jsx b/packages/sandbox_simple/src/demo/config.jsx similarity index 75% rename from packages/sandbox_simple/src/demo/config_simple.jsx rename to packages/sandbox_simple/src/demo/config.jsx index 86b4c42aa..c8de59b01 100644 --- a/packages/sandbox_simple/src/demo/config_simple.jsx +++ b/packages/sandbox_simple/src/demo/config.jsx @@ -15,7 +15,6 @@ const fields = { firstName: { label2: "Username", //only for menu's toggler type: "text", - excludeOperators: ["proximity"], mainWidgetProps: { valueLabel: "Name", valuePlaceholder: "Enter name", @@ -28,7 +27,6 @@ const fields = { }, login: { type: "text", - excludeOperators: ["proximity"], fieldSettings: { validateValue: (val, fieldSettings) => { return (val.length < 10 && (val === "" || val.match(/^[A-Za-z0-9_-]+$/) !== null)); @@ -131,48 +129,6 @@ const fields = { allowCustomValues: true }, }, - selecttree: { - label: "Color (tree)", - type: "treeselect", - fieldSettings: { - treeExpandAll: true, - treeValues: [ - { value: "1", title: "Warm colors", children: [ - { value: "2", title: "Red" }, - { value: "3", title: "Orange" } - ] }, - { value: "4", title: "Cool colors", children: [ - { value: "5", title: "Green" }, - { value: "6", title: "Blue", children: [ - { value: "7", title: "Sub blue", children: [ - { value: "8", title: "Sub sub blue and a long text" } - ] } - ] } - ] } - ], - } - }, - multiselecttree: { - label: "Colors (tree)", - type: "treemultiselect", - fieldSettings: { - treeExpandAll: true, - treeValues: [ - { value: "1", title: "Warm colors", children: [ - { value: "2", title: "Red" }, - { value: "3", title: "Orange" } - ] }, - { value: "4", title: "Cool colors", children: [ - { value: "5", title: "Green" }, - { value: "6", title: "Blue", children: [ - { value: "7", title: "Sub blue", children: [ - { value: "8", title: "Sub sub blue and a long text" } - ] } - ] } - ] } - ] - } - }, stock: { label: "In stock", type: "boolean", @@ -203,6 +159,7 @@ const operators = { ], }, }; +delete operators.proximity; const widgets = { ...InitialConfig.widgets, @@ -235,19 +192,13 @@ const widgets = { dateFormat: "DD.MM.YYYY", valueFormat: "YYYY-MM-DD HH:mm:ss", }, - treeselect: { - ...InitialConfig.widgets.treeselect, - customProps: { - showSearch: true - } - }, }; const types = { ...InitialConfig.types, // examples of overriding - boolean: merge(InitialConfig.types.boolean, { + boolean: merge({}, InitialConfig.types.boolean, { widgets: { boolean: { widgetProps: { @@ -301,10 +252,6 @@ const settings = { label: "Field", widget: "field", }, - func: { - label: "Function", - widget: "func", - } }, // canReorder: false, // canRegroup: false, @@ -312,17 +259,15 @@ const settings = { // showLabels: true, maxNesting: 3, canLeaveEmptyGroup: true, //after deletion - - // renderField: (props) => , - // renderOperator: (props) => , - // renderFunc: (props) => , + }; const funcs = {}; - +const ctx = InitialConfig.ctx; const config = { + ctx, conjunctions, operators, widgets, diff --git a/packages/sandbox_simple/src/demo/config_complex.jsx b/packages/sandbox_simple/src/demo/config_complex.jsx deleted file mode 100644 index 4f11dcbb9..000000000 --- a/packages/sandbox_simple/src/demo/config_complex.jsx +++ /dev/null @@ -1,446 +0,0 @@ -import React from "react"; -import merge from "lodash/merge"; -import { BasicConfig } from "@react-awesome-query-builder/ui"; -const InitialConfig = BasicConfig; - - - -////////////////////////////////////////////////////////////////////// - -const fields = { - user: { - label: "User", - tooltip: "Group of fields", - type: "!struct", - subfields: { - firstName: { - label2: "Username", //only for menu's toggler - type: "text", - excludeOperators: ["proximity"], - fieldSettings: { - validateValue: (val, fieldSettings) => { - return (val.length < 10); - }, - }, - mainWidgetProps: { - valueLabel: "Name", - valuePlaceholder: "Enter name", - }, - }, - login: { - type: "text", - tableName: "t1", // legacy: PR #18, PR #20 - excludeOperators: ["proximity"], - fieldSettings: { - validateValue: (val, fieldSettings) => { - return (val.length < 10 && (val === "" || val.match(/^[A-Za-z0-9_-]+$/) !== null)); - }, - }, - mainWidgetProps: { - valueLabel: "Login", - valuePlaceholder: "Enter login", - }, - } - } - }, - prox1: { - label: "prox", - tooltip: "Proximity search", - type: "text", - operators: ["proximity"], - }, - num: { - label: "Number", - type: "number", - preferWidgets: ["number"], - fieldSettings: { - min: -1, - max: 5 - }, - funcs: ["LINEAR_REGRESSION"], - }, - slider: { - label: "Slider", - type: "number", - preferWidgets: ["slider", "rangeslider"], - valueSources: ["value", "field"], - fieldSettings: { - min: 0, - max: 100, - step: 1, - marks: { - 0: 0%, - 100: 100% - }, - }, - //overrides - widgets: { - slider: { - widgetProps: { - valuePlaceholder: "..Slider", - } - } - }, - }, - date: { - label: "Date", - type: "date", - valueSources: ["value"], - }, - time: { - label: "Time", - type: "time", - valueSources: ["value"], - operators: ["greater_or_equal", "less_or_equal", "between"], - defaultOperator: "between", - }, - datetime: { - label: "DateTime", - type: "datetime", - valueSources: ["value"] - }, - datetime2: { - label: "DateTime2", - type: "datetime", - valueSources: ["field"] - }, - color: { - label: "Color", - type: "select", - valueSources: ["value"], - fieldSettings: { - // * old format: - // listValues: { - // yellow: 'Yellow', - // green: 'Green', - // orange: 'Orange' - // }, - // * new format: - listValues: [ - { value: "yellow", title: "Yellow" }, - { value: "green", title: "Green" }, - { value: "orange", title: "Orange" } - ], - } - }, - color2: { - label: "Color2", - type: "select", - fieldSettings: { - listValues: { - yellow: "Yellow", - green: "Green", - orange: "Orange", - purple: "Purple" - }, - } - }, - multicolor: { - label: "Colors", - type: "multiselect", - fieldSettings: { - listValues: { - yellow: "Yellow", - green: "Green", - orange: "Orange" - }, - allowCustomValues: true - } - }, - selecttree: { - label: "Color (tree)", - type: "treeselect", - fieldSettings: { - treeExpandAll: true, - // * deep format (will be auto converted to flat format): - // treeValues: [ - // { value: "1", title: "Warm colors", children: [ - // { value: "2", title: "Red" }, - // { value: "3", title: "Orange" } - // ] }, - // { value: "4", title: "Cool colors", children: [ - // { value: "5", title: "Green" }, - // { value: "6", title: "Blue", children: [ - // { value: "7", title: "Sub blue", children: [ - // { value: "8", title: "Sub sub blue and a long text" } - // ] } - // ] } - // ] } - // ], - // * flat format: - treeValues: [ - { value: "1", title: "Warm colors" }, - { value: "2", title: "Red", parent: "1" }, - { value: "3", title: "Orange", parent: "1" }, - { value: "4", title: "Cool colors" }, - { value: "5", title: "Green", parent: "4" }, - { value: "6", title: "Blue", parent: "4" }, - { value: "7", title: "Sub blue", parent: "6" }, - { value: "8", title: "Sub sub blue and a long text", parent: "7" }, - ], - } - }, - multiselecttree: { - label: "Colors (tree)", - type: "treemultiselect", - fieldSettings: { - treeExpandAll: true, - treeValues: [ - { value: "1", title: "Warm colors", children: [ - { value: "2", title: "Red" }, - { value: "3", title: "Orange" } - ] }, - { value: "4", title: "Cool colors", children: [ - { value: "5", title: "Green" }, - { value: "6", title: "Blue", children: [ - { value: "7", title: "Sub blue", children: [ - { value: "8", title: "Sub sub blue and a long text" } - ] } - ] } - ] } - ] - } - }, - stock: { - label: "In stock", - type: "boolean", - defaultValue: true, - mainWidgetProps: { - labelYes: "+", - labelNo: "-" - } - }, -}; - - -////////////////////////////////////////////////////////////////////// - -const conjunctions = { - AND: InitialConfig.conjunctions.AND, - OR: InitialConfig.conjunctions.OR, -}; - - -const proximity = { - ...InitialConfig.operators.proximity, - valueLabels: [ - { label: "Word 1", placeholder: "Enter first word" }, - { label: "Word 2", placeholder: "Enter second word" }, - ], - textSeparators: [ - //'Word 1', - //'Word 2' - ], - options: { - ...InitialConfig.operators.proximity.options, - optionLabel: "Near", // label on top of "near" selectbox (for config.settings.showLabels==true) - optionTextBefore: "Near", // label before "near" selectbox (for config.settings.showLabels==false) - optionPlaceholder: "Select words between", // placeholder for "near" selectbox - minProximity: 2, - maxProximity: 10, - defaults: { - proximity: 2 - }, - customProps: {} - } -}; - -const operators = { - ...InitialConfig.operators, - // examples of overriding - between: { - ...InitialConfig.operators.between, - valueLabels: [ - "Value from", - "Value to" - ], - textSeparators: [ - "from", - "to" - ], - }, - proximity, -}; - -const widgets = { - ...InitialConfig.widgets, - // examples of overriding - text: { - ...InitialConfig.widgets.text, - }, - slider: { - ...InitialConfig.widgets.slider, - customProps: { - width: "300px" - } - }, - rangeslider: { - ...InitialConfig.widgets.rangeslider, - customProps: { - width: "300px" - } - }, - date: { - ...InitialConfig.widgets.date, - dateFormat: "DD.MM.YYYY", - valueFormat: "YYYY-MM-DD", - }, - time: { - ...InitialConfig.widgets.time, - timeFormat: "HH:mm", - valueFormat: "HH:mm:ss", - }, - datetime: { - ...InitialConfig.widgets.datetime, - timeFormat: "HH:mm", - dateFormat: "DD.MM.YYYY", - valueFormat: "YYYY-MM-DD HH:mm:ss", - }, - func: { - ...InitialConfig.widgets.func, - customProps: { - showSearch: true - } - }, - treeselect: { - ...InitialConfig.widgets.treeselect, - customProps: { - showSearch: true - } - }, -}; - - -const types = { - ...InitialConfig.types, - // examples of overriding - boolean: merge(InitialConfig.types.boolean, { - widgets: { - boolean: { - widgetProps: { - hideOperator: true, - operatorInlineLabel: "is", - } - }, - }, - }), -}; - - -const localeSettings = { - locale: { - moment: "ru", - }, - valueLabel: "Value", - valuePlaceholder: "Value", - fieldLabel: "Field", - operatorLabel: "Operator", - fieldPlaceholder: "Select field", - operatorPlaceholder: "Select operator", - deleteLabel: null, - addGroupLabel: "Add group", - addRuleLabel: "Add rule", - addSubRuleLabel: "Add sub rule", - delGroupLabel: null, - notLabel: "Not", - valueSourcesPopupTitle: "Select value source", - removeRuleConfirmOptions: { - title: "Are you sure delete this rule?", - okText: "Yes", - okType: "danger", - }, - removeGroupConfirmOptions: { - title: "Are you sure delete this group?", - okText: "Yes", - okType: "danger", - }, -}; - -const settings = { - ...InitialConfig.settings, - ...localeSettings, - - valueSourcesInfo: { - value: { - label: "Value" - }, - field: { - label: "Field", - widget: "field", - }, - func: { - label: "Function", - widget: "func", - } - }, - // canReorder: false, - // canRegroup: false, - // showNot: false, - // showLabels: true, - maxNesting: 3, - canLeaveEmptyGroup: true, //after deletion - - // renderField: (props) => , - // renderOperator: (props) => , - // renderFunc: (props) => , -}; - - -const funcs = { - LOWER: { - label: "Lowercase", - mongoFunc: "$toLower", - jsonLogic: ({str}) => ({ "method": [ str, "toLowerCase" ] }), - returnType: "text", - args: { - str: { - label: "String", - type: "text", - valueSources: ["value", "field"], - }, - } - }, - LINEAR_REGRESSION: { - label: "Linear regression", - returnType: "number", - formatFunc: ({coef, bias, val}, _) => `(${coef} * ${val} + ${bias})`, - sqlFormatFunc: ({coef, bias, val}) => `(${coef} * ${val} + ${bias})`, - mongoFormatFunc: ({coef, bias, val}) => ({"$sum": [{"$multiply": [coef, val]}, bias]}), - jsonLogic: ({coef, bias, val}) => ({ "+": [ {"*": [coef, val]}, bias ] }), - renderBrackets: ["", ""], - renderSeps: [" * ", " + "], - args: { - coef: { - label: "Coef", - type: "number", - defaultValue: 1, - valueSources: ["value"], - }, - val: { - label: "Value", - type: "number", - valueSources: ["value"], - }, - bias: { - label: "Bias", - type: "number", - defaultValue: 0, - valueSources: ["value"], - } - } - }, -}; - - -const config = { - conjunctions, - operators, - widgets, - types, - settings, - fields, - funcs -}; - -export default config; - diff --git a/packages/sandbox_simple/src/demo/demo.jsx b/packages/sandbox_simple/src/demo/index.jsx similarity index 95% rename from packages/sandbox_simple/src/demo/demo.jsx rename to packages/sandbox_simple/src/demo/index.jsx index 5bd05496c..9e863aa4c 100644 --- a/packages/sandbox_simple/src/demo/demo.jsx +++ b/packages/sandbox_simple/src/demo/index.jsx @@ -3,8 +3,7 @@ import React, {Component} from "react"; import { Utils, Query, Builder } from "@react-awesome-query-builder/ui"; -import throttle from "lodash/throttle"; -import loadedConfig from "./config_simple"; // <- you can try './config_complex' for more complex examples +import loadedConfig from "./config"; import loadedInitValue from "./init_value"; import loadedInitLogic from "./init_logic"; const stringify = JSON.stringify; @@ -79,9 +78,9 @@ export default class DemoQueryBuilder extends Component { const {logic, data, errors} = jsonLogicFormat(immutableTree, config); }; - updateResult = throttle(() => { + updateResult = () => { this.setState({tree: this.immutableTree, config: this.config}); - }, 100); + }; renderResult = ({tree: immutableTree, config}) => { const {logic, data, errors} = jsonLogicFormat(immutableTree, config); diff --git a/packages/sandbox_simple/src/index.jsx b/packages/sandbox_simple/src/index.jsx index a29395769..6173b4559 100644 --- a/packages/sandbox_simple/src/index.jsx +++ b/packages/sandbox_simple/src/index.jsx @@ -1,6 +1,6 @@ import React from "react"; import { render } from "react-dom"; -import Demo from "./demo/demo"; +import Demo from "./demo"; import "@react-awesome-query-builder/ui/css/styles.scss"; diff --git a/packages/tests/karma.tests.js b/packages/tests/karma.tests.js index b3c9323d6..ff6e14c28 100644 --- a/packages/tests/karma.tests.js +++ b/packages/tests/karma.tests.js @@ -3,5 +3,5 @@ import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; Enzyme.configure({adapter: new Adapter()}); -const tests = require.context("./specs", true, /\.test\.[tj]s$/); +const tests = require.context("./specs", true, /\.test\.[tj]sx?$/); tests.keys().forEach(tests); diff --git a/packages/tests/package.json b/packages/tests/package.json index 22ba42702..58b70cf55 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -33,11 +33,10 @@ "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.57", "@material-ui/pickers": "^3.3.10", - "@mui/icons-material": "^5.10.3", - "@mui/lab": "^5.0.0-alpha.98", - "@mui/material": "^5.10.4", - "@mui/system": "^5.10.4", - "@mui/x-date-pickers": "^5.0.0", + "@mui/icons-material": "^5.11.16", + "@mui/material": "^5.12.3", + "@mui/system": "^5.12.3", + "@mui/x-date-pickers": "^5.0.20", "@popperjs/core": "^2.11.6", "@react-awesome-query-builder/antd": "workspace:^", "@react-awesome-query-builder/bootstrap": "workspace:^", @@ -48,29 +47,34 @@ "@react-awesome-query-builder/ui": "workspace:^", "antd": "^5.1.2", "bootstrap": "^5.1.3", + "lodash": "^4.17.21", "moment": "^2.29.4", "prop-types": "^15.7.2", "react": "^17.0.2", "react-dom": "^17.0.2", - "reactstrap": "^9.0.0" + "reactstrap": "^9.0.0", + "serialize-javascript": "^6.0.1" }, "devDependencies": { "@babel/core": "^7.14.5", "@babel/runtime": "^7.14.5", "@jsdevtools/coverage-istanbul-loader": "^3.0.5", "@types/chai": "^4.2.18", + "@types/deep-equal-in-any-order": "^1.0.1", "@types/enzyme": "^3.10.8", "@types/estree": "^1.0.0", + "@types/lodash": "^4.14.170", "@types/mocha": "^9.0.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.18", + "@types/serialize-javascript": "^5.0.2", "@types/sinon": "^10.0.2", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.3", "babel-loader": "^8.2.2", "babel-plugin-istanbul": "^6.0.0", "chai": "^4.3.2", - "codecov": "^3.8.2", "css-loader": "^6.2.0", + "deep-equal-in-any-order": "^2.0.6", "enzyme": "^3.11.0", "karma": "^6.3.4", "karma-babel-preprocessor": "^8.0.2", diff --git a/packages/tests/specs/Compress.test.ts b/packages/tests/specs/Compress.test.ts new file mode 100644 index 000000000..ec115e53e --- /dev/null +++ b/packages/tests/specs/Compress.test.ts @@ -0,0 +1,173 @@ +import { CoreConfig, Utils } from "@react-awesome-query-builder/core"; +import { + Config, BasicConfig, Fields, Field, + SelectField, AsyncFetchListValuesFn, SelectFieldSettings, NumberFieldSettings, +} from "@react-awesome-query-builder/ui"; +import { MuiConfig } from "@react-awesome-query-builder/mui"; +import { MaterialConfig } from "@react-awesome-query-builder/material"; +import { AntdConfig } from "@react-awesome-query-builder/antd"; +import { BootstrapConfig } from "@react-awesome-query-builder/bootstrap"; +import { FluentUIConfig } from "@react-awesome-query-builder/fluent"; +import * as configs from "../support/configs"; +import * as inits from "../support/inits"; +import { export_checks, with_qb } from "../support/utils"; +import { SliderMark, configMixin, makeCtx, zipInits, expectedZipConfig } from "../support/zipConfigs"; +import chai from "chai"; +import sinon from "sinon"; +import deepEqualInAnyOrder from "deep-equal-in-any-order"; +import merge from "lodash/merge"; +const { ConfigUtils } = Utils; +chai.use(deepEqualInAnyOrder); +const { expect } = chai; + +const BaseConfigs: Record = { + CoreConfig, + BasicConfig, + MuiConfig, + MaterialConfig, + AntdConfig, + BootstrapConfig, + FluentUIConfig, +}; + +describe("Compressed config", () => { + const makeConfig: (base: Config) => Config = configs.with_all_types; + + for (const configKey in BaseConfigs) { + const BaseConfig = BaseConfigs[configKey]; + const config: Config = merge({}, makeConfig(BaseConfig), { + settings: { + useConfigCompress: true + } + }); + + describe(configKey, () => { + it("should contain only diff", () => { + const zipConfig = ConfigUtils.compressConfig(config, BaseConfig); + expect((zipConfig as Config).ctx).to.be.undefined; + expect(JSON.stringify(zipConfig.fields)).to.equal(JSON.stringify(config.fields)); + expect(Object.keys(zipConfig.widgets).length).to.equal(0); + }); + + describe("should be decompressed and used without errors", () => { + const zipConfig = ConfigUtils.compressConfig(config, BaseConfig); + const decConfig = ConfigUtils.decompressConfig(zipConfig, BaseConfig); + export_checks(() => decConfig, inits.with_ops, "JsonLogic", {}, [], configKey !== "CoreConfig"); + }); + }); + + it("should throw if contains functions", () => { + const badConfig = merge({}, makeConfig(BaseConfig), { + fields: { + str: { + fieldSettings: { + validateValue: (val: string) => { + return (val.length < 10); + }, + }, + }, + }, + }); + expect(() => ConfigUtils.compressConfig(badConfig, BaseConfig)).to.throw(); + }); + } +}); + +describe("settings.useConfigCompress", () => { + let BaseConfig = BasicConfig; + + it("decompressConfig() should throw if useConfigCompress is not true", () => { + const config: Config = merge({}, BaseConfig); + const zipConfig = ConfigUtils.compressConfig(config, BaseConfig); + expect(() => ConfigUtils.decompressConfig(zipConfig, BaseConfig)).to.throw(); + }); + + it("extendConfig() should compile functions", async () => { + const config: Config = configMixin(BaseConfig); + const ctx = makeCtx(BaseConfig); + + // compress and decompress + const zipConfig = ConfigUtils.compressConfig(config, BaseConfig); + const decConfig = ConfigUtils.decompressConfig(zipConfig, BaseConfig, ctx); + export_checks(() => decConfig, inits.with_ops, "JsonLogic", {}); + + // extend manually + const extConfig = ConfigUtils.extendConfig(decConfig) as BasicConfig; + // check autocomplete + let asyncFetch: AsyncFetchListValuesFn; + asyncFetch = ((extConfig.fields.autocomplete as Field).fieldSettings as SelectFieldSettings).asyncFetch as AsyncFetchListValuesFn; + await asyncFetch("aa", 0); + expect(ctx.autocompleteFetch.callCount).to.equal(1); + asyncFetch = ((extConfig.fields.autocomplete2 as Field).fieldSettings as SelectFieldSettings).asyncFetch as AsyncFetchListValuesFn; + await asyncFetch("bb", 0); + expect(ctx.autocompleteFetch.callCount).to.equal(2); + asyncFetch = ((extConfig.fields.autocomplete3 as Field).fieldSettings as SelectFieldSettings).asyncFetch as AsyncFetchListValuesFn; + expect(() => asyncFetch("cc", 0)).to.throw(); + + // check funcs + expect(zipConfig.funcs, "zipConfig.funcs").to.deep.equalInAnyOrder(expectedZipConfig.funcs); + expect(zipConfig.operators, "zipConfig.operators").to.deep.equalInAnyOrder(expectedZipConfig.operators); + expect(zipConfig.types, "zipConfig.operators").to.deep.equalInAnyOrder(expectedZipConfig.types); + + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.sqlFormatFunc", null); + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.myFormat", null); + expect(decConfig.funcs).to.not.have.nested.property("numeric.subfields.LINEAR_REGRESSION.spelFormatFunc"); + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.returnType", "number"); + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.renderBrackets[0]", ""); + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.renderBrackets[1]", ""); + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.renderSeps[0]", "*"); + expect(decConfig.funcs).to.not.have.nested.property("numeric.subfields.LINEAR_REGRESSION.renderSeps[1]"); + expect(decConfig.funcs).to.not.have.nested.property("numeric.subfields.LINEAR_REGRESSION.args.bias"); + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.args.coef.type", "number"); + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.args.coef.newKey", "new_arg"); + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.args.coef.defaultValue", 10); + expect(decConfig.funcs).to.not.have.nested.property("numeric.subfields.LINEAR_REGRESSION.args.coef.label"); + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.args.newArg.type", "string"); + expect(decConfig.funcs).to.have.nested.property("numeric.subfields.LINEAR_REGRESSION.args.newArg.label", "New arg"); + expect(decConfig.funcs).to.not.have.nested.property("LOWER.spelFunc"); + expect(decConfig.funcs).to.not.have.nested.property("LOWER.label"); + expect(decConfig.funcs).to.have.nested.property("LOWER.myFormat", 123); + expect(decConfig.funcs).to.have.nested.property("LOWER.jsonLogicCustomOps", 1); + expect(decConfig.funcs).to.have.nested.property("LOWER.mongoFunc.lower", 12); + expect(decConfig.funcs).to.have.nested.property("LOWER.jsonLogic", "ToLowerCase"); + + // check operators + expect(decConfig.operators).to.have.deep.nested.property("between.jsonLogic", {aaa: 1}); + expect(decConfig.operators).to.have.nested.property("between.reversedOp", "not_between"); + expect(decConfig.operators).to.not.have.nested.property("between.labelForFormat"); + + // check types + expect(decConfig.types).to.have.nested.property("boolean.widgets.boolean.opProps", 111); + + // can't compress extended config + expect(() => ConfigUtils.compressConfig(extConfig, BaseConfig)).to.throw(); + }); + + it("extendConfig() should compile React components", async () => { + BaseConfig = MuiConfig; + const config: Config = configMixin(BaseConfig); + const ctx = makeCtx(BaseConfig); + + // compress and decompress + const zipConfig = ConfigUtils.compressConfig(config, BaseConfig); + const decConfig = ConfigUtils.decompressConfig(zipConfig, BaseConfig, ctx); + + // extend via render + await with_qb(() => decConfig, zipInits.withSlider, "JsonLogic", (qb, onChange, {expect_queries}) => { + // check slider marks + const rule0 = qb.find(".rule").at(0); + const slider0 = rule0.find(".rule--value .widget--widget .MuiSlider-root").at(0); + const marks0 = slider0.find(".MuiSlider-markLabel"); + const mark00 = marks0.filterWhere(m => m.prop("data-index") == 0).at(0); + const mark01 = marks0.filterWhere(m => m.prop("data-index") == 1).at(0); + expect(mark00.html()).to.contain("0%"); + expect(mark01.html()).to.contain("50%"); + + const rule1 = qb.find(".rule").at(1); + const slider1 = rule1.find(".rule--value .widget--widget .MuiSlider-root").at(0); + const mark10 = slider1.find(".MuiSlider-markLabel").at(0); + expect(mark10.html()).to.contain(" { + return ac + .find("Popup") // in portal + .find("OptionList") + .find("Item") + .getElements() + .map(el => el.key) + .join(";"); +}; describe("interactions on antd", () => { + describe("autocomplete", () => { + it("find B", async () => { + await with_qb_ant(configs.with_autocomplete, inits.with_autocomplete_a, "JsonLogic", async (qb, onChange, {expect_jlogic}) => { + let ac = qb.find("Select").filterWhere(s => s.props()?.placeholder == "Select value"); + expect(ac.prop("value")).to.eq("a"); + + ac.prop("onDropdownVisibleChange")(true); + qb.update(); + expect(stringifyOptions(qb)).to.eq("a"); + + ac.prop("onSearch")("b"); + await sleep(400); // should be > 50ms delay + qb.update(); + ac = qb.find("Select").filterWhere(s => s.props()?.placeholder == "Select value"); + expect(stringifyOptions(qb)).to.eq("a;b"); + }); + }); + }); + it("set not", async () => { await with_qb_ant(configs.simple_with_numbers_and_str, inits.with_number, "JsonLogic", (qb, onChange, {expect_jlogic}) => { qb diff --git a/packages/tests/specs/InteractionsMaterial.test.js b/packages/tests/specs/InteractionsMaterial.test.js new file mode 100644 index 000000000..c235c5baf --- /dev/null +++ b/packages/tests/specs/InteractionsMaterial.test.js @@ -0,0 +1,41 @@ +import * as configs from "../support/configs"; +import * as inits from "../support/inits"; +import { with_qb_material, sleep } from "../support/utils"; +import Autocomplete from "@material-ui/lab/Autocomplete"; +import { expect } from "chai"; + +const stringifyOptions = (options) => { + return options.map(({title, value}) => `${value}_${title}`).join(";"); +}; + +describe("interactions on Material-UI", () => { + + describe("autocomplete", () => { + it("find B", async () => { + await with_qb_material(configs.with_autocomplete, inits.with_autocomplete_a, "JsonLogic", async (qb, onChange, {expect_jlogic}) => { + let ac = qb.find(Autocomplete).filter({label: "Select value"}); + expect(stringifyOptions(ac.prop("options"))).to.eq("a_a"); + + ac.prop("onInputChange")(null, "b"); + await sleep(200); // should be > 50ms delay + qb.update(); + ac = qb.find(Autocomplete).filter({label: "Select value"}); + + expect(stringifyOptions(ac.prop("options"))).to.eq("a_a;b_B"); + }); + }); + }); + + it("should render labels with showLabels=true", async () => { + await with_qb_material([configs.with_different_groups, configs.with_settings_show_labels], inits.with_different_groups, "JsonLogic", (qb) => { + //todo + }); + }); + + it("should render admin mode with showLock=true", async () => { + await with_qb_material([configs.with_different_groups, configs.with_settings_show_lock], inits.with_different_groups, "JsonLogic", (qb) => { + //todo + }); + }); + +}); diff --git a/packages/tests/specs/InteractionsMui.test.js b/packages/tests/specs/InteractionsMui.test.js index 5c1a950c3..f70a05779 100644 --- a/packages/tests/specs/InteractionsMui.test.js +++ b/packages/tests/specs/InteractionsMui.test.js @@ -1,41 +1,55 @@ import * as configs from "../support/configs"; import * as inits from "../support/inits"; -import { with_qb_material, sleep } from "../support/utils"; -import Autocomplete from "@material-ui/lab/Autocomplete"; +import { with_qb_mui, sleep } from "../support/utils"; +import Slider from "@mui/material/Slider"; +import TextField from "@mui/material/TextField"; import { expect } from "chai"; -const stringifyOptions = (options) => { - return options.map(({title, value}) => `${value}_${title}`).join(";"); -}; describe("interactions on MUI", () => { + it("change range slider value", async () => { + await with_qb_mui(configs.with_all_types, inits.with_range_slider, "JsonLogic", (qb, onChange, {expect_jlogic, expect_checks}) => { + expect_checks({ + "query": "slider >= 18 && slider <= 42", + "queryHuman": "Slider BETWEEN 18 AND 42", + "sql": "slider BETWEEN 18 AND 42", + "mongo": { + "slider": { + "$gte": 18, + "$lte": 42 + } + }, + "logic": { + "and": [ + { "<=": [ 18, { "var": "slider" }, 42 ] } + ] + } + }); - describe("autocomplete", () => { - it("find B", async () => { - await with_qb_material(configs.with_autocomplete, inits.with_autocomplete_a, "JsonLogic", async (qb, onChange, {expect_jlogic}) => { - let ac = qb.find(Autocomplete).filter({label: "Select value"}); - expect(stringifyOptions(ac.prop("options"))).to.eq("a_a"); - - ac.prop("onInputChange")(null, "b"); - await sleep(200); // should be > 50ms delay - qb.update(); - ac = qb.find(Autocomplete).filter({label: "Select value"}); + qb.find(Slider).prop("onChange")(null, [19, 42]); + qb.update(); + expect_jlogic([null, + { "and": [{ "<=": [ 19, { "var": "slider" }, 42 ] }] } + ], 0); - expect(stringifyOptions(ac.prop("options"))).to.eq("a_a;b_B"); - }); - }); - }); + qb.find(TextField).filter({placeholder: "Enter number from"}).prop("onChange")({target: {value: 20}}); + qb.update(); + expect_jlogic([null, + { "and": [{ "<=": [ 20, { "var": "slider" }, 42 ] }] } + ], 1); - it("should render labels with showLabels=true", async () => { - await with_qb_material([configs.with_different_groups, configs.with_settings_show_labels], inits.with_different_groups, "JsonLogic", (qb) => { - //todo - }); - }); + qb.find(TextField).filter({placeholder: "Enter number to"}).prop("onChange")({target: {value: 40}}); + qb.update(); + expect_jlogic([null, + { "and": [{ "<=": [ 20, { "var": "slider" }, 40 ] }] } + ], 2); + + qb.find(TextField).filter({placeholder: "Enter number from"}).prop("onChange")({target: {value: null}}); + qb.update(); + qb.find(TextField).filter({placeholder: "Enter number to"}).prop("onChange")({target: {value: null}}); + qb.update(); + expect_jlogic([null, undefined], 3); - it("should render admin mode with showLock=true", async () => { - await with_qb_material([configs.with_different_groups, configs.with_settings_show_lock], inits.with_different_groups, "JsonLogic", (qb) => { - //todo }); }); - }); diff --git a/packages/tests/specs/JsonLogic.test.js b/packages/tests/specs/JsonLogic.test.js new file mode 100644 index 000000000..f7eceb148 --- /dev/null +++ b/packages/tests/specs/JsonLogic.test.js @@ -0,0 +1,44 @@ +import { Utils } from "@react-awesome-query-builder/core"; +const { applyJsonLogic } = Utils.ConfigUtils; + +describe("JsonLogic", () => { + describe("should add custom operations", () => { + it("JSX, fromEntries, mergeObjects", () => { + const jsxRes = applyJsonLogic({ + JSX: [ + "img", + {mergeObjects: [ + {fromEntries:[ [ ["src", "1.png"] ] ]}, + {fromEntries:[ [ ["width", 100] ] ]} + ]} + ] + }); + expect(jsxRes).to.satisfy(jsx => jsx.type === "img" && jsx.props.src === "1.png" && jsx.props.width === 100); + }); + + it("string operations", () => { + expect(applyJsonLogic({strlen: "abc"})).to.eq(3); + expect(applyJsonLogic({toLowerCase: "aBc"})).to.eq("abc"); + expect(applyJsonLogic({toUpperCase: "aBc"})).to.eq("ABC"); + expect(applyJsonLogic({regexTest: [ {var: "val"}, "^[A-Za-z0-9_-]+$" ]}, {val: "aa8"})).to.equal(true); + expect(applyJsonLogic({regexTest: [ {var: "val"}, "^[A-Za-z0-9_-]+$" ]}, {val: "bb#"})).to.equal(false); + }); + + it("date operations", () => { + expect(applyJsonLogic({now: []})).to.be.instanceOf(Date); + const addRes = applyJsonLogic({date_add: [ {now: []}, 2, "year" ]}); + expect(addRes.getUTCFullYear()).to.equal(new Date().getUTCFullYear() + 2); + }); + + it("CALL", () => { + expect(applyJsonLogic({ + CALL: [ {var: "mySum"}, null, {var: "a"}, {var: "b"} ] + }, { + mySum: (a, b) => a + b, + a: 4, + b: 7 + })).to.eq(11); + }); + + }); +}); diff --git a/packages/tests/specs/QueryWithConj.test.js b/packages/tests/specs/QueryWithConj.test.js index bbeb2a5d4..5188e9161 100644 --- a/packages/tests/specs/QueryWithConj.test.js +++ b/packages/tests/specs/QueryWithConj.test.js @@ -73,6 +73,15 @@ describe("query with conjunction", () => { }); }); + describe("can use reversed op", () => { + export_checks([configs.with_number_and_string, configs.without_less_format], inits.with_less, "JsonLogic", { + "query": "!(num >= 2)", + "queryHuman": "NOT (Number >= 2)", + "sql": "NOT(num >= 2)", + "spel": "!(num >= 2)", + }); + }); + describe("should handle OR with 2 rules with NOT", () => { export_checks(configs.with_number_and_string, inits.with_not_number_and_string, "JsonLogic", { "query": "NOT (num < 2 || login == \"ukrbublik\")", diff --git a/packages/tests/specs/QueryWithFunc.test.js b/packages/tests/specs/QueryWithFunc.test.js index 571c093bf..010aa1ebe 100644 --- a/packages/tests/specs/QueryWithFunc.test.js +++ b/packages/tests/specs/QueryWithFunc.test.js @@ -90,11 +90,12 @@ describe("query with func", () => { }); }); - describe("loads tree with func LINEAR_REGRESSION", () => { - export_checks(configs.with_funcs, inits.with_func_linear_regression, "default", { + describe("loads tree in tree format with func LINEAR_REGRESSION", () => { + export_checks(configs.with_funcs, inits.with_func_linear_regression_tree, "default", { "query": "num == (2 * 3 + 0)", "queryHuman": "Number = (2 * 3 + 0)", "sql": "num = (2 * 3 + 0)", + "spel": "num == (2 * 3 + 0)", "mongo": { "$expr": { "$eq": [ @@ -118,4 +119,58 @@ describe("query with func", () => { }); }); + describe("loads tree in JsonLogic format with func LINEAR_REGRESSION", () => { + export_checks(configs.with_funcs, inits.with_func_linear_regression, "JsonLogic", { + "query": "num == (2 * 3 + 0)", + "queryHuman": "Number = (2 * 3 + 0)", + "sql": "num = (2 * 3 + 0)", + "spel": "num == (2 * 3 + 0)", + "mongo": { + "$expr": { + "$eq": [ + "$num", + { "$sum": [ + { "$multiply": [ 2, 3 ] }, 0 + ] } + ] + } + }, + "logic": { + "and": [ + { + "==": [ + { "var": "num" }, + { "+": [ { "*": [ 2, 3 ] }, 0 ] } + ] + } + ] + } + }); + }); + + describe("loads tree with func RELATIVE_DATETIME", () => { + export_checks(configs.with_funcs, inits.with_func_relative_datetime, "JsonLogic", { + "query": "datetime == NOW + 2 day", + "queryHuman": "Datetime = NOW + 2 day", + "sql": "datetime = DATE_ADD(NOW(), INTERVAL 2 day)", + "spel": "datetime == RELATIVE_DATETIME(new java.util.Date()(), 'plus', 2, 'day')", + "logic": { + "and": [ + { + "==": [ + { "var": "datetime" }, + { + "date_add": [ + { "now": [] }, + 2, + "day" + ] + } + ] + } + ] + } + }); + }); + }); diff --git a/packages/tests/specs/QueryWithOperators.test.js b/packages/tests/specs/QueryWithOperators.test.js index 4c8268032..80013ec35 100644 --- a/packages/tests/specs/QueryWithOperators.test.js +++ b/packages/tests/specs/QueryWithOperators.test.js @@ -8,11 +8,12 @@ import { BasicConfig } from "@react-awesome-query-builder/ui"; describe("query with ops", () => { describe("export", () => { export_checks(configs.with_all_types, inits.with_ops, "JsonLogic", { - "spel": "(num != 2 && str.contains('abc') && !(str.contains('xyz')) && num >= 1 && num <= 2 && (num < 3 || num > 4) && num == null && {'yellow'}.?[true].contains(color) && !({'green'}.?[true].contains(color)) && !(multicolor.equals({'yellow'})))", - "query": "(num != 2 && str Contains \"abc\" && str Not Contains \"xyz\" && num >= 1 && num <= 2 && (num < 3 || num > 4) && !num && color IN (\"yellow\") && color NOT IN (\"green\") && multicolor != [\"yellow\"])", - "queryHuman": "(Number != 2 AND String Contains abc AND String Not Contains xyz AND Number BETWEEN 1 AND 2 AND Number NOT BETWEEN 3 AND 4 AND Number IS NULL AND Color IN (Yellow) AND Color NOT IN (Green) AND Colors != [Yellow])", - "sql": "(num <> 2 AND str LIKE '%abc%' AND str NOT LIKE '%xyz%' AND num BETWEEN 1 AND 2 AND num NOT BETWEEN 3 AND 4 AND num IS NULL AND color IN ('yellow') AND color NOT IN ('green') AND multicolor != 'yellow')", + "spel": "(text == 'Long\nText' && num != 2 && str.contains('abc') && !(str.contains('xyz')) && num >= 1 && num <= 2 && (num < 3 || num > 4) && num == null && {'yellow'}.?[true].contains(color) && !({'green'}.?[true].contains(color)) && !(multicolor.equals({'yellow'})))", + "query": "(text == \"Long\\nText\" && num != 2 && str Contains \"abc\" && str Not Contains \"xyz\" && num >= 1 && num <= 2 && (num < 3 || num > 4) && !num && color IN (\"yellow\") && color NOT IN (\"green\") && multicolor != [\"yellow\"])", + "queryHuman": "(Textarea = Long\nText AND Number != 2 AND String Contains abc AND String Not Contains xyz AND Number BETWEEN 1 AND 2 AND Number NOT BETWEEN 3 AND 4 AND Number IS NULL AND Color IN (Yellow) AND Color NOT IN (Green) AND Colors != [Yellow])", + "sql": "(text = 'Long\\nText' AND num <> 2 AND str LIKE '%abc%' AND str NOT LIKE '%xyz%' AND num BETWEEN 1 AND 2 AND num NOT BETWEEN 3 AND 4 AND num IS NULL AND color IN ('yellow') AND color NOT IN ('green') AND multicolor != 'yellow')", "mongo": { + "text": "Long\nText", "num": { "$ne": 2, "$gte": 1, @@ -40,6 +41,8 @@ describe("query with ops", () => { "logic": { "and": [ { + "==": [ { "var": "text" }, "Long\nText" ] + }, { "!=": [ { "var": "num" }, 2 ] }, { "in": [ "abc", { "var": "str" } ] @@ -75,6 +78,96 @@ describe("query with ops", () => { } } ] + }, + "elasticSearch": { + "bool": { + "must": [ + { + "term": { + "text": "Long\nText" + } + }, + { + "bool": { + "must_not": { + "term": { + "num": 2 + } + } + } + }, + { + "regexp": { + "str": { + "value": "abc" + } + } + }, + { + "bool": { + "must_not": { + "regexp": { + "str": { + "value": "xyz" + } + } + } + } + }, + { + "range": { + "num": { + "gte": "1", + "lte": "2" + } + } + }, + { + "bool": { + "must_not": { + "range": { + "num": { + "gte": "3", + "lte": "4" + } + } + } + } + }, + { + "bool": { + "must_not": { + "exists": { + "field": "num" + } + } + } + }, + { + "term": { + "color": "yellow" + } + }, + { + "bool": { + "must_not": { + "term": { + "color": "green" + } + } + } + }, + { + "bool": { + "must_not": { + "term": { + "multicolor": "yellow" + } + } + } + } + ] + } } }); }); diff --git a/packages/tests/specs/Serialize.test.ts b/packages/tests/specs/Serialize.test.ts new file mode 100644 index 000000000..41c530742 --- /dev/null +++ b/packages/tests/specs/Serialize.test.ts @@ -0,0 +1,48 @@ +import { CoreConfig } from "@react-awesome-query-builder/core"; +import { Config, BasicConfig } from "@react-awesome-query-builder/ui"; +import { MuiConfig } from "@react-awesome-query-builder/mui"; +import { MaterialConfig } from "@react-awesome-query-builder/material"; +import { AntdConfig } from "@react-awesome-query-builder/antd"; +import { BootstrapConfig } from "@react-awesome-query-builder/bootstrap"; +import { FluentUIConfig } from "@react-awesome-query-builder/fluent"; +import * as configs from "../support/configs"; +import * as inits from "../support/inits"; +import { UNSAFE_serializeConfig, UNSAFE_deserializeConfig, export_checks } from "../support/utils"; +import { expect } from "chai"; + +const BaseConfigs: Record = { + CoreConfig, + BasicConfig, + MuiConfig, + MaterialConfig, + AntdConfig, + BootstrapConfig, + FluentUIConfig, +}; + +describe("Serialized config", () => { + for (const configKey in BaseConfigs) { + const BaseConfig = BaseConfigs[configKey]; + const makeConfig: (base: Config) => Config = configs.with_all_types; + const config = makeConfig(BaseConfig); + + describe(configKey, () => { + it("should not contain refs to webpack", () => { + const strConfig = UNSAFE_serializeConfig(config); + expect(strConfig).to.not.contain("__WEBPACK_IMPORTED_MODULE_"); + }); + + it("should be deserialized correctly", () => { + const strConfig = UNSAFE_serializeConfig(config); + const deserConfig = UNSAFE_deserializeConfig(strConfig, BaseConfig.ctx); + expect(deserConfig).to.satisfy((c: Config) => !!c.ctx, "Should contain ctx"); + }); + + describe("should be deserialized and used without errors", () => { + const strConfig = UNSAFE_serializeConfig(config); + const deserConfig = UNSAFE_deserializeConfig(strConfig, BaseConfig.ctx); + export_checks(() => deserConfig, inits.with_ops, "JsonLogic", {}, [], configKey !== "CoreConfig"); + }); + }); + } +}); diff --git a/packages/tests/specs/SwitchCase.test.ts b/packages/tests/specs/SwitchCase.test.ts new file mode 100644 index 000000000..9c9d3b9c4 --- /dev/null +++ b/packages/tests/specs/SwitchCase.test.ts @@ -0,0 +1,14 @@ +import * as configs from "../support/configs"; +import * as inits from "../support/inits"; +import { with_qb, with_qb_ant, export_checks, export_checks_in_it } from "../support/utils"; + + +describe("query with switch-case", () => { + + describe("export", () => { + export_checks(configs.with_cases, inits.spel_with_cases, "SpEL", { + "spel": "(str == '222' ? 'is_string' : (num == 222 ? 'is_number' : 'unknown'))" + }); + }); + +}); diff --git a/packages/tests/specs/WidgetsMaterial.test.ts b/packages/tests/specs/WidgetsMaterial.test.ts index 1911cb902..63a5e481a 100644 --- a/packages/tests/specs/WidgetsMaterial.test.ts +++ b/packages/tests/specs/WidgetsMaterial.test.ts @@ -74,7 +74,7 @@ describe("material-ui widgets interactions", () => { const {onChange: onChangeDate} = qb .find("KeyboardDateInput") .props(); - // @ts-ignore + // @ts-ignore onChangeDate(moment("0001-01-01 10:30")); expect_jlogic([null, { "and": [{ "==": [ { "var": "time" }, 60*60*10+60*30 ] }] } diff --git a/packages/tests/specs/WidgetsMui.test.ts b/packages/tests/specs/WidgetsMui.test.ts new file mode 100644 index 000000000..c34652317 --- /dev/null +++ b/packages/tests/specs/WidgetsMui.test.ts @@ -0,0 +1,98 @@ +import moment from "moment"; +import { expect } from "chai"; +import * as configs from "../support/configs"; +import * as inits from "../support/inits"; +import { with_qb_mui, hexToRgbString } from "../support/utils"; + +describe("mui theming", () => { + it("applies secondary color", async () => { + await with_qb_mui(configs.with_theme_mui, inits.with_bool, "JsonLogic", (qb) => { + const boolSwitch = qb.find(".rule--value .MuiSwitch-thumb"); + // for some reason elements are duplicated for MUI + expect(boolSwitch, "boolSwitch").to.have.length(2); + const boolSwitchNode = boolSwitch.at(1).getDOMNode(); + const boolSwitchStyle = getComputedStyle(boolSwitchNode); + expect(boolSwitchStyle.getPropertyValue("color"), "boolSwitch color").to.eq(hexToRgbString("#5e00d7")); + }, { + attach: true + }); + }); +}); + +describe("mui widgets interactions", () => { + + it("change date", async () => { + await with_qb_mui(configs.with_date_and_time, inits.with_date_and_time, "JsonLogic", (qb, onChange, {expect_jlogic}) => { + // open date picker for '2020-05-18' + const openPickerBtn = qb.find(".rule--widget--DATE button.MuiIconButton-root"); + const dateInput = qb.find(".rule--widget--DATE input.MuiInput-input"); + expect(dateInput, "dateInput").to.have.length(1); + if (openPickerBtn.length) { + // desktop mode + openPickerBtn.simulate("click"); + } else { + // mobile mode + // should not happen, see `desktopModeMediaQuery` + dateInput.simulate("click"); + } + + // click on 3rd week, 2nd day of week (should be sunday, 10 day for default US locale) + const dayBtn = document.querySelector( + ".MuiCalendarPicker-root" + + " .MuiDayPicker-monthContainer" + + " .MuiDayPicker-weekContainer:nth-child(3)" + + " > .MuiPickersDay-root:nth-child(2)" + ); + expect(dayBtn, "dayBtn").to.exist; + expect(dayBtn?.innerText, "dayBtn").to.eq("11"); + dayBtn?.click(); + + // now input should be '2020-05-11' + const dateInputValue = dateInput.getDOMNode().getAttribute("value"); + expect(dateInputValue, "dateInputValue").to.eq("11.05.2020"); + + expect_jlogic([null, + { + "or": [{ + "==": [ { "var": "datetime" }, "2020-05-18T21:50:01.000Z" ] + }, { + "and": [{ + "==": [ { "var": "date" }, "2020-05-11T00:00:00.000Z" ] + }, { + "==": [ { "var": "time" }, 3000 ] + }] + }] + } + ]); + }); + }); + + it("change time value", async function() { + await with_qb_mui(configs.with_all_types, inits.with_time, "JsonLogic", (qb, onChange, {expect_jlogic}) => { + const timeInput = qb.find(".rule--widget--TIME input.MuiInput-input"); + expect(timeInput, "timeInput").to.have.length(1); + timeInput.simulate("click"); + const clockPicker = document.querySelector(".MuiClockPicker-root"); + if (clockPicker) { + // mobile mode + // should not happen, see `desktopModeMediaQuery` + if (window?.matchMedia?.("(pointer:none)")?.matches) { + throw new Error("Pointer media feature is neither coarse nor fine"); + } + this.skip(); + } else { + // desktop mode + qb + .find(".rule--widget--TIME .MuiInput-input") + .at(1) + .simulate("change", { target: { value: "10:30" } }); + } + + expect_jlogic([null, + { "and": [{ "==": [ { "var": "time" }, 60*60*10+60*30 ] }] } + ]); + }); + }); + + +}); diff --git a/packages/tests/support/configs.js b/packages/tests/support/configs.js index 024e6a25f..506d8c7ec 100644 --- a/packages/tests/support/configs.js +++ b/packages/tests/support/configs.js @@ -72,7 +72,28 @@ export const simple_with_numbers_and_str = (BasicConfig) => ({ }, }, }); - + +export const without_less_format = (BasicConfig) => ({ + ...BasicConfig, + operators: { + ...BasicConfig.operators, + less: { + ...BasicConfig.operators.less, + sqlOp: null, + spelOp: null, + spelOps: null, + formatOp: null, + }, + greater_or_equal: { + ...BasicConfig.operators.greater_or_equal, + formatOp: (field, op, values, _valueSrc, _valueType, opDef) => { + const fop = opDef.labelForFormat || op; + return `${field} ${fop} ${values}`; + }, + }, + } +}); + export const with_number_and_string = (BasicConfig) => ({ ...BasicConfig, fields: { @@ -138,7 +159,26 @@ export const with_theme_material = (BasicConfig) => ({ }, } }); - + +export const with_theme_mui = (BasicConfig) => ({ + ...with_all_types(BasicConfig), + settings: { + ...BasicConfig.settings, + theme: { + mui: { + palette: { + primary: { + main: "#5e00d7", + }, + secondary: { + main: "#edf2ff", + }, + }, + } + }, + } +}); + export const with_select = (BasicConfig) => ({ ...BasicConfig, fields: { @@ -521,6 +561,11 @@ export const with_all_types = (BasicConfig) => ({ label: "String", type: "text", }, + text: { + label: "Textarea", + type: "text", + preferWidgets: ["textarea"], + }, date: { label: "Date", type: "date", @@ -658,6 +703,10 @@ export const with_funcs = (BasicConfig) => ({ label: "Number", type: "number", }, + datetime: { + label: "Datetime", + type: "datetime", + }, str: { label: "String", type: "text", @@ -1020,3 +1069,27 @@ export const with_groupVarKey = (BasicConfig) => ({ } } }); + +export const with_cases = (BasicConfig) => ({ + ...BasicConfig, + fields: { + num: { + label: "Number", + type: "number", + }, + datetime: { + label: "Datetime", + type: "datetime", + }, + str: { + label: "String", + type: "text", + }, + }, + settings: { + ...BasicConfig.settings, + maxNumberOfCases: 3, + canRegroupCases: true, + canLeaveEmptyCase: false, + } +}); diff --git a/packages/tests/support/inits.js b/packages/tests/support/inits.js index 5309e1ddc..fdc5e1310 100644 --- a/packages/tests/support/inits.js +++ b/packages/tests/support/inits.js @@ -89,7 +89,7 @@ export const with_number_not_in_group = { { "var": "num" }, 2 ] }; - + export const with_number_and_string = { "or": [{ "<": [ @@ -101,7 +101,7 @@ export const with_number_and_string = { ] }] }; - + export const with_not_number_and_string = { "!": { "or": [{ @@ -113,7 +113,11 @@ export const with_not_number_and_string = { }] } }; - + +export const with_less = { + "<": [ { "var": "num" }, 2 ] +}; + export const with_date_and_time = { "or": [{ "==": [ { "var": "datetime" }, "2020-05-18T21:50:01.000Z" ] @@ -510,6 +514,8 @@ export const with_treeselect = { export const with_ops = { "and": [ { + "==": [ { "var": "text" }, "Long\nText" ] + }, { "!=": [ { "var": "num" }, 2 ] }, { "in": [ "abc", { "var": "str" } ] @@ -648,7 +654,7 @@ export const with_func_tolower_from_field = { ] }; -export const with_func_linear_regression = { +export const with_func_linear_regression_tree = { type: "group", id: uuid(), children1: { @@ -679,6 +685,27 @@ export const with_func_linear_regression = { } }; + +export const with_func_linear_regression = { + "and": [ + { + "==": [ + { "var": "num" }, + { "+": [ { "*": [ 2, 3 ] }, 0 ] } + ] + } + ] +}; + +export const with_func_relative_datetime = { + "and": [ { + "==": [ + { "var": "datetime" }, + { "date_add": [ { "now": [] }, 2, "day" ] } + ] + } ] +}; + export const with_prox = { type: "group", id: uuid(), @@ -942,3 +969,5 @@ export const spel_with_number = "num == 2"; export const spel_with_not = "!(num == 2)"; export const spel_with_not_not = "!(num == 2 || !(num == 3))"; + +export const spel_with_cases = "(str == '222' ? is_string : (num == 222 ? is_number : unknown))"; diff --git a/packages/tests/support/utils.tsx b/packages/tests/support/utils.tsx index 410d2669f..254a33c40 100644 --- a/packages/tests/support/utils.tsx +++ b/packages/tests/support/utils.tsx @@ -4,13 +4,14 @@ import { act } from "react-dom/test-utils"; import sinon, {spy} from "sinon"; import { expect } from "chai"; const stringify = JSON.stringify; +import serializeJs from "serialize-javascript"; +import mergeWith from "lodash/mergeWith"; +import omit from "lodash/omit"; import { Utils, - JsonLogicTree, JsonTree, Config, ImmutableTree -} from "@react-awesome-query-builder/core"; -import { - Query, Builder, BasicConfig, + JsonLogicTree, JsonTree, ImmutableTree, ConfigContext, + Query, Builder, BasicConfig, Config, BuilderProps } from "@react-awesome-query-builder/ui"; const { @@ -32,7 +33,7 @@ type ConsoleData = { type TreeValueFormat = "JsonLogic" | "default" | "SpEL" | null; type TreeValue = JsonLogicTree | JsonTree | string | undefined; type ConfigFn = (_: Config) => Config; -type ConfigFns = ConfigFn | [ConfigFn]; +type ConfigFns = ConfigFn | ConfigFn[]; type ChecksFn = (qb: ReactWrapper, onChange: sinon.SinonSpy, tasks: Tasks, consoleData: ConsoleData) => Promise | void; interface ExtectedExports { query?: string; @@ -112,7 +113,7 @@ export const with_qb_skins = async (config_fn: ConfigFns, value: TreeValue, val }; const do_with_qb = async (BasicConfig: Config, config_fn: ConfigFns, value: TreeValue, valueFormat: TreeValueFormat, checks: ChecksFn, options?: DoOptions) => { - const config_fns = (Array.isArray(config_fn) ? config_fn : [config_fn]) as [ConfigFn]; + const config_fns = (Array.isArray(config_fn) ? config_fn : [config_fn]) as ConfigFn[]; const config = config_fns.reduce((c, f) => f(c), BasicConfig); // normally config should be saved at state in `onChange`, see README const extendedConfig = ConfigUtils.extendConfig(config); @@ -299,7 +300,6 @@ const do_export_checks = (config: Config, tree: ImmutableTree, expects: Extected onChange={emptyOnChange} /> ); - qb.unmount(); }); } @@ -312,13 +312,16 @@ const do_export_checks = (config: Config, tree: ImmutableTree, expects: Extected spel: spelFormat(tree, config), mongo: mongodbFormat(tree, config), logic: logic, + elasticSearch: elasticSearchFormat(tree, config), }; console.log(stringify(correct, undefined, 2)); } }; -export const export_checks = (config_fn: ConfigFn, value: TreeValue, valueFormat: TreeValueFormat, expects: ExtectedExports, expectedErrors: Array = []) => { - const config = config_fn(BasicConfig); +export const export_checks = (config_fn: ConfigFns, value: TreeValue, valueFormat: TreeValueFormat, expects: ExtectedExports, expectedErrors: Array = [], with_render = true) => { + const config_fns = (Array.isArray(config_fn) ? config_fn : [config_fn]) as ConfigFn[]; + const config = config_fns.reduce((c, f) => f(c), BasicConfig as Config); + let tree, errors: string[] = []; try { ({tree, errors} = load_tree(value, config, valueFormat)); @@ -334,14 +337,14 @@ export const export_checks = (config_fn: ConfigFn, value: TreeValue, valueFormat expect(errors.join("; ")).to.equal(expectedErrors.join("; ")); }); - do_export_checks(config, tree as ImmutableTree, expects, true); + do_export_checks(config, tree as ImmutableTree, expects, with_render); } else { it("should load tree without errors", () => { throw new Error(errors.join("; ")); }); } } else { - do_export_checks(config, tree as ImmutableTree, expects, true); + do_export_checks(config, tree as ImmutableTree, expects, with_render); } }; @@ -403,3 +406,28 @@ export function sleep(delay: number) { setTimeout(resolve, delay); }); } + +const mergeCustomizerCleanJSX = (_objValue: any, srcValue: any) => { + const { isDirtyJSX, cleanJSX } = Utils.ConfigUtils; + if (isDirtyJSX(srcValue)) { + return cleanJSX(srcValue); + } + return undefined; +}; + +export const UNSAFE_serializeConfig = (config: Config): string => { + const sanitizedConfig = mergeWith({}, omit(config, ["ctx"]), mergeCustomizerCleanJSX) as Config; + const strConfig = serializeJs(sanitizedConfig, { + space: 2, + unsafe: true, + }); + //remove coverage instructions + const sanitizedStrConfig = strConfig.replace(/cov_\w+\(\)\.\w+(\[\d+\])+\+\+(;|,)/gm, ""); + return sanitizedStrConfig; +}; + +export const UNSAFE_deserializeConfig = (strConfig: string, ctx: ConfigContext): Config => { + const config = eval("("+strConfig+")") as Config; + config.ctx = ctx; + return config; +}; \ No newline at end of file diff --git a/packages/tests/support/zipConfigs.tsx b/packages/tests/support/zipConfigs.tsx new file mode 100644 index 000000000..9fcc469a7 --- /dev/null +++ b/packages/tests/support/zipConfigs.tsx @@ -0,0 +1,309 @@ +import React from "react"; +import { + Config, Fields, Funcs, BasicFuncs, Func, Types, Type, Operator, Operators, Settings, + SelectField, AsyncFetchListValuesFn, SelectFieldSettings, NumberFieldSettings, + FieldProps, ConfigContext, VanillaWidgets, +} from "@react-awesome-query-builder/ui"; +import sinon from "sinon"; +import omit from "lodash/omit"; +import merge from "lodash/merge"; +const { VanillaFieldSelect } = VanillaWidgets; + +export const SliderMark: React.FC<{ pct: number }> = ({ pct }) => { + return {pct}%; +}; +const SliderMark_NotExists: React.FC<{ pct: number }> = () => null; +const MyLabel: React.FC = () => null; + +const fields: Fields = { + num: { + label: "Number", + type: "number", + preferWidgets: ["number"], + fieldSettings: { + min: 0, + max: 10, + }, + }, + str: { + label: "String", + type: "text", + }, + color: { + label: "Color", + type: "select", + fieldSettings: { + listValues: [ + { value: "yellow", title: "Yellow" }, + { value: "green", title: "Green" }, + { value: "orange", title: "Orange" }, + ], + } + }, + autocomplete: { + type: "select", + fieldSettings: { + useAsyncSearch: true, + useLoadMore: true, + forceAsyncSearch: false, + allowCustomValues: false, + asyncFetch: "autocompleteFetch", + } as SelectFieldSettings, + }, + autocomplete2: { + type: "select", + fieldSettings: { + useAsyncSearch: true, + asyncFetch: { CALL: [ {var: "ctx.autocompleteFetch"}, null, {var: "search"}, {var: "offset"} ] }, + } as SelectFieldSettings, + }, + autocomplete3: { + type: "select", + fieldSettings: { + useAsyncSearch: true, + asyncFetch: "autocompleteFetch__does_not_exist", + } as SelectFieldSettings, + }, + slider: { + type: "number", + preferWidgets: ["slider", "rangeslider"], + fieldSettings: { + min: 0, + max: 100, + step: 1, + marks: { + 0: , + 50: {50}%, + 100: , + }, + } as NumberFieldSettings, + }, + slider2: { + type: "number", + preferWidgets: ["slider", "rangeslider"], + fieldSettings: { + min: 0, + max: 100, + step: 1, + marks: { + 0: , + 50: {50}%, + }, + } as NumberFieldSettings, + }, + in_stock: { + type: "boolean", + mainWidgetProps: { + labelYes: Yes, + labelNo: "No", + } + }, +}; + +const operators: Record> = { + between: { + // not changed + valueLabels: [ + "Value from", + "Value to" + ], + // modify + textSeparators: [ + from, + to, + ], + // modify, change type from primitive to object + jsonLogic: { aaa: 1 }, + // delete + labelForFormat: undefined, + }, +}; + +const types: Record> = { + boolean: { + widgets: { + boolean: { + widgetProps: { + // add + hideOperator: true, + operatorInlineLabel: "is", + valueLabels: [] + }, + // modify, change type from object to primitive + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + opProps: 111 as any + }, + }, + } +}; + +// tip: LINEAR_REGRESSION and LOWER are heavily modified for tests, don't use in query value +const funcs: Funcs = { + numeric: { + type: "!struct", + label: "Numeric", + subfields: { + LINEAR_REGRESSION: omit({ + ...BasicFuncs.LINEAR_REGRESSION, + sqlFormatFunc: null, // modify + myFormat: null, // add + renderSeps: ["*"], // modify + args: omit({ + ...(BasicFuncs.LINEAR_REGRESSION as Func).args, + // modify + coef: omit({ + ...(BasicFuncs.LINEAR_REGRESSION as Func).args.coef, + newKey: "new_arg", // add + defaultValue: 10, // override + // omit label + }, ["label"]), + // add + newArg: { + type: "string", + label: "New arg" + }, + // omit bias + }, "bias"), + // omit spel* + }, ["spelFormatFunc", "spelFunc"]) as Func, + } + }, + LOWER: omit({ + ...BasicFuncs.LOWER, + label: undefined, // modify, delete + mongoFunc: { lower: 12 }, // modify, change type from primitive to obj + myFormat: 123, // add + jsonLogicCustomOps: 1, // modify, change type from obj to primitive + jsonLogic: "ToLowerCase", // modify + // omit spel* + }, ["spelFormatFunc", "spelFunc"]) as Func, +}; + +const settings: Partial = { + renderField: "myRenderField", + renderButton: "button", // missing in ctx, so will try to render