diff --git a/package-lock.json b/package-lock.json index 942640b2..70565991 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,9 +24,13 @@ "@rjsf/mui": "^5.13.0", "@rjsf/utils": "^5.13.0", "@rjsf/validator-ajv8": "^5.13.4", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0", "analytics-client": "^2.0.1", "color": "^4.2.3", "color-hash": "^2.0.2", + "date-fns": "^4.1.0", "lodash": "^4.17.21", "notistack": "^3.0.1", "react": "^18.2.0", @@ -2938,6 +2942,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2971,6 +2992,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz", @@ -4174,10 +4202,22 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@rjsf/validator-ajv8/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "node_modules/@rjsf/validator-ajv8/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } }, "node_modules/@sinclair/typebox": { "version": "0.27.8", @@ -7315,14 +7355,14 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", "uri-js": "^4.2.2" }, "funding": { @@ -7331,9 +7371,10 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", "dependencies": { "ajv": "^8.0.0" }, @@ -7361,18 +7402,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, "peerDependencies": { - "ajv": "^6.9.1" + "ajv": "^8.8.2" } }, "node_modules/analytics-client": { @@ -9132,6 +9171,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -10408,6 +10457,23 @@ "dev": true, "license": "MIT" }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -10461,6 +10527,13 @@ "node": ">=8" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10770,7 +10843,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -13115,10 +13189,10 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -17542,6 +17616,40 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -19565,24 +19673,24 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "node_modules/webpack-dev-middleware/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "ajv": "^8.0.0" }, "peerDependencies": { - "ajv": "^8.8.2" + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/webpack-dev-middleware/node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", diff --git a/package.json b/package.json index 70fcc571..679fa70a 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,13 @@ "@rjsf/mui": "^5.13.0", "@rjsf/utils": "^5.13.0", "@rjsf/validator-ajv8": "^5.13.4", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0", "analytics-client": "^2.0.1", "color": "^4.2.3", "color-hash": "^2.0.2", + "date-fns": "^4.1.0", "lodash": "^4.17.21", "notistack": "^3.0.1", "react": "^18.2.0", diff --git a/src/components/RJST/Actions/ActionContent.tsx b/src/components/RJST/Actions/ActionContent.tsx new file mode 100644 index 00000000..5f9fb9a7 --- /dev/null +++ b/src/components/RJST/Actions/ActionContent.tsx @@ -0,0 +1,39 @@ +import type { CheckedState } from '../components/Table/utils'; +import type { RJSTAction } from '../schemaOps'; +import { useQuery } from 'react-query'; + +export const LOADING_DISABLED_REASON = 'Loading'; + +interface ActionContentProps { + action: RJSTAction; + affectedEntries: T[] | undefined; + checkedState?: CheckedState; + getDisabledReason: RJSTAction['isDisabled']; + onDisabledReady: (arg: string | null) => void; +} + +// This component sole purpose is to have the useQuery being called exactly once per item, +// so that it satisfies React hooks assumption that the number of hook calls inside each component +// stays the same across renders. +export const ActionContent = ({ + action, + children, + affectedEntries, + checkedState, + getDisabledReason, + onDisabledReady, +}: React.PropsWithChildren>) => { + useQuery({ + queryKey: ['actionContent', action.title, affectedEntries, checkedState], + queryFn: async () => { + const disabled = + (await getDisabledReason?.({ + affectedEntries, + checkedState, + })) ?? null; + onDisabledReady(disabled); + return disabled; + }, + }); + return children; +}; diff --git a/src/components/RJST/Actions/Create.tsx b/src/components/RJST/Actions/Create.tsx new file mode 100644 index 00000000..5c07b0f8 --- /dev/null +++ b/src/components/RJST/Actions/Create.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import type { + ActionData, + RJSTContext, + RJSTModel, + RJSTBaseResource, +} from '../schemaOps'; +import { rjstJsonSchemaPick } from '../schemaOps'; +import { getCreateDisabledReason } from '../utils'; +import { ActionContent, LOADING_DISABLED_REASON } from './ActionContent'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faMagic } from '@fortawesome/free-solid-svg-icons/faMagic'; +import { Box, Button } from '@mui/material'; +import { Spinner } from '../../Spinner'; +import { useTranslation } from '../../../hooks/useTranslations'; +import { Tooltip } from '../../Tooltip'; + +interface CreateProps> { + model: RJSTModel; + rjstContext: RJSTContext; + hasOngoingAction: boolean; + onActionTriggered: (data: ActionData) => void; +} + +export const Create = >({ + model, + rjstContext, + hasOngoingAction, + onActionTriggered, +}: CreateProps) => { + const { t } = useTranslation(); + const { actions } = rjstContext; + const createActions = actions?.filter((a) => a.type === 'create'); + const [disabledReasonsByAction, setDisabledReasonsByAction] = + React.useState>(); + const [isInitialized, setIsInitialized] = React.useState(false); + + React.useEffect(() => { + if (!isInitialized && createActions) { + setDisabledReasonsByAction( + Object.fromEntries( + createActions.map((a) => [a.title, LOADING_DISABLED_REASON]), + ), + ); + setIsInitialized(true); + } + }, [createActions, isInitialized]); + + if (!createActions || createActions.length < 1 || !disabledReasonsByAction) { + return null; + } + + if (createActions.length > 1) { + throw new Error('Only one create action per resource is allowed'); + } + + const [action] = createActions; + + const disabledReason = + getCreateDisabledReason(model.permissions, hasOngoingAction, t) ?? + disabledReasonsByAction[action.title]; + console.log(disabledReason); + return ( + + + + + + ); +}; diff --git a/src/components/RJST/Actions/Tags.tsx b/src/components/RJST/Actions/Tags.tsx new file mode 100644 index 00000000..b069e3c8 --- /dev/null +++ b/src/components/RJST/Actions/Tags.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import type { RJSTContext, RJSTBaseResource } from '../schemaOps'; +import { parseDescriptionProperty } from '../schemaOps'; +import get from 'lodash/get'; +import { useTranslation } from '../../../hooks/useTranslations'; +import type { + ResourceTagSubmitInfo, + SubmitInfo, +} from '../../TagManagementDialog/models'; +import { closeSnackbar, enqueueSnackbar } from 'notistack'; +import { useQuery } from 'react-query'; +import { Spinner } from '../../Spinner'; +import { TagManagementDialog } from '../../TagManagementDialog'; + +interface TagsProps { + selected: T[] | undefined; + rjstContext: RJSTContext; + schema: JSONSchema; + setIsBusyMessage: (message: string | undefined) => void; + onDone: () => void; + refresh?: () => void; +} + +export const Tags = >({ + selected, + rjstContext, + schema, + setIsBusyMessage, + refresh, + onDone, +}: TagsProps) => { + const { t } = useTranslation(); + + const { sdk, internalPineFilter, checkedState } = rjstContext; + + const getAllTags = sdk?.tags && 'getAll' in sdk.tags ? sdk.tags.getAll : null; + + // This will get nested property names based on the x-ref-scheme property. + const getItemName = (item: T) => { + const property = schema.properties?.[ + rjstContext.nameField as keyof typeof schema.properties + ] as JSONSchema; + const refScheme = parseDescriptionProperty(property, 'x-ref-scheme'); + + if (refScheme != null && typeof refScheme === 'object') { + const field = refScheme[0]; + const nameFieldItem = item[rjstContext.nameField as keyof T]; + return get( + property.type === 'array' + ? (nameFieldItem as Array)?.[0] + : nameFieldItem, + field, + ); + } + + return item[rjstContext.nameField as keyof T]; + }; + + const { data: items, isLoading } = useQuery({ + queryKey: [ + 'tableTags', + internalPineFilter, + checkedState, + getAllTags, + selected == null, + ], + queryFn: async () => { + if ( + // we are in server side pagination + selected == null && + checkedState === 'all' && + getAllTags + ) { + return (await getAllTags(internalPineFilter)) ?? null; + } + return selected ?? null; + }, + }); + + const changeTags = React.useCallback( + async (tags: SubmitInfo) => { + if (!sdk?.tags) { + return; + } + + setIsBusyMessage(t(`loading.updating_tags`)); + enqueueSnackbar({ + key: 'change-tags-loading', + message: t(`loading.updating_tags`), + preventDuplicate: true, + }); + + try { + await sdk.tags.submit(tags); + enqueueSnackbar({ + key: 'change-tags', + message: t('success.tags_updated_successfully'), + variant: 'success', + preventDuplicate: true, + }); + refresh?.(); + } catch (err: any) { + enqueueSnackbar({ + key: 'change-tags', + message: err.message, + variant: 'error', + preventDuplicate: true, + }); + } finally { + closeSnackbar('change-tags-loading'); + setIsBusyMessage(undefined); + } + }, + [sdk?.tags, refresh, setIsBusyMessage, t], + ); + + if (!rjstContext.tagField || !rjstContext.nameField || !items) { + return null; + } + + return ( + + + items={items} + itemType={rjstContext.resource} + titleField={getItemName ?? (rjstContext.nameField as keyof T)} + tagField={rjstContext.tagField as keyof T} + done={async (tagSubmitInfo) => { + await changeTags(tagSubmitInfo); + onDone(); + }} + cancel={() => { + onDone(); + }} + /> + + ); +}; diff --git a/src/components/RJST/Actions/Update.tsx b/src/components/RJST/Actions/Update.tsx new file mode 100644 index 00000000..2e6838ed --- /dev/null +++ b/src/components/RJST/Actions/Update.tsx @@ -0,0 +1,245 @@ +import React from 'react'; +import type { + ActionData, + RJSTContext, + RJSTModel, + RJSTBaseResource, +} from '../schemaOps'; +import { rjstJsonSchemaPick } from '../schemaOps'; +import { rjstGetDisabledReason } from '../utils'; +import { ActionContent, LOADING_DISABLED_REASON } from './ActionContent'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPenToSquare } from '@fortawesome/free-solid-svg-icons/faPenToSquare'; +import type { CheckedState } from '../components/Table/utils'; +import { useTranslation } from '../../../hooks/useTranslations'; +import { Box, Button, useMediaQuery, useTheme } from '@mui/material'; +import type { DropDownButtonProps } from '../../DropDownButton'; +import { DropDownButton } from '../../DropDownButton'; +import { Spinner } from '../../Spinner'; +import { Tooltip } from '../../Tooltip'; + +interface UpdateProps> { + model: RJSTModel; + rjstContext: RJSTContext; + selected: T[] | undefined; + checkedState?: CheckedState; + hasOngoingAction: boolean; + onActionTriggered: (data: ActionData) => void; +} + +export const Update = >({ + model, + rjstContext, + selected, + hasOngoingAction, + onActionTriggered, + checkedState, +}: UpdateProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isTablet = useMediaQuery(theme.breakpoints.down('md')); + const { actions } = rjstContext; + const updateActions = React.useMemo( + () => + actions + ?.filter( + (action) => action.type === 'update' || action.type === 'delete', + ) + .sort((a) => (a.type === 'delete' ? 1 : -1)) + .sort((a) => (a.isDangerous ? 1 : a.type === 'delete' ? 0 : -1)), + [actions], + ); + + const [disabledReasonsByAction, setDisabledReasonsByAction] = + React.useState>(); + + const [isInitialized, setIsInitialized] = React.useState(false); + + React.useEffect(() => { + if (isInitialized || !updateActions) { + return; + } + setDisabledReasonsByAction( + Object.fromEntries( + updateActions.map((action) => [action.title, LOADING_DISABLED_REASON]), + ), + ); + setIsInitialized(true); + }, [updateActions, isInitialized]); + + const actionHandlers = React.useMemo< + DropDownButtonProps<{ section?: string }>['items'] + >(() => { + if (!updateActions || !disabledReasonsByAction) { + return []; + } + return updateActions.map((action) => { + const disabledActionReason = + rjstGetDisabledReason( + selected, + checkedState, + hasOngoingAction, + action.type as 'update' | 'delete', + t, + ) ?? disabledReasonsByAction[action.title]; + + return { + eventName: `${model.resource} ${action.title}`, + children: ( + + action={action} + getDisabledReason={action.isDisabled} + affectedEntries={selected} + checkedState={checkedState} + onDisabledReady={(result) => { + setDisabledReasonsByAction((disabledReasonsState) => ({ + ...disabledReasonsState, + [action.title]: result, + })); + }} + > + + {action.title} + + + + ), + onClick: () => { + onActionTriggered({ + action, + schema: + action.type === 'delete' + ? {} + : rjstJsonSchemaPick( + model.schema, + model.permissions[action.type], + ), + affectedEntries: selected, + }); + }, + tooltip: + typeof disabledActionReason === 'string' + ? { + title: disabledActionReason, + placement: isTablet ? 'top' : 'right', + } + : undefined, + disabled: !!disabledActionReason, + section: action.section, + }; + }); + }, [ + updateActions, + disabledReasonsByAction, + checkedState, + hasOngoingAction, + isTablet, + model.permissions, + model.resource, + model.schema, + onActionTriggered, + selected, + t, + ]); + + if (!updateActions || updateActions.length < 1 || !disabledReasonsByAction) { + return null; + } + + if (updateActions.length === 1) { + const action = updateActions[0]; + const disabledUpdateReason = + rjstGetDisabledReason( + selected, + checkedState, + hasOngoingAction, + action.type as 'update' | 'delete', + t, + ) ?? disabledReasonsByAction[action.title]; + return ( + + + + + + ); + } + + const disabledReason = rjstGetDisabledReason( + selected, + checkedState, + hasOngoingAction, + null, + t, + ); + + return ( + + + items={actionHandlers} + disabled={!!disabledReason} + startIcon={} + color="secondary" + groupByProp="section" + > + {t('labels.modify')} + + + ); +}; diff --git a/src/components/RJST/DataTypes/array.tsx b/src/components/RJST/DataTypes/array.tsx new file mode 100644 index 00000000..5bb68364 --- /dev/null +++ b/src/components/RJST/DataTypes/array.tsx @@ -0,0 +1,171 @@ +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import { + FULL_TEXT_SLUG, + createModelFilter, + type FormData, +} from '../components/Filters/SchemaSieve'; +import type { CreateFilter } from './utils'; +import { getDataTypeSchema } from './utils'; +import { getDataModel } from '.'; +import { getRefSchema, isJSONSchema } from '../schemaOps'; + +export const operators = () => ({ + contains: 'contains', + not_contains: 'does not contain', +}); + +export type OperatorSlug = + | keyof ReturnType + | typeof FULL_TEXT_SLUG; + +const isSchemaWithPrimitiveItems = ( + schema: JSONSchema, +): schema is JSONSchema & { items: JSONSchema } => + !!schema?.items && + typeof schema.items !== 'boolean' && + 'type' in schema.items; + +const isArrayOfObjectSchema = (schema: JSONSchema) => { + return ( + !!schema?.items && + typeof schema.items !== 'boolean' && + 'properties' in schema.items && + !!schema.items.properties + ); +}; + +const buildFilterForPropertySchema = ( + field: string, + operator: OperatorSlug, + value: string, + schema: JSONSchema, +): JSONSchema => { + const filter = getFilter(field, schema, value, operator); + + if (!Object.keys(filter).length) { + return {}; + } + return { + type: 'object', + properties: { [field]: { type: 'array', ...filter } }, + required: [field], + }; +}; +const wrapFilter = (filter: JSONSchema): JSONSchema => { + if (typeof filter.not === 'object') { + return { not: { contains: filter.not } }; + } + return { minItems: 1, type: 'array', contains: filter }; +}; + +const determineOperator = ( + operator: OperatorSlug, + schema: JSONSchema, +): OperatorSlug | 'is' | 'is_not' => { + if (operator === 'not_contains') { + return 'is_not'; + } + if ( + operator === 'contains' || + (schema.type === 'string' && operator === FULL_TEXT_SLUG) + ) { + return 'is'; + } + return operator; +}; + +const getFilter = ( + field: string, + schema: JSONSchema, + value: string, + operator: OperatorSlug, +): JSONSchema => { + if (isArrayOfObjectSchema(schema)) { + return buildArrayOfObjectFilter(schema, operator, value); + } + + const hasPrimitiveItems = isSchemaWithPrimitiveItems(schema); + const effectiveSchema = hasPrimitiveItems ? schema.items : schema; + const effectiveOperator = determineOperator(operator, effectiveSchema); + + const filter = createModelFilter(effectiveSchema, { + field, + operator: effectiveOperator, + value, + }); + if (!filter || typeof filter !== 'object' || !Object.keys(filter).length) { + return {}; + } + + const recursiveFilter = hasPrimitiveItems + ? filter.properties?.[field] + : filter; + + return recursiveFilter && + isJSONSchema(recursiveFilter) && + Object.keys(recursiveFilter).length + ? wrapFilter(recursiveFilter) + : {}; +}; + +const buildArrayOfObjectFilter = ( + schema: JSONSchema, + operator: OperatorSlug, + value: string, +): JSONSchema => { + if (!isJSONSchema(schema.items) || !isJSONSchema(schema.items.properties)) { + return {}; + } + + const propertyFilters = Object.entries(schema.items.properties) + .map(([key, propSchema]) => + createModelFilter(propSchema, { field: key, operator, value }), + ) + .filter(isJSONSchema); + + if (!propertyFilters.length) { + return {}; + } + + return { + minItems: 1, + type: 'array', + contains: + propertyFilters.length === 1 + ? propertyFilters[0] + : { anyOf: propertyFilters }, + }; +}; + +export const createFilter: CreateFilter = ( + field, + operator, + value, + propertySchema, +) => { + if (!propertySchema) { + return {}; + } + return buildFilterForPropertySchema(field, operator, value, propertySchema); +}; + +export const rendererSchema = ( + schemaField: JSONSchema, + index: number, + schema: JSONSchema, + data: FormData, +): any => { + const refSchema = getRefSchema(schema, 'items.properties.'); + if (isArrayOfObjectSchema(refSchema) && isJSONSchema(refSchema.items)) { + const model = getDataModel(refSchema.items); + if (!model) { + return; + } + + return model.rendererSchema(schemaField, index, refSchema.items, data); + } + // we are not considering items as array, we don't need it atm + const propertyItems = isJSONSchema(refSchema.items) ? refSchema.items : {}; + + return getDataTypeSchema(schemaField, index, operators(), propertyItems); +}; diff --git a/src/components/RJST/DataTypes/boolean.tsx b/src/components/RJST/DataTypes/boolean.tsx new file mode 100644 index 00000000..18f5fe57 --- /dev/null +++ b/src/components/RJST/DataTypes/boolean.tsx @@ -0,0 +1,57 @@ +import type { CreateFilter } from './utils'; +import { getDataTypeSchema } from './utils'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; + +export const operators = () => ({ + is: 'is', +}); + +export type OperatorSlug = keyof ReturnType | 'is_not'; + +export const createFilter: CreateFilter = ( + field, + operator, + value, +) => { + const val = + typeof value === 'string' ? value.toLowerCase() === 'true' : value; + + if (operator === 'is') { + return { + type: 'object', + properties: { + [field]: { + const: val, + }, + }, + required: [field], + }; + } + if (operator === 'is_not') { + return { + type: 'object', + properties: { + [field]: { + not: { + const: val, + }, + }, + }, + required: [field], + }; + } + return {}; +}; + +export const uiSchema = () => ({ + 'ui:widget': 'select', +}); + +export const rendererSchema = (schemaField: JSONSchema, index: number) => { + const valueSchema: JSONSchema = { + type: 'boolean', + title: 'Value', + description: '', + }; + return getDataTypeSchema(schemaField, index, operators(), valueSchema); +}; diff --git a/src/components/RJST/DataTypes/date-time.tsx b/src/components/RJST/DataTypes/date-time.tsx new file mode 100644 index 00000000..3fc4cc2f --- /dev/null +++ b/src/components/RJST/DataTypes/date-time.tsx @@ -0,0 +1,103 @@ +import type { CreateFilter } from './utils'; +import { getDataTypeSchema, normalizeDateTime } from './utils'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; + +export const operators = () => ({ + is: 'is', + is_before: 'is before', + is_after: 'is after', +}); + +export type OperatorSlug = keyof ReturnType | 'is_not'; + +export const createFilter: CreateFilter = ( + field, + operator, + value, +) => { + const normalizedValue = normalizeDateTime(value); + + // TODO: Double check that it works and if this can be improved + const dataType = typeof normalizedValue as 'string' | 'number'; + if (value != null && normalizedValue == null) { + return {}; + } + + if (operator === 'is') { + return { + type: 'object', + properties: { + [field]: { + type: dataType ?? 'string', + format: 'date-time', + const: normalizedValue, + }, + }, + required: [field], + }; + } + + if (operator === 'is_not') { + return { + type: 'object', + properties: { + [field]: { + type: dataType ?? 'string', + format: 'date-time', + not: { + const: normalizedValue, + }, + }, + }, + required: [field], + }; + } + + if (operator === 'is_before') { + const rule = + dataType === 'number' + ? { exclusiveMaximum: normalizedValue as number } + : { formatMaximum: normalizedValue }; + return { + type: 'object', + properties: { + [field]: { + type: dataType ?? 'string', + format: 'date-time', + ...rule, + }, + }, + required: [field], + }; + } + + if (operator === 'is_after') { + const rule = + dataType === 'number' + ? { exclusiveMinimum: normalizedValue as number } + : { formatMinimum: normalizedValue }; + return { + type: 'object', + properties: { + [field]: { + type: dataType ?? 'string', + format: 'date-time', + ...rule, + }, + }, + required: [field], + }; + } + + return {}; +}; + +export const rendererSchema = (schemaField: JSONSchema, index: number) => { + const valueSchema: JSONSchema = { + type: 'string', + format: 'date-time', + title: 'Value', + description: '', + }; + return getDataTypeSchema(schemaField, index, operators(), valueSchema); +}; diff --git a/src/components/RJST/DataTypes/enum.tsx b/src/components/RJST/DataTypes/enum.tsx new file mode 100644 index 00000000..b0de6dc0 --- /dev/null +++ b/src/components/RJST/DataTypes/enum.tsx @@ -0,0 +1,148 @@ +import isEqual from 'lodash/isEqual'; +import { FULL_TEXT_SLUG } from '../components/Filters/SchemaSieve'; +import type { CreateFilter } from './utils'; +import { getDataTypeSchema, regexEscape } from './utils'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; + +export const operators = () => ({ + is: 'is', + is_not: 'is not', +}); + +export type OperatorSlug = + | keyof ReturnType + | typeof FULL_TEXT_SLUG; + +const notNullObj = { not: { const: null } }; +const isNotNullObj = (value: unknown): boolean => isEqual(value, notNullObj); + +const getFilter = (index: number, enums: JSONSchema['enum']) => { + if (!enums) { + return null; + } + const enumValue = enums[index]; + if (typeof enumValue === 'string') { + return { + type: 'string', + regexp: { + pattern: regexEscape(enumValue), + flags: 'i', + }, + }; + } + if (typeof enumValue === 'object') { + return isNotNullObj(enumValue) + ? enumValue + : { + const: enumValue, + }; + } + return null; +}; + +const getValues = ( + value: string, + propertySchema?: JSONSchema & { enumNames?: string[] }, +) => { + if (!propertySchema?.enum || !propertySchema.enumNames) { + return null; + } + const enums = propertySchema.enum; + const enumNamesIncludingValueIndexes: number[] = []; + const lowerCaseValue = value.toLowerCase(); + propertySchema.enumNames.forEach((enumName, index) => { + if (enumName.toLowerCase().includes(lowerCaseValue)) { + enumNamesIncludingValueIndexes.push(index); + } + }); + + const values = enums + ? enumNamesIncludingValueIndexes.map((i) => getFilter(i, enums)) + : []; + return values.length > 1 + ? { + anyOf: values, + } + : (values[0] ?? null); +}; + +export const createFilter: CreateFilter = ( + field, + operator, + value, + propertySchema, +) => { + if (operator === FULL_TEXT_SLUG && propertySchema?.enumNames) { + const filter = getValues(value, propertySchema); + if (!filter) { + return {}; + } + return { + type: 'object', + properties: { + [field]: filter, + }, + required: [field], + }; + } + + if (operator === FULL_TEXT_SLUG && typeof value === 'string') { + return { + type: 'object', + properties: { + [field]: { + type: 'string', + regexp: { + pattern: regexEscape(value), + flags: 'i', + }, + }, + }, + required: [field], + }; + } + + if (operator === 'is') { + return { + type: 'object', + properties: { + [field]: isNotNullObj(value) + ? value + : { + const: value, + }, + }, + required: [field], + }; + } + + if (operator === 'is_not') { + return { + type: 'object', + properties: { + [field]: isNotNullObj(value) + ? { const: null } + : { + not: { + const: value, + }, + }, + }, + }; + } + + return {}; +}; + +export const rendererSchema = ( + schemaField: JSONSchema, + index: number, + propertySchema: JSONSchema, +) => { + const valueSchema: JSONSchema = { + ...propertySchema, + title: 'Value', + description: '', + }; + return getDataTypeSchema(schemaField, index, operators(), valueSchema); +}; diff --git a/src/components/RJST/DataTypes/index.ts b/src/components/RJST/DataTypes/index.ts new file mode 100644 index 00000000..c7dde6b0 --- /dev/null +++ b/src/components/RJST/DataTypes/index.ts @@ -0,0 +1,132 @@ +import * as arrayType from './array'; +import * as stringType from './string'; +import * as objectType from './object'; +import * as booleanType from './boolean'; +import * as numberType from './number'; +import * as enumType from './enum'; +import * as oneOfType from './oneOf'; +import * as dateTimeType from './date-time'; + +import { + type JSONSchema7 as JSONSchema, + type JSONSchema7Definition as JSONSchemaDefinition, + type JSONSchema7TypeName as JSONSchemaTypeName, +} from 'json-schema'; +import { getPropertySchema } from '../components/Filters/SchemaSieve'; +import { getRefSchema } from '../schemaOps'; + +type ExcludeLiteral = T extends U ? never : T; + +type PartialJSONSchemaTypeName = ExcludeLiteral; + +type DataTypeModule = + | typeof arrayType + | typeof objectType + | typeof stringType + | typeof booleanType + | typeof numberType + | typeof enumType + | typeof oneOfType + | typeof dateTimeType; + +export type TransformedDataTypeModule = Omit & { + operators: Record; + operatorsOneOf: JSONSchema[]; +}; + +/* eslint-disable id-denylist */ +const dataTypeMap: Record = { + array: arrayType, + string: stringType, + object: objectType, + boolean: booleanType, + number: numberType, + integer: numberType, +}; + +export const isDateTimeFormat = (format: string | undefined) => + format?.endsWith('date-time'); + +const transformModule = ( + module: DataTypeModule | undefined, + property: JSONSchema, +): TransformedDataTypeModule | null => { + if (!module) { + return null; + } + const operators = module.operators(property); + const operatorsOneOf = Object.entries(operators).map(([key, value]) => ({ + title: value, + const: key, + })); + return { + ...module, + operators, + operatorsOneOf, + }; +}; + +// This function will retrieve the data type model based on the property type. +// if the JSONSchema property is a number, it will get DataTypes/number.tsx. +export const getDataModel = ( + property: JSONSchemaDefinition | undefined, +): TransformedDataTypeModule | null => { + if (!property || typeof property === 'boolean') { + return null; + } + try { + let module: DataTypeModule; + const { format, type, enum: propertyEnum, oneOf } = property; + if (propertyEnum) { + module = enumType; + } else if (oneOf) { + module = oneOfType; + } else if (format?.endsWith('date-time')) { + module = dateTimeType; + } else { + if (!type) { + return null; + } + const typeSet = Array.isArray(type) ? type : [type]; + const dataTypeKey = Object.keys(dataTypeMap).find((t) => + typeSet.includes(t as JSONSchemaTypeName), + ); + if (!dataTypeKey) { + return null; + } + module = dataTypeMap[dataTypeKey as keyof typeof dataTypeMap]; + } + return transformModule(module, property); + } catch (error) { + console.error('Error loading component', error); + throw error; + } +}; + +export const getPropertySchemaAndModel = ( + field: string, + schema: JSONSchema, +) => { + const propertySchema = getPropertySchema(field, schema); + // this does not work with array of arrays yet, it should be implemented as soon as we need it + const prefix = + propertySchema?.type === 'array' ? 'items.properties.' : 'properties.'; + const refSchema = propertySchema + ? getRefSchema(propertySchema, prefix) + : propertySchema; + + return { model: getDataModel(refSchema), propertySchema: refSchema }; +}; + +export const getAllOperators = (schema: JSONSchema) => { + return { + ...arrayType.operators(), + ...stringType.operators(), + ...objectType.operators(schema), + ...booleanType.operators(), + ...numberType.operators(), + ...enumType.operators(), + ...oneOfType.operators(), + ...dateTimeType.operators(), + }; +}; diff --git a/src/components/RJST/DataTypes/number.tsx b/src/components/RJST/DataTypes/number.tsx new file mode 100644 index 00000000..4817ef08 --- /dev/null +++ b/src/components/RJST/DataTypes/number.tsx @@ -0,0 +1,112 @@ +import { FULL_TEXT_SLUG } from '../components/Filters/SchemaSieve'; +import type { CreateFilter } from './utils'; +import { getDataTypeSchema } from './utils'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; + +// This is the max safe integer supported DB: 2**31-1 +const MAX_SAFE_DB_INT4 = 2147483647; + +export const operators = () => ({ + is: 'is', + is_not: 'is not', + is_more_than: 'is more than', + is_less_than: 'is less than', +}); + +export type OperatorSlug = + | keyof ReturnType + | typeof FULL_TEXT_SLUG; + +export const createFilter: CreateFilter = ( + field, + operator, + value, + propertySchema, +) => { + const val = + typeof value === 'number' + ? value + : value !== '' && value != null + ? Number(value) + : undefined; + + const fieldType = propertySchema?.type ?? 'number'; + + if ( + val == null || + isNaN(val) || + (fieldType === 'integer' && + (!Number.isInteger(val) || val >= MAX_SAFE_DB_INT4)) + ) { + return {}; + } + + if (operator === 'is' || operator === FULL_TEXT_SLUG) { + return { + type: 'object', + properties: { + [field]: { + type: fieldType, + const: val, + }, + }, + required: [field], + }; + } + + if (operator === 'is_not') { + return { + type: 'object', + properties: { + [field]: { + type: fieldType, + not: { + const: val, + }, + }, + }, + required: [field], + }; + } + + if (operator === 'is_more_than') { + return { + type: 'object', + properties: { + [field]: { + type: fieldType, + exclusiveMinimum: val, + }, + }, + required: [field], + }; + } + + if (operator === 'is_less_than') { + return { + type: 'object', + properties: { + [field]: { + type: fieldType, + exclusiveMaximum: val, + }, + }, + required: [field], + }; + } + + return {}; +}; + +export const rendererSchema = ( + schemaField: JSONSchema, + index: number, + propertySchema: JSONSchema, +) => { + const valueSchema: JSONSchema = { + type: propertySchema.type ?? 'number', + title: 'Value', + description: '', + }; + return getDataTypeSchema(schemaField, index, operators(), valueSchema); +}; diff --git a/src/components/RJST/DataTypes/object.tsx b/src/components/RJST/DataTypes/object.tsx new file mode 100644 index 00000000..efbf94b6 --- /dev/null +++ b/src/components/RJST/DataTypes/object.tsx @@ -0,0 +1,456 @@ +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import find from 'lodash/find'; +import type { CreateFilter, KeysOfUnion } from './utils'; +import { getDataTypeSchema, regexEscape } from './utils'; +import type { FormData } from '../components/Filters/SchemaSieve'; +import { + FULL_TEXT_SLUG, + createModelFilter, +} from '../components/Filters/SchemaSieve'; +import { isJSONSchema, getRefSchema } from '../schemaOps'; +import findKey from 'lodash/findKey'; +import pick from 'lodash/pick'; +import mapValues from 'lodash/mapValues'; + +const getKeyLabel = (schema: JSONSchema) => { + const s = find(schema.properties, { description: 'key' }) as JSONSchema; + return s?.title ? s.title : 'key'; +}; + +const getValueLabel = (schema: JSONSchema) => { + const s = find(schema.properties, { description: 'value' }) as JSONSchema; + return s?.title ? s.title : 'value'; +}; + +export const isKeyValueObj = (schema: JSONSchema) => + !!find(schema.properties, { description: 'key' }) || + !!find(schema.properties, { description: 'value' }); + +export const operators = (s: JSONSchema) => { + return { + is: 'is', + is_not: 'is not', + ...(!isKeyValueObj(s) + ? { + contains: 'contains', + not_contains: 'does not contain', + } + : (() => { + const keyLabel = getKeyLabel(s); + const valueLabel = getValueLabel(s); + return { + key_contains: `${keyLabel} contains`, + key_not_contains: `${keyLabel} does not contain`, + key_is: `${keyLabel} is`, + key_starts_with: `${keyLabel} starts with`, + key_ends_with: `${keyLabel} ends with`, + key_not_starts_with: `${keyLabel} does not starts with`, + key_not_ends_with: `${keyLabel} does not ends with`, + value_is: `${valueLabel} is`, + value_contains: `${valueLabel} contains`, + value_not_contains: `${valueLabel} does not contain`, + value_starts_with: `${valueLabel} starts with`, + value_ends_with: `${valueLabel} ends with`, + value_not_starts_with: `${valueLabel} does not starts with`, + value_not_ends_with: `${valueLabel} does not ends with`, + }; + })()), + }; +}; + +const keySpecificOperators = [ + 'key_is', + 'key_contains', + 'key_not_contains', + 'key_starts_with', + 'key_ends_with', + 'key_not_starts_with', + 'key_not_ends_with', +]; + +const valueSpecificOperators = [ + 'value_is', + 'value_contains', + 'value_not_contains', + 'value_starts_with', + 'value_ends_with', + 'value_not_starts_with', + 'value_not_ends_with', +]; + +const getValueForOperation = ( + operator: string, + schema: JSONSchema, + value: string | object, +) => { + // Determine if the operation is key-specific or value-specific + const isKeyOperation = keySpecificOperators.includes(operator); + const isValueOperation = valueSpecificOperators.includes(operator); + + // Find the schema key or value based on the operation type + const schemaField = isKeyOperation + ? 'key' + : isValueOperation + ? 'value' + : null; + const schemaProperty = schemaField + ? findKey(schema.properties, { description: schemaField }) + : null; + + // Return the appropriate value format based on the operation type + return schemaProperty + ? typeof value === 'string' + ? { type: 'string', [schemaProperty]: value } + : pick(value, schemaProperty) + : value; +}; + +const getTitleForOperation = ( + operator: OperatorSlug, + schema: JSONSchema, + value: string | object, +) => { + if (typeof value !== 'object' || !schema.properties) { + return schema.title; + } + + // Combine key and value specific operators for easier checking + const allSpecificOperators = [ + ...keySpecificOperators, + ...valueSpecificOperators, + ]; + + // Extract the first key from the value object + const firstKeyOfValue = Object.keys(value)[0]; + + // Proceed only if the operator is in the list of specific operators and the property exists + if ( + allSpecificOperators.includes(operator) && + schema.properties[firstKeyOfValue] + ) { + const property = schema.properties[firstKeyOfValue]; + + // Ensure the property is an object and has a title + if (typeof property === 'object' && property.title) { + return property.title; + } + } + + // Default return if none of the conditions above are met + return schema.title; +}; + +export type OperatorSlug = + | KeysOfUnion> + | typeof FULL_TEXT_SLUG; + +export const createFilter: CreateFilter = ( + field, + operator, + value, + schema, +) => { + if (!schema) { + return {}; + } + + const isKeyValue = isKeyValueObj(schema); + const internalValue = isKeyValue + ? getValueForOperation(operator, schema, value) + : value; + const propertyTitle = getTitleForOperation(operator, schema, internalValue); + + const isFilter = (v: any) => ({ const: v }); + + const containsFilter = (v: any) => ({ + description: v, + regexp: { + pattern: regexEscape(String(v)), + flags: 'i', + }, + }); + + const startsWithFilter = (v: any) => ({ + pattern: `^${regexEscape(v)}`, + $comment: 'starts_with', + }); + const endsWithFilter = (v: any) => ({ + pattern: `${regexEscape(v)}$`, + $comment: 'ends_with', + }); + + if (!isKeyValue) { + return { + type: 'object', + properties: { + [field]: getFilter(field, schema, internalValue, operator), + }, + required: [field], + }; + } + + // TODO: this case does not cover complex objects for FULL_TEXT_SLUG + if (operator === FULL_TEXT_SLUG && schema.properties) { + const schemaKey = findKey(schema.properties, { description: 'key' }); + const schemaValue = findKey(schema.properties, { description: 'value' }); + const properties = [schemaKey, schemaValue] + .map((key) => + key + ? { + type: 'object', + properties: { + [key]: { + type: 'string', + pattern: regexEscape(internalValue), + }, + }, + required: [key], + } + : null, + ) + .filter((p) => p); + return { + type: 'object', + properties: { + [field]: { + type: 'array', + contains: { + type: 'object', + title: propertyTitle, + anyOf: properties, + }, + }, + }, + }; + } + + if (operator === 'is') { + return { + type: 'object', + properties: { + [field]: { + type: 'array', + contains: { + type: 'object', + title: propertyTitle, + properties: mapValues(internalValue, (v) => ({ const: v })), + }, + }, + }, + required: [field], + }; + } + + if (operator === 'is_not') { + return { + type: 'object', + properties: { + [field]: { + not: { + contains: { + type: 'object', + title: propertyTitle, + properties: mapValues(internalValue, (v) => ({ const: v })), + }, + }, + }, + }, + }; + } + + if (operator === 'key_is' || operator === 'value_is') { + return { + type: 'object', + properties: { + [field]: { + type: 'array', + contains: { + type: 'object', + title: propertyTitle, + properties: mapValues(internalValue, isFilter), + }, + }, + }, + required: [field], + }; + } + + if (operator === 'key_contains' || operator === 'value_contains') { + return { + type: 'object', + properties: { + [field]: { + type: 'array', + contains: { + type: 'object', + title: propertyTitle, + properties: + typeof value !== 'object' + ? containsFilter(internalValue) + : mapValues(internalValue, containsFilter), + }, + }, + }, + required: [field], + }; + } + + if (operator === 'key_not_contains' || operator === 'value_not_contains') { + return { + type: 'object', + properties: { + [field]: { + not: { + contains: { + type: 'object', + title: propertyTitle, + properties: + typeof internalValue !== 'object' + ? containsFilter(internalValue) + : mapValues(internalValue, containsFilter), + }, + }, + }, + }, + }; + } + + if ( + operator === 'key_starts_with' || + operator === 'value_starts_with' || + operator === 'key_ends_with' || + operator === 'value_ends_with' + ) { + const filterMethod = operator.includes('starts') + ? startsWithFilter + : endsWithFilter; + return { + type: 'object', + properties: { + [field]: { + type: 'array', + contains: { + type: 'object', + title: propertyTitle, + properties: + typeof internalValue !== 'object' + ? filterMethod(internalValue) + : mapValues(internalValue, filterMethod), + }, + }, + }, + required: [field], + }; + } + + if ( + operator === 'key_not_starts_with' || + operator === 'value_not_starts_with' || + operator === 'key_not_ends_with' || + operator === 'value_not_ends_with' + ) { + const filterMethod = operator.includes('starts') + ? startsWithFilter + : endsWithFilter; + return { + type: 'object', + properties: { + [field]: { + not: { + contains: { + type: 'object', + title: propertyTitle, + properties: + typeof internalValue !== 'object' + ? filterMethod(internalValue) + : mapValues(internalValue, filterMethod), + }, + }, + }, + }, + }; + } + + return {}; +}; + +export const getFilter = ( + field: string, + schema: JSONSchema, + value: string, + operator: string, +): JSONSchema => { + if (!!schema?.properties && typeof schema.properties !== 'boolean') { + const anyOf = Object.entries(schema.properties) + .map(([propKey, propValue]) => { + const filter = createModelFilter(propValue, { + field: propKey, + operator, + value, + }); + return filter; + }) + .filter(isJSONSchema); + return { + anyOf, + }; + } + const fieldFilter = createModelFilter(schema, { field, operator, value }); + + if (!fieldFilter || typeof fieldFilter !== 'object') { + return {}; + } + + return { + contains: fieldFilter.properties?.[field], + }; +}; + +const reworkTagsProperties = ( + properties: JSONSchema['properties'], + filterBy: 'key' | 'value' | null, +) => { + if (!properties) { + return properties; + } + return Object.fromEntries( + Object.entries(properties) + .filter(([, value]) => + filterBy && isJSONSchema(value) + ? value.description === filterBy + : !!value, + ) + .map(([key, value]) => [ + key, + isJSONSchema(value) ? { ...value, description: '' } : value, + ]), + ); +}; + +export const rendererSchema = ( + schemaField: JSONSchema, + index: number, + schema: JSONSchema, + data: FormData, +) => { + const refSchema = getRefSchema(schema, 'properties.'); + // This is a customization for Tags, we need to keep it until we can remove this custom tag logic. + // Ideally objects should always render all properties and have as operators is/is_not/contains/not_contains + const properties = reworkTagsProperties( + refSchema.properties, + data?.operator?.includes('key') + ? 'key' + : data?.operator?.includes('value') + ? 'value' + : null, + ); + + const valueSchema: JSONSchema = { + ...refSchema, + type: 'object', + title: '', + description: '', + properties, + }; + return getDataTypeSchema(schemaField, index, operators(schema), valueSchema); +}; diff --git a/src/components/RJST/DataTypes/oneOf.tsx b/src/components/RJST/DataTypes/oneOf.tsx new file mode 100644 index 00000000..6a193b5d --- /dev/null +++ b/src/components/RJST/DataTypes/oneOf.tsx @@ -0,0 +1,84 @@ +import { isJSONSchema } from '../schemaOps'; +import { FULL_TEXT_SLUG } from '../components/Filters/SchemaSieve'; +import type { CreateFilter } from './utils'; +import { getDataTypeSchema } from './utils'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +export const operators = () => ({ + is: 'is', + is_not: 'is not', +}); + +export type OperatorSlug = + | keyof ReturnType + | typeof FULL_TEXT_SLUG; + +export const createFilter: CreateFilter = ( + field, + operator, + value, + propertySchema, +) => { + if (operator === FULL_TEXT_SLUG) { + const constValues = + propertySchema?.oneOf + ?.filter( + (o) => + isJSONSchema(o) && + o.title?.toLowerCase().includes(value.toLowerCase()), + ) + .map((v) => (isJSONSchema(v) ? { const: v.const } : null)) ?? null; + + if (!constValues?.length) { + return {}; + } + + return { + type: 'object', + properties: { + [field]: + constValues.length > 1 ? { anyOf: constValues } : constValues[0], + }, + required: [field], + }; + } + + if (operator === 'is') { + return { + type: 'object', + properties: { + [field]: { + const: value, + }, + }, + required: [field], + }; + } + + if (operator === 'is_not') { + return { + type: 'object', + properties: { + [field]: { + not: { + const: value, + }, + }, + }, + }; + } + + return {}; +}; + +export const rendererSchema = ( + schemaField: JSONSchema, + index: number, + propertySchema: JSONSchema, +) => { + const valueSchema: JSONSchema = { + ...propertySchema, + title: 'Value', + description: '', + }; + return getDataTypeSchema(schemaField, index, operators(), valueSchema); +}; diff --git a/src/components/RJST/DataTypes/string.tsx b/src/components/RJST/DataTypes/string.tsx new file mode 100644 index 00000000..f77feccc --- /dev/null +++ b/src/components/RJST/DataTypes/string.tsx @@ -0,0 +1,167 @@ +import type { CreateFilter } from './utils'; +import { getDataTypeSchema, regexEscape } from './utils'; +import { FULL_TEXT_SLUG } from '../components/Filters/SchemaSieve'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; + +// TODO: we should make it an object as soon as we will be able to remove custom Tags logic in DataTypes/object.tsx. +export const operators = () => ({ + contains: 'contains', + not_contains: 'does not contain', + is: 'is', + is_not: 'is not', + starts_with: 'starts with', + not_starts_with: 'does not start with', + ends_with: 'ends with', + not_ends_with: 'does not end with', +}); + +export type OperatorSlug = + | keyof ReturnType + | typeof FULL_TEXT_SLUG; + +export const createFilter: CreateFilter = ( + field, + operator, + value, +) => { + // When parsing query parameters on page reload using 'qs', we need to ensure that string properties remain strings. + // For example, a string like "16.7" might be automatically converted to a number (16.7). + // This conversion can cause issues with functions like 'regexEscape', which expect string inputs. + const stringValue = String(value); + if (operator === 'is') { + return { + type: 'object', + properties: { + [field]: { + type: 'string', + const: stringValue, + }, + }, + required: [field], + }; + } + + if (operator === 'is_not') { + return { + type: 'object', + properties: { + [field]: { + type: 'string', + not: { + const: stringValue, + }, + }, + }, + required: [field], + }; + } + + if (operator === 'contains' || operator === FULL_TEXT_SLUG) { + return { + type: 'object', + properties: { + [field]: { + type: 'string', + regexp: { + pattern: regexEscape(stringValue), + flags: 'i', + }, + }, + }, + required: [field], + }; + } + + if (operator === 'not_contains') { + return { + type: 'object', + properties: { + [field]: { + not: { + type: 'string', + regexp: { + pattern: regexEscape(stringValue), + flags: 'i', + }, + }, + }, + }, + }; + } + + if (operator === 'starts_with') { + return { + type: 'object', + properties: { + [field]: { + type: 'string', + pattern: `^${regexEscape(stringValue)}`, + $comment: 'starts_with', + }, + }, + required: [field], + }; + } + + if (operator === 'not_starts_with') { + return { + type: 'object', + properties: { + [field]: { + type: 'string', + not: { + $comment: 'not_starts_with', + pattern: `^${regexEscape(stringValue)}`, + }, + }, + }, + required: [field], + }; + } + + if (operator === 'ends_with') { + return { + type: 'object', + properties: { + [field]: { + type: 'string', + $comment: 'ends_with', + pattern: `${regexEscape(stringValue)}$`, + }, + }, + required: [field], + }; + } + + if (operator === 'not_ends_with') { + return { + type: 'object', + properties: { + [field]: { + type: 'string', + not: { + $comment: 'not_ends_with', + pattern: `${regexEscape(stringValue)}$`, + }, + }, + }, + required: [field], + }; + } + + return {}; +}; + +export const rendererSchema = ( + schemaField: JSONSchema, + index: number, + schema: JSONSchema, +) => { + const valueSchema: JSONSchema = { + type: 'string', + title: 'Value', + description: '', + examples: schema.examples, + }; + return getDataTypeSchema(schemaField, index, operators(), valueSchema); +}; diff --git a/src/components/RJST/DataTypes/utils.ts b/src/components/RJST/DataTypes/utils.ts new file mode 100644 index 00000000..de10aab2 --- /dev/null +++ b/src/components/RJST/DataTypes/utils.ts @@ -0,0 +1,69 @@ +import type { Schema } from 'ajv'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import memoize from 'lodash/memoize'; + +const matchOperatorsRe = /[|\\{}()[\]^$+*?]/g; +const unescapeRegexRe = /\\([|\\{}()[\]^$+*?])/g; + +export const regexEscape = (str: string) => + str.replace(matchOperatorsRe, '\\$&'); + +export const regexUnescape = (str: string) => + str.replace(unescapeRegexRe, '$1'); + +export type KeysOfUnion = T extends any ? keyof T : never; + +export type CreateFilter = ( + field: string, + operator: TOperatorSlugs, + value: any, + propertySchema?: JSONSchema & { enumNames?: string[] }, +) => Schema | JSONSchema; + +export const getDefaultDate = (): string => { + const date = new Date(); + return date.toISOString().split('.')[0]; +}; + +// Normalize a timestamp to a RFC3339 timestamp, which is required for JSON schema. +export const normalizeDateTime = memoize((timestamp: string | number) => { + const d = new Date(timestamp); + if (isNaN(d.getTime())) { + return null; + } + return typeof timestamp === 'number' + ? d.getTime() + : d.toISOString().split('.')[0] + 'Z'; // Remove miliseconds; +}); + +export const getDataTypeSchema = ( + schemaField: Partial, + index: number, + operators: Record, + valueSchema: Partial, +): JSONSchema => { + const operatorsOneOf = Object.entries(operators).map( + ([operatorKey, operatorValue]) => ({ + title: operatorValue, + const: operatorKey, + }), + ); + // Let's keep all ids generation here to have a better view. + schemaField.$id = `field-${index}`; + valueSchema.$id = `value-${index}`; + return { + $id: `filter-schema-${index}`, + type: 'object', + properties: { + field: schemaField, + operator: { + $id: `operator-${index}`, + title: 'Operator', + type: 'string', + oneOf: operatorsOneOf, + }, + value: valueSchema, + }, + required: ['field', 'operator', 'value'], + }; +}; diff --git a/src/components/RJST/Filters/Filters.tsx b/src/components/RJST/Filters/Filters.tsx new file mode 100644 index 00000000..38f33fd1 --- /dev/null +++ b/src/components/RJST/Filters/Filters.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import { PersistentFilters } from './PersistentFilters'; +import type { FiltersView } from '../components/Filters'; +import { + type FilterRenderMode, + Filters as FiltersComponent, +} from '../components/Filters'; +import { + modifySchemaWithRefSchemes, + removeFieldsWithNoFilter, + removeRefSchemeSeparatorsFromFilters, +} from './utils'; +import { useHistory } from '../../../hooks/useHistory'; + +export interface FiltersProps { + schema: JSONSchema; + filters: JSONSchema[]; + views: FiltersView[]; + changeFilters: (filters: JSONSchema[]) => void; + changeViews: (views: FiltersView[]) => void; + viewsRestorationKey?: string; + renderMode?: FilterRenderMode | FilterRenderMode[]; + onSearch?: (searchTerm: string) => React.ReactElement | null; + persistFilters?: boolean; +} + +const DEFAULT_RENDER_MODE = (['add', 'search', 'views'] as const).slice(); + +export const Filters = ({ + schema, + filters, + views, + changeFilters, + changeViews, + viewsRestorationKey, + renderMode, + onSearch, + persistFilters, +}: FiltersProps) => { + const history = useHistory(); + + const filteredSchema = React.useMemo( + () => removeFieldsWithNoFilter(schema), + [schema], + ); + + // This is the function that will rework the schema taking in consideration x-ref-scheme and x-foreign-key-scheme. + const reworkedSchema = React.useMemo( + () => modifySchemaWithRefSchemes(filteredSchema), + [filteredSchema], + ); + + const onFiltersChange = (updatedFilters: JSONSchema[]) => { + const reworkedFilters = + removeRefSchemeSeparatorsFromFilters(updatedFilters); + changeFilters(reworkedFilters); + }; + + return ( + <> + {!!history && persistFilters ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/components/RJST/Filters/PersistentFilters.tsx b/src/components/RJST/Filters/PersistentFilters.tsx new file mode 100644 index 00000000..b9c52f02 --- /dev/null +++ b/src/components/RJST/Filters/PersistentFilters.tsx @@ -0,0 +1,257 @@ +import * as React from 'react'; +import qs from 'qs'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import type { FiltersProps } from '../components/Filters'; +import { Filters } from '../components/Filters'; +import type { FilterDescription } from '../components/Filters/SchemaSieve'; +import { + FULL_TEXT_SLUG, + createFilter, + createFullTextSearchFilter, + parseFilterDescription, +} from '../components/Filters/SchemaSieve'; +import { isJSONSchema } from '../schemaOps'; +import { useAnalyticsContext } from '../../../contexts/AnalyticsContext'; + +export interface ListQueryStringFilterObject { + n: string; + o: string; + v: string; +} + +const isListQueryStringFilterRule = ( + rule: any, +): rule is ListQueryStringFilterObject => + rule != null && + typeof rule === 'object' && + // it has to have an associated field + !!rule.n && + typeof rule.n === 'string' && + // it should at least have an operator + ((!!rule.o && typeof rule.o === 'string') || + // or a value + (rule.v != null && rule.v !== '')); + +const isQueryStringFilterRuleset = ( + rule: any, +): rule is ListQueryStringFilterObject[] => + Array.isArray(rule) && + !!rule?.length && + rule?.every(isListQueryStringFilterRule); + +export function listFilterQuery(filters: JSONSchema[], stringify: true): string; +export function listFilterQuery( + filters: JSONSchema[], + stringify?: false, +): Array; +export function listFilterQuery(filters: JSONSchema[], stringify = true) { + const queryStringFilters = filters.map((filter) => { + const signatures = + filter.title === FULL_TEXT_SLUG + ? [parseFilterDescription(filter)].filter( + (f): f is FilterDescription => !!f, + ) + : filter.anyOf + ?.filter((f): f is JSONSchema => isJSONSchema(f)) + .map( + (f) => + ({ + ...parseFilterDescription(f), + operatorSlug: f.title, + }) as FilterDescription & { operatorSlug?: string }, + ) + .filter((f) => !!f); + + return signatures?.map( + ({ + field, + operator, + operatorSlug, + value, + }: FilterDescription & { operatorSlug?: string }) => ({ + n: field, + o: operatorSlug ?? operator, + v: value, + }), + ); + }); + return stringify + ? qs.stringify(queryStringFilters, { + strictNullHandling: true, + }) + : queryStringFilters; +} + +export const loadRulesFromUrl = ( + searchLocation: string, + schema: JSONSchema, + history: unknown, +): JSONSchema[] => { + const { properties } = schema; + if (!searchLocation || !properties) { + return []; + } + const parsed = + qs.parse(searchLocation, { + ignoreQueryPrefix: true, + strictNullHandling: true, + // The 'qs' library doesn't automatically parse values into their respective types (e.g., numbers, booleans). + // It treats everything as a string by default, as explained in the documentation: + // https://github.com/ljharb/qs#parsing-primitivescalar-values-numbers-booleans-null-etc + // To handle this, we use a transformer to avoid scattering parsing logic across multiple filters. + decoder: ( + str: string, + defaultDecoder: qs.defaultDecoder, + charset: string, + type: 'key' | 'value', + ) => { + if (type === 'value') { + const num = Number(str); + if (!isNaN(num)) { + return num; + } + + switch (str) { + case 'true': + return true; + case 'false': + return false; + case 'null': + return null; + case 'undefined': + return undefined; + } + } + + return defaultDecoder(str, defaultDecoder, charset); + }, + }) || {}; + + const rules = (Array.isArray(parsed) ? parsed : Object.values(parsed)) + .filter(isQueryStringFilterRuleset) + .map( + (r: ListQueryStringFilterObject[]) => { + if (!Array.isArray(r)) { + r = [r]; + } + const signatures = r.map( + ({ n, o, v }: ListQueryStringFilterObject) => ({ + field: n, + operator: o, + value: v, + }), + ); + + const isSignaturesInvalid = signatures.some((s) => { + const fieldExist = + Object.keys(properties).includes(s.field) || + s.operator === FULL_TEXT_SLUG; + const operatorIsValid = + s.operator != null && + typeof s.operator === 'string' && + s.operator?.split(' ').length === 1; + return !fieldExist || !operatorIsValid; + }); + + // In case of invalid signatures, remove search params to avoid Errors. + if (isSignaturesInvalid && typeof history === 'function') { + history({ search: '' }, { replace: true }); + } + + if (signatures[0].operator === FULL_TEXT_SLUG) { + // TODO: listFilterQuery serializes the already escaped value and this + // then re-escapes while de-serializing. Fix that loop, which can keep + // escaping regex characters (eg \) indefinitely on each call/reload from the url. + return createFullTextSearchFilter( + schema, + String(signatures[0].value), + ); + } + return createFilter(schema, signatures); + }, + // TODO: createFilter should handle this case as well. + ) + .filter((f): f is JSONSchema => !!f); + return rules; +}; + +interface PersistentFiltersProps extends FiltersProps { + history: unknown; +} + +export const PersistentFilters = ({ + schema, + views, + filters, + onViewsChange, + onFiltersChange, + history, + onSearch, + ...otherProps +}: PersistentFiltersProps & + Required>) => { + const { state: analytics } = useAnalyticsContext(); + const { pathname, search } = document.location; + const storedFilters = React.useMemo(() => { + return loadRulesFromUrl(search, schema, history); + }, [search, schema, history]); + + const onFiltersUpdate = React.useCallback( + (updatedFilters: JSONSchema[]) => { + // Get filter query in two steps: first parse the filters, then stringify outside the function for performance + const parsedFilters = listFilterQuery(updatedFilters, false); + const filterQuery = qs.stringify(parsedFilters, { + strictNullHandling: true, + }); + if (typeof history === 'function') { + history( + { + pathname, + search: filterQuery, + }, + { replace: true }, + ); + } + + onFiltersChange?.(updatedFilters); + + if (filterQuery !== search.substring(1)) { + analytics.webTracker?.track('Update table filters', { + current_url: location.origin + location.pathname, + // Need to reduce to a nested object instead of nested array for Amplitude to pick up on the property + filters: Object.assign({}, parsedFilters), + }); + } + }, + [onFiltersChange, analytics.webTracker, history, pathname, search], + ); + + // When the component mounts, filters from the page URL, + // then communicate them back to the parent component. + React.useEffect(() => { + // Make sure we only call onFiltersUpdate on mount once, even if + // we are rendering each part of the Filter component separately. + const normalizedRenderMode = new Set( + Array.isArray(otherProps.renderMode) + ? otherProps.renderMode + : [otherProps.renderMode], + ); + + if (normalizedRenderMode.has('all') || normalizedRenderMode.has('add')) { + onFiltersUpdate?.(storedFilters); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + ); +}; diff --git a/src/components/RJST/Filters/utils.ts b/src/components/RJST/Filters/utils.ts new file mode 100644 index 00000000..6e59cc45 --- /dev/null +++ b/src/components/RJST/Filters/utils.ts @@ -0,0 +1,268 @@ +import type { + JSONSchema7 as JSONSchema, + JSONSchema7Definition as JSONSchemaDefinition, +} from 'json-schema'; +import { + convertRefSchemeToSchemaPath, + getRefSchemeTitle, + getRefSchemePrefix, + isJSONSchema, + parseDescription, + parseDescriptionProperty, +} from '../schemaOps'; +import isEmpty from 'lodash/isEmpty'; +import get from 'lodash/get'; + +const X_FOREIGN_KEY_SCHEMA_SEPARATOR = '___ref_scheme_separator_'; + +export const removeFieldsWithNoFilter = (schema: JSONSchema): JSONSchema => { + const processProperties = ( + properties: JSONSchema['properties'] | undefined, + parentXNoFilterSet?: Set, + ): JSONSchema['properties'] | undefined => { + if (!properties) { + return undefined; + } + + const newProperties: JSONSchema['properties'] = {}; + for (const [key, value] of Object.entries(properties)) { + // if boolean keep boolean values as is + if (typeof value === 'boolean') { + newProperties[key] = value; + continue; + } + + // Apply removal logic if parent has defined an array x-no-filter for its children + // TODO: This only works with immediate children and NOT with nested properties. + if (parentXNoFilterSet?.has(key)) { + continue; + } + // Extract x-no-filter if available + const xNoFilter = parseDescriptionProperty(value, 'x-no-filter'); + + if (xNoFilter === true) { + // Exclude property entirely if xNoFilter is true + continue; + } + + const newValue: JSONSchemaDefinition = { ...value }; + const xNoFilterSet = Array.isArray(xNoFilter) + ? new Set(xNoFilter) + : undefined; + + if ('properties' in value) { + newValue.properties = processProperties(value.properties, xNoFilterSet); + } + + // we are not considering the case where items is an array. Should be added if necessary + if ( + value.items && + typeof value.items === 'object' && + !Array.isArray(value.items) + ) { + if ('properties' in value.items) { + newValue.items = { + ...value.items, + properties: processProperties(value.items.properties, xNoFilterSet), + }; + } + } + + const hasEmptyProperties = + newValue.properties && isEmpty(newValue.properties); + + const hasEmptyItemsProperties = + isJSONSchema(newValue.items) && + 'properties' in newValue.items && + isEmpty(newValue.items.properties); + + if (hasEmptyProperties || hasEmptyItemsProperties) { + continue; + } + + newProperties[key] = newValue; + } + + return newProperties; + }; + + if (schema.properties) { + schema = { + ...schema, + properties: processProperties(schema.properties), + }; + } + return schema; +}; + +/** + * Constructs a schema or modifies properties of an existing schema based on a reference scheme path. + * This function recursively applies changes to nested properties or items based on the transformed reference scheme. + * + * @param schemaOrProperties - The schema or the properties part of a schema to modify. + * @param transformedXRefScheme - An array of property names extracted from a reference scheme, indicating the path through the schema. + * @param description - Optional new description to apply to the schema at the specified path. + * @param title - Optional new title to apply to the schema at the specified path; defaults to existing title if not provided. + * @returns A new schema definition with modifications applied as per the reference scheme path. + */ +export const constructSchemaProperties = ( + schemaOrProperties: JSONSchemaDefinition, + transformedXRefScheme: string[] | undefined, + description?: string, + title?: string, +): JSONSchemaDefinition | null => { + // Return the original schema if there is nothing to transform or no path is provided. + if ( + schemaOrProperties == null || + !transformedXRefScheme?.length || + !isJSONSchema(schemaOrProperties) + ) { + return schemaOrProperties; + } + + // Deconstruct the transformedXRefScheme into the first key and the rest of the path. + const [firstRefSchemeKey, ...restRefScheme] = transformedXRefScheme; + + // If the current level is a JSON schema and has properties || items definition, apply the transformation to them. + if (schemaOrProperties.properties || schemaOrProperties.items) { + return { + ...schemaOrProperties, + title: title ?? schemaOrProperties.title, // Use provided title or default to existing title. + description, // Apply the new description. + [schemaOrProperties.properties ? 'properties' : 'items']: + constructSchemaProperties( + schemaOrProperties.properties ?? + (schemaOrProperties.items as JSONSchema), + restRefScheme, + description, + ), // Recurse into properties/items. + }; + } + + // If the first key of the reference scheme is present, process this specific property. + if (firstRefSchemeKey) { + const property = ( + schemaOrProperties as NonNullable + )[firstRefSchemeKey as keyof typeof schemaOrProperties]; + + // skip the case were the property has been removed in a previous step + if (!property) { + return null; + } + + // Recursively construct properties for the specific key found in the ref scheme. + return { + [firstRefSchemeKey]: constructSchemaProperties( + property, + restRefScheme, + description, + title, + ), + }; + } + + // Return the schema unchanged if none of the above conditions apply. + return schemaOrProperties; +}; + +/** + * Modifies a schema to apply special processing rules, such as handling ref schemes. + */ +export const modifySchemaWithRefSchemes = (schema: JSONSchema): JSONSchema => { + const applyRefSchemeModifications = ( + properties: JSONSchema['properties'] | undefined, + ): JSONSchema['properties'] | undefined => { + if (!properties) { + return undefined; + } + + const newProperties: JSONSchema['properties'] = {}; + for (const [key, value] of Object.entries(properties)) { + if (typeof value === 'boolean') { + newProperties[key] = value; + continue; + } + const description = parseDescription(value); + const refScheme = + description?.['x-foreign-key-scheme'] ?? description?.['x-ref-scheme']; + delete description?.['x-foreign-key-scheme']; + delete description?.['x-ref-scheme']; + + if (refScheme?.length) { + for (const xRefScheme of refScheme) { + const transformedXRefScheme = + convertRefSchemeToSchemaPath(xRefScheme); + const title = getRefSchemeTitle(transformedXRefScheme, value); + const entireRefScheme = + getRefSchemePrefix(value) + transformedXRefScheme; + // In case we pass a x-no-filter in a x-foreign-key-scheme and this property has been filtered out, we just continue + const propertyExist = get(properties, `${key}.${entireRefScheme}`); + if (!propertyExist) { + continue; + } + if (transformedXRefScheme && description) { + description['x-ref-scheme'] = [transformedXRefScheme]; + } + const propertyKey = + refScheme.length > 1 + ? `${key}${X_FOREIGN_KEY_SCHEMA_SEPARATOR}${xRefScheme}` + : key; + const property = constructSchemaProperties( + value, + entireRefScheme?.split('.'), + JSON.stringify(description), + title, + ); + if (!property) { + continue; + } + newProperties[propertyKey] = property; + } + } else { + newProperties[key] = value; + } + } + + return newProperties; + }; + + return schema.properties + ? { ...schema, properties: applyRefSchemeModifications(schema.properties) } + : schema; +}; + +/** + * Removes separators added to denote x-ref-scheme modifications from filter names. + */ +export const removeRefSchemeSeparatorsFromFilters = ( + filters: JSONSchema[], +): JSONSchema[] => { + const recursivelyRemoveSeparators = (filter: JSONSchema): JSONSchema => { + return { + ...filter, + anyOf: filter.anyOf?.map((f) => { + if (isJSONSchema(f) && f.anyOf) { + return recursivelyRemoveSeparators(f); + } + if (!isJSONSchema(f) || !f.properties) { + return f; + } + + return { + ...f, + properties: Object.fromEntries( + Object.entries(f.properties).map(([key, value]) => { + const newKey = key.split(X_FOREIGN_KEY_SCHEMA_SEPARATOR)[0]; + return [newKey, value]; + }), + ), + required: f.required?.map( + (r) => r.split(X_FOREIGN_KEY_SCHEMA_SEPARATOR)[0], + ), + }; + }), + }; + }; + + return filters.map(recursivelyRemoveSeparators); +}; diff --git a/src/components/RJST/Lenses/LensSelection.tsx b/src/components/RJST/Lenses/LensSelection.tsx new file mode 100644 index 00000000..20a4f87d --- /dev/null +++ b/src/components/RJST/Lenses/LensSelection.tsx @@ -0,0 +1,46 @@ +import type { LensTemplate } from '.'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useTranslation } from '../../../hooks/useTranslations'; +import { ToggleButton, ToggleButtonGroup } from '@mui/material'; + +interface LensSelectionProps { + lenses: LensTemplate[]; + lens: LensTemplate; + setLens: (lens: LensTemplate) => void; +} + +export const LensSelection = ({ + lenses, + lens, + setLens, +}: LensSelectionProps) => { + const { t } = useTranslation(); + if (lenses.length <= 1) { + return null; + } + return ( + + {lenses.map((item) => { + return ( + { + setLens(item); + }} + > + + + ); + })} + + ); +}; diff --git a/src/components/RJST/Lenses/index.tsx b/src/components/RJST/Lenses/index.tsx new file mode 100644 index 00000000..08b5a662 --- /dev/null +++ b/src/components/RJST/Lenses/index.tsx @@ -0,0 +1,40 @@ +import * as types from './types'; +import type { IconProp } from '@fortawesome/fontawesome-svg-core'; +import uniq from 'lodash/uniq'; + +export interface LensTemplate { + slug: string; + name: string; + data: { + label: string; + format: string; + renderer: ( + props: types.CollectionLensRendererProps, + ) => React.ReactElement | null; + icon: IconProp; + }; + default?: boolean; +} + +const lenses: LensTemplate[] = Object.values(types); + +// Returns an array of lenses that can be used to render `data`. +export const getLenses = ( + data: T[] | undefined, + customLenses?: Array>, +) => { + if (!data) { + return; + } + + const concatenatedLenses: Array> = lenses.concat( + customLenses ?? [], + ); + + const slugs = concatenatedLenses.map((lens) => lens.slug); + if (slugs.length > uniq(slugs).length) { + throw new Error('Lenses must have unique slugs'); + } + + return concatenatedLenses; +}; diff --git a/src/components/RJST/Lenses/types/index.ts b/src/components/RJST/Lenses/types/index.ts new file mode 100644 index 00000000..112530a1 --- /dev/null +++ b/src/components/RJST/Lenses/types/index.ts @@ -0,0 +1,38 @@ +import type { RJSTEntityPropertyDefinition } from '../../'; +import type { RJSTContext, RJSTModel } from '../../schemaOps'; +import type { + CheckedState, + Pagination, + TableSortOptions, +} from '../../components/Table/utils'; +import type { BoxProps } from '@mui/material'; +export { table } from './table'; + +export interface LensRendererBaseProps + extends Pick { + properties: Array>; + rjstContext: RJSTContext; + model: RJSTModel; + hasUpdateActions: boolean; + onEntityClick?: ( + entity: T, + event: React.MouseEvent, + ) => void; +} + +export interface CollectionLensRendererProps + extends LensRendererBaseProps { + filtered: T[]; + selected?: Array>; + checkedState?: CheckedState; + sort: TableSortOptions | null; + changeSelected: ( + selected: T[] | undefined, + allChecked?: CheckedState, + ) => void; + data: T[] | undefined; + onPageChange?: (page: number, itemsPerPage: number) => void; + onSort?: (sort: TableSortOptions) => void; + pagination: Pagination; + rowKey?: keyof T; +} diff --git a/src/components/RJST/Lenses/types/table.tsx b/src/components/RJST/Lenses/types/table.tsx new file mode 100644 index 00000000..b469a80b --- /dev/null +++ b/src/components/RJST/Lenses/types/table.tsx @@ -0,0 +1,277 @@ +import React from 'react'; +import { faTable } from '@fortawesome/free-solid-svg-icons/faTable'; +import type { LensTemplate } from '..'; +import type { CollectionLensRendererProps } from '.'; +import { DEFAULT_ITEMS_PER_PAGE, getFromLocalStorage } from '../../utils'; +import { Table } from '../../components/Table'; +import type { TableSortOptions, TagField } from '../../components/Table/utils'; +import { + TAG_COLUMN_PREFIX, + useColumns, +} from '../../components/Table/useColumns'; +import { useTagActions } from '../../components/Table/useTagActions'; +import { AddTagHandler } from '../../components/Table/AddTagHandler'; +import { Box, styled, Tooltip, Typography } from '@mui/material'; +import { color } from '@balena/design-tokens'; +import { Copy } from '../../../Copy'; +import type { RJSTEntityPropertyDefinition } from '../..'; +import { useAnalyticsContext } from '../../../../contexts/AnalyticsContext'; + +const TagContainer = styled(Box)(() => ({ + border: `0.5px solid ${color.border.accent.value}`, + borderRadius: '3px', + color: color.text.value, + backgroundColor: color.bg.accent.value, + position: 'relative', + width: 'fit-content', +})); + +export interface TagLabelProps { + value: string; +} + +export const TagLabel = ({ value }: TagLabelProps) => { + return value ? ( + + + + + + {value} + + + + + + ) : ( + no value + ); +}; + +const tagKeyRender = (key: string) => { + function renderFunction(tags?: Array<{ tag_key: string; value: string }>) { + if (!tags?.length) { + return null; + } + const tag = tags.find((t) => t.tag_key === key); + return tag ? : null; + } + + return renderFunction; +}; + +const getResourceTags = ( + item: T, + tagField: P, +) => (tagField in item ? (item[tagField] as TagField[]) : null); + +const findTagOfTaggedResource = ( + taggedResource: T, + tagField: keyof T, + tagKey: string, +) => + getResourceTags(taggedResource, tagField)?.find( + (tag) => tag.tag_key === tagKey, + ); + +const sortData = ( + data: T[], + columns: Array>, + sort: TableSortOptions | null, +): T[] => { + if (!sort?.field) { + return data; + } + + const column = columns.find((c) => c.key === sort.key); + if (!column) { + return data; + } + + const sortedData = [...data]; + const sortDirectionMultiplier = sort.direction === 'desc' ? -1 : 1; + + const { sortable, key, title, field } = column; + + if ('sortable' in column && typeof sortable === 'function') { + sortedData.sort((a, b) => sortDirectionMultiplier * sortable(a, b)); + } else if (key.startsWith(TAG_COLUMN_PREFIX)) { + if (title) { + sortedData.sort((a, b) => { + const item1tag = findTagOfTaggedResource(a, field as keyof T, title); + const item2tag = findTagOfTaggedResource(b, field as keyof T, title); + + if (!item1tag && !item2tag) { + return 0; + } + if (!item1tag) { + return sortDirectionMultiplier; + } + if (!item2tag) { + return -sortDirectionMultiplier; + } + + return ( + sortDirectionMultiplier * + (item1tag.value || '').localeCompare(item2tag.value || '') + ); + }); + } + } else { + sortedData.sort((a, b) => { + const aValue = a[sort.field as keyof T]; + const bValue = b[sort.field as keyof T]; + + if (aValue < bValue) { + return -sortDirectionMultiplier; + } + if (aValue > bValue) { + return sortDirectionMultiplier; + } + return 0; + }); + } + + return sortedData; +}; + +const TableRenderer = ({ + filtered, + selected, + properties, + hasUpdateActions, + checkedState, + changeSelected, + data, + rjstContext, + onEntityClick, + onPageChange, + onSort, + pagination, + model, + sort, + rowKey = 'id', +}: CollectionLensRendererProps) => { + const { state: analytics } = useAnalyticsContext(); + const [columns, setColumns] = useColumns( + rjstContext.resource, + properties, + tagKeyRender, + ); + + const { actions, showAddTagDialog, setShowAddTagDialog, tagKeys } = + useTagActions(rjstContext, data); + + const memoizedPagination = React.useMemo( + () => ({ + itemsPerPage: pagination?.itemsPerPage ?? DEFAULT_ITEMS_PER_PAGE, + currentPage: pagination?.currentPage ?? 0, + totalItems: pagination?.totalItems, + serverSide: !!pagination?.serverSide, + }), + [pagination], + ); + + const sortedData = React.useMemo(() => { + if (pagination.serverSide) { + return filtered; + } + return sortData(filtered, columns, sort); + }, [pagination.serverSide, filtered, columns, sort]); + + const order = React.useMemo(() => { + if (sort) { + return sort; + } + const sortPreferences = getFromLocalStorage( + `${model.resource}__sort`, + ); + + const sortPref = + sortPreferences ?? + ({ + direction: 'asc', + ...columns[0], + } as TableSortOptions); + + onSort?.(sortPref); + + return sortPref; + }, [sort, model, columns, onSort]); + + const handleAddTagClose = (selectedTagColumns: string[] | undefined) => { + if (!selectedTagColumns?.length) { + setShowAddTagDialog(false); + return; + } + const additionalColumns = selectedTagColumns.map((key: string) => { + return { + title: key, + label: `Tag: ${key}`, + key: `${TAG_COLUMN_PREFIX}${key}`, + selected: true, + type: 'predefined', + field: rjstContext.tagField, + sortable: pagination.serverSide ? false : true, + priority: '', + render: tagKeyRender(key), + } as RJSTEntityPropertyDefinition; + }); + setColumns(columns.concat(additionalColumns)); + setShowAddTagDialog(false); + }; + + return ( + <> + { + setColumns(updatedPreferences); + const columnsAnalyticsObject = Object.fromEntries( + updatedPreferences.map((col) => [col.field, col.selected]), + ); + analytics.webTracker?.track('Update table column display', { + current_url: location.origin + location.pathname, + resource: model.resource, + columns: columnsAnalyticsObject, + }); + }} + actions={actions ?? []} + /> + {showAddTagDialog && tagKeys?.length && ( + + )} + + ); +}; + +export const table: LensTemplate = { + slug: 'table', + name: 'Default table lens', + data: { + label: 'Table', + format: 'table', + renderer: TableRenderer, + icon: faTable, + }, +}; diff --git a/src/components/RJST/NoRecordsFoundView.tsx b/src/components/RJST/NoRecordsFoundView.tsx new file mode 100644 index 00000000..dc05daef --- /dev/null +++ b/src/components/RJST/NoRecordsFoundView.tsx @@ -0,0 +1,70 @@ +import type { + ActionData, + RJSTBaseResource, + RJSTContext, + RJSTModel, +} from './schemaOps'; +import { Create } from './Actions/Create'; +import type { NoDataInfo } from '.'; +import { useTranslation } from '../../hooks/useTranslations'; +import { Container, Typography } from '@mui/material'; +import { Callout } from '../Callout'; +import { MUILinkWithTracking } from '../MUILinkWithTracking'; + +export interface NoRecordsFoundViewProps { + model: RJSTModel; + rjstContext: RJSTContext; + onActionTriggered: (data: ActionData) => void; + noDataInfo?: NoDataInfo; +} + +export const NoRecordsFoundView = >({ + model, + rjstContext, + onActionTriggered, + noDataInfo, +}: NoRecordsFoundViewProps) => { + const { t } = useTranslation(); + return ( + + + {noDataInfo?.title ?? + t('no_data.no_resource_data_title', { + resource: t(`resource.${model.resource}_other`).toLowerCase(), + })} + + {noDataInfo?.subtitle && ( + {noDataInfo?.subtitle} + )} + {noDataInfo?.info && ( + + {noDataInfo.info} + + )} + + {noDataInfo?.description ?? t('no_data.no_resource_data_description')} + + + {noDataInfo?.docsLink && ( + + {noDataInfo.docsLabel ?? noDataInfo.docsLink} + + )} + + ); +}; diff --git a/src/components/RJST/components/Filters/FilterDescription.tsx b/src/components/RJST/components/Filters/FilterDescription.tsx new file mode 100644 index 00000000..b6e460d4 --- /dev/null +++ b/src/components/RJST/components/Filters/FilterDescription.tsx @@ -0,0 +1,107 @@ +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import * as React from 'react'; +import { + FULL_TEXT_SLUG, + parseFilterDescription, + type FilterDescription as SieveFilterDescription, +} from './SchemaSieve'; +import { isDateTimeFormat } from '../../DataTypes'; +import isEqual from 'lodash/isEqual'; +import { findInObject } from '../../utils'; +import { isJSONSchema } from '../../schemaOps'; +import type { TagItem, TagProps } from '../../../Tag'; +import { Tag } from '../../../Tag'; +import { format as dateFormat } from 'date-fns'; + +const transformToReadableValue = ( + parsedFilterDescription: SieveFilterDescription, +): string => { + const { schema, value } = parsedFilterDescription; + if (schema && isDateTimeFormat(schema.format)) { + return dateFormat(value, 'PPPppp'); + } + const schemaEnum: JSONSchema['enum'] = findInObject(schema, 'enum'); + const schemaEnumNames: string[] | undefined = findInObject( + schema, + 'enumNames', + ); + if (schemaEnum && schemaEnumNames) { + const index = schemaEnum.findIndex((a) => isEqual(a, value)); + return schemaEnumNames[index]; + } + + const oneOf: JSONSchema['oneOf'] = findInObject(schema, 'oneOf'); + if (oneOf) { + const selected = oneOf.find( + (o) => isJSONSchema(o) && isEqual(o.const, value), + ); + + return isJSONSchema(selected) && selected.title ? selected.title : value; + } + + if (typeof value === 'object') { + if (Object.keys(value).length > 1) { + return Object.entries(value) + .map(([key, v]) => { + const property = schema.properties?.[key]; + return isJSONSchema(property) + ? `${property.title ?? key}: ${v}` + : `${key}: ${v}`; + }) + .join(', '); + } + return Object.values(value)[0] as string; + } + + return String(value); +}; + +export interface FilterDescriptionProps extends Omit { + filter: JSONSchema; +} + +export const FilterDescription = ({ + filter, + ...props +}: FilterDescriptionProps) => { + const tagProps = React.useMemo(() => { + if (filter.title === FULL_TEXT_SLUG) { + const parsedFilterDescription = parseFilterDescription(filter); + if (!parsedFilterDescription) { + return; + } + return parsedFilterDescription + ? [ + { + name: parsedFilterDescription.field, + operator: 'contains', + value: transformToReadableValue(parsedFilterDescription), + }, + ] + : undefined; + } + + return filter.anyOf + ?.map((f, index) => { + if (!isJSONSchema(f)) { + return; + } + const parsedFilterDescription = parseFilterDescription(f); + if (!parsedFilterDescription) { + return; + } + const value = transformToReadableValue(parsedFilterDescription); + return { + name: + parsedFilterDescription?.schema?.title ?? + parsedFilterDescription.field, + operator: parsedFilterDescription.operator, + value, + prefix: index > 0 ? 'or' : undefined, + }; + }) + .filter((f) => Boolean(f)); + }, [filter]) as TagItem[]; + + return tagProps ? : null; +}; diff --git a/src/components/RJST/components/Filters/FiltersDialog.tsx b/src/components/RJST/components/Filters/FiltersDialog.tsx new file mode 100644 index 00000000..8fc334e6 --- /dev/null +++ b/src/components/RJST/components/Filters/FiltersDialog.tsx @@ -0,0 +1,377 @@ +import React from 'react'; +import { useTranslation } from '../../../../hooks/useTranslations'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import { getDataModel, getPropertySchemaAndModel } from '../../DataTypes'; +import { + createFilter, + createFullTextSearchFilter, + getPropertySchema, + type FormData, +} from './SchemaSieve'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes'; +import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus'; +import validator from '@rjsf/validator-ajv8'; +import type { ArrayFieldTemplateProps } from '@rjsf/utils'; +import { + Box, + Button, + DialogContent, + IconButton, + Typography, +} from '@mui/material'; +import { SelectWidget } from '../../../Form/Widgets/SelectWidget'; +import { findInObject } from '../../utils'; +import { getRefSchema, isJSONSchema } from '../../schemaOps'; +import type { IChangeEvent } from '@rjsf/core'; +import { DialogWithCloseButton } from '../../../DialogWithCloseButton'; +import { RJSForm } from '../../../Form'; + +const ArrayFieldTemplate: React.FC = ({ + items, + canAdd, + onAddClick, +}) => { + const { t } = useTranslation(); + return ( + <> + {items?.map((element, index) => { + return ( + + {index > 0 && ( + + {t('commons.or').toUpperCase()} + + )} + .form-group.field.field-object > .MuiFormControl-root > .MuiGrid-root.MuiGrid-container.MuiGrid-spacing-xs-2': + { + marginTop: '-8px!important', + }, + }} + > + {element.children} + + {index !== 0 && ( + + + + )} + + + + {canAdd && index === items.length - 1 && ( + + )} + + + ); + })} + + ); +}; + +const widgets = { + CheckboxWidget: SelectWidget, +}; + +const initialFormData = [ + { + field: undefined, + operator: undefined, + value: undefined, + }, +]; + +const getDefaultValue = (data?: FormData, propertySchema?: JSONSchema) => { + if (data?.value !== undefined) { + return data.value; + } + if (!propertySchema) { + return undefined; + } + const schemaEnum = findInObject(propertySchema, 'enum'); + const schemaOneOf = findInObject(propertySchema, 'oneOf'); + + return schemaEnum?.[0] !== undefined + ? schemaEnum?.[0] + : schemaOneOf?.[0]?.const; +}; + +const normalizeFormData = ( + schema: JSONSchema, + + data?: FormData[] | FormData, +) => { + if (!data || !Array.isArray(data)) { + return data; + } + return data.map((d) => { + if (!schema.properties) { + return d; + } + const field = d?.field ?? Object.keys(schema.properties)[0]; + const propertySchema = getPropertySchema(field, schema); + const prefix = + propertySchema?.type === 'array' ? 'items.properties.' : 'properties.'; + const refSchema = propertySchema + ? getRefSchema(propertySchema, prefix) + : propertySchema; + const model = getDataModel(refSchema); + const operator = model + ? (Object.keys(model.operators).find((o) => o === d?.operator) ?? + Object.keys(model.operators)[0]) + : undefined; + + return { + field: d?.field ?? field, + operator, + value: getDefaultValue(d, propertySchema), + }; + }); +}; + +interface FiltersDialogProps { + schema: JSONSchema; + + onClose: ((filter?: JSONSchema | null) => void) | undefined; + + data?: FormData[] | FormData; +} + +export const FiltersDialog = ({ + schema, + data = initialFormData, + onClose, +}: FiltersDialogProps) => { + const { t } = useTranslation(); + const [formData, setFormData] = React.useState< + FormData[] | FormData | undefined + >(normalizeFormData(schema, data)); + // This ensures that validation errors only appear after the user first submit, providing a better user experience. + // See react-jsonschema-form issue #512 for more details: https://github.com/rjsf-team/react-jsonschema-form/issues/512 + const [isFirstValidation, setIsFirstValidation] = React.useState(true); + + const handleChange = React.useCallback( + ({ formData: fData }: IChangeEvent) => { + if (Array.isArray(formData) && formData.length !== fData?.length) { + setIsFirstValidation(true); + } + // Unfortunately we cannot detect which field is changing so we need to set a previous state + // github.com/rjsf-team/react-jsonschema-form/issues/718 + const newFormData = Array.isArray(formData) + ? fData?.map((d, i) => { + if ( + formData?.[i]?.field && + formData[i]?.operator && + (d.field !== formData[i].field || + d.operator !== formData[i].operator) + ) { + setIsFirstValidation(true); + return { ...d, value: undefined }; + } + return d; + }) + : fData; + + setFormData(normalizeFormData(schema, newFormData)); + }, + [setFormData, formData, schema], + ); + + const handleSubmit = ({ + formData: submittedFormData, + }: IChangeEvent) => { + setIsFirstValidation(false); + if (!onClose) { + return; + } + const filters = Array.isArray(submittedFormData) + ? createFilter(schema, submittedFormData) + : submittedFormData?.value + ? createFullTextSearchFilter(schema, submittedFormData.value) + : null; + onClose(filters); + }; + + const internalSchemaAndUiSchema = React.useMemo(() => { + const { properties } = schema; + if (!properties) { + return undefined; + } + + if (!Array.isArray(formData)) { + return { + schema: { + type: 'object', + properties: { + field: { + title: '', + type: 'string', + }, + operator: { + title: '', + type: 'string', + }, + value: { + title: '', + type: 'string', + }, + }, + } as JSONSchema, + uiSchema: { + 'ui:grid': { + field: { xs: 4, sm: 4 }, + operator: { xs: 4, sm: 4 }, + value: { xs: 4, sm: 4 }, + }, + field: { + 'ui:readonly': true, + }, + operator: { + 'ui:readonly': true, + }, + }, + }; + } + + const oneOf = Object.entries(properties) + /* since properties is of type JSONSchemaDefinition = JSONSchema | boolean, + * we need to remove all possible boolean values + */ + .filter(([_k, v]) => isJSONSchema(v)) + .map(([key, property]) => ({ + title: (property as JSONSchema).title, + const: key, + })); + + const uiSchema = { + 'ui:ArrayFieldTemplate': ArrayFieldTemplate, + items: { + 'ui:grid': { + field: { xs: 4, sm: 4 }, + operator: { xs: 4, sm: 4 }, + value: { xs: 4, sm: 4 }, + }, + value: {}, + }, + }; + + return { + schema: { + type: 'array', + minItems: 1, + items: formData.map((fd, index) => { + const schemaField: JSONSchema = { + $id: `filter-dialog-item-${index}`, + title: 'Field', + type: 'string', + oneOf, + }; + const { propertySchema, model } = getPropertySchemaAndModel( + fd?.field ?? oneOf[0].const, + schema, + ); + if (!model || !propertySchema) { + return {}; + } + const rendererSchema = + model.rendererSchema(schemaField, index, propertySchema, fd) ?? {}; + // This if statement is needed to display objects in a nice way. + // Would be nice to find a better way and keep schema and uiSchema separated + if (propertySchema?.type === 'object') { + if (!propertySchema.properties) { + return rendererSchema; + } + uiSchema.items.value = Object.fromEntries( + Object.entries(propertySchema.properties).map(([key, value]) => [ + key, + { + 'ui:title': '', + 'ui:placeholder': (value as JSONSchema).title + '*', + }, + ]), + ); + } + return rendererSchema; + }), + additionalItems: { + $id: 'filter-dialog-additional-item-0', + field: formData?.[0].field, + operator: formData?.[0].operator, + value: undefined, + }, + } as JSONSchema, + uiSchema, + }; + }, [formData, schema]); + + if (!internalSchemaAndUiSchema) { + return null; + } + + return ( + { + setFormData(initialFormData); + onClose?.(); + }} + open + > + + { + setIsFirstValidation(false); + }, + }} + /> + + + ); +}; diff --git a/src/components/RJST/components/Filters/FocusSearch.tsx b/src/components/RJST/components/Filters/FocusSearch.tsx new file mode 100644 index 00000000..2f801a0d --- /dev/null +++ b/src/components/RJST/components/Filters/FocusSearch.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import debounce from 'lodash/debounce'; +import { useHistory } from '../../../../hooks/useHistory'; +import { ajvFilter, createFullTextSearchFilter } from './SchemaSieve'; +import { Box, styled, Typography } from '@mui/material'; +import { convertToPineClientFilter } from '../../oData/jsonToOData'; +import { designTokens, Spinner } from '../../../..'; +import type { RJSTContext, RJSTModel } from '../../schemaOps'; +import { getPropertyScheme } from '../../schemaOps'; +import { DEFAULT_ITEMS_PER_PAGE } from '../../utils'; + +const Focus = styled(Box)(({ theme }) => ({ + flexBasis: '100%', + backgroundColor: 'white', + border: `solid 1px ${designTokens.color.border.subtle.value}`, + maxHeight: '200px', + position: 'absolute', + width: '100%', + zIndex: 1, + borderRadius: '0 0 4px 4px', + overflow: 'hidden', + boxShadow: theme.shadows[8], +})); + +const FocusContent = styled(Box)(() => ({ + maxHeight: '180px', + overflowY: 'auto', + overflowX: 'auto', +})); + +const FocusItem = styled(Box)<{ hasGetBaseUrl: boolean }>( + ({ hasGetBaseUrl }) => ({ + cursor: hasGetBaseUrl ? 'pointer' : 'default', + '&:hover': { + background: designTokens.color.bg.value, // This is the background color MUI Select uses for entities on hover. + }, + }), +); + +interface FocusSearchProps { + searchTerm: string; + filtered: T[]; + rjstContext: RJSTContext; + model: RJSTModel; + rowKey?: keyof T; +} + +export const FocusSearch = ({ + searchTerm, + filtered, + rjstContext, + model, + rowKey = 'id', +}: FocusSearchProps) => { + const history = useHistory(); + const [searchResults, setSearchResults] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + const inputSearch = rjstContext.sdk?.inputSearch; + + // Debounce function to limit the frequency of search term changes + const debouncedSearch = React.useMemo( + () => + debounce(async (searchFilter) => { + setIsLoading(true); + if (inputSearch && searchFilter) { + // Keep the same structure we have on RJST/index.tsx internalOnChange + const pineFilter = convertToPineClientFilter([], searchFilter); + const oData = { + $filter: pineFilter, + // In case of need we can add an infinite scroll logic + $top: DEFAULT_ITEMS_PER_PAGE, + $skip: 0, + }; + const results = await inputSearch({ oData }); + setSearchResults(results); + } else if (searchFilter) { + setSearchResults(ajvFilter(searchFilter, filtered) || []); + } else { + setSearchResults([]); + } + setIsLoading(false); + }, 300), + [inputSearch, filtered], + ); + + React.useEffect(() => { + const filter = createFullTextSearchFilter(model.schema, searchTerm); + void debouncedSearch(filter); + return () => { + debouncedSearch.cancel(); + }; + }, [model.schema, searchTerm, debouncedSearch]); + + const getEntityValue = (entity: T) => { + const property = model.priorities?.primary[0]; + if (!property) { + return null; + } + const schemaProperty = + model.schema.properties?.[ + property as keyof typeof model.schema.properties + ]; + const refScheme = getPropertyScheme(schemaProperty ?? null); + if (!refScheme || typeof schemaProperty === 'boolean') { + return entity[property]; + } + const newEntity = + schemaProperty?.type === 'array' ? entity[property][0] : entity[property]; + if (refScheme.length > 1) { + return refScheme.map((reference) => newEntity?.[reference]).join(' '); + } + return newEntity?.[refScheme[0]] ?? entity[property]; + }; + + return ( + + + {!searchResults?.length ? ( + + no results + + ) : ( + + {searchResults.map((entity) => ( + { + e.preventDefault(); + if (rjstContext.getBaseUrl && typeof history === 'function') { + try { + const url = new URL(rjstContext.getBaseUrl(entity)); + window.open(url.toString(), '_blank'); + } catch { + history(rjstContext.getBaseUrl(entity)); + } + } + }} + hasGetBaseUrl={!!rjstContext.getBaseUrl} + > + + + {getEntityValue(entity)} + + + + ))} + + )} + + + ); +}; diff --git a/src/components/RJST/components/Filters/SchemaSieve.ts b/src/components/RJST/components/Filters/SchemaSieve.ts new file mode 100644 index 00000000..121f00f2 --- /dev/null +++ b/src/components/RJST/components/Filters/SchemaSieve.ts @@ -0,0 +1,257 @@ +import type { + JSONSchema7 as JSONSchema, + JSONSchema7Definition as JSONSchemaDefinition, +} from 'json-schema'; +import { + getAllOperators, + getDataModel, + isDateTimeFormat, +} from '../../DataTypes'; +import ajvKeywords from 'ajv-keywords'; +import addFormats from 'ajv-formats'; +import pickBy from 'lodash/pickBy'; +import Ajv from 'ajv'; +import { enqueueSnackbar } from 'notistack'; + +const ajv = new Ajv(); +ajvKeywords(ajv, ['regexp']); +// TODO: check why is this needed +addFormats(ajv as any); + +export interface FilterDescription { + schema: JSONSchema; + field: string; + operator: string; + value: string; +} + +export const randomUUID = (length = 16) => { + let text = ''; + const possible = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < length; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +}; + +export const FULL_TEXT_SLUG = 'full_text_search'; + +export interface FormData { + field: string | undefined; + operator: string | undefined; + value: string | undefined; +} + +export function ajvFilter( + filters: JSONSchema | JSONSchema[], + collection: T[], +): T[]; +export function ajvFilter( + filters: JSONSchema | JSONSchema[], + collection: Record, +): Record; +export function ajvFilter( + filters: JSONSchema | JSONSchema[], + collection: T[] | Record, +) { + // Remove all schemas that may have been compiled already + ajv.removeSchema(/^.*$/); + const validators = Array.isArray(filters) + ? filters.map((s) => ajv.compile(s)) + : [ajv.compile(filters)]; + if (Array.isArray(collection)) { + return collection.filter((m) => validators.every((v) => v(m))); + } + + return pickBy(collection, (m) => validators.every((v) => v(m))); +} + +// This is a duplicate of RJST models/helpers isJSONSchema function. +// Ideally Filters should not have any rjst notions +const isJSONSchema = ( + value: + | JSONSchema + | JSONSchemaDefinition + | JSONSchemaDefinition[] + | undefined + | null, +): value is JSONSchema => { + return ( + typeof value === 'object' && value !== null && typeof value !== 'boolean' + ); +}; + +export const getPropertySchema = (key: string, schema: JSONSchema) => { + if (!schema.properties) { + return; + } + const propertySchema = schema.properties[key]; + return isJSONSchema(propertySchema) ? propertySchema : undefined; +}; + +export const createModelFilter = ( + propertySchema: JSONSchemaDefinition, + { field, operator, value }: FormData, +) => { + const model = getDataModel(propertySchema); + if ( + !propertySchema || + typeof propertySchema === 'boolean' || + !field || + !operator || + !model + ) { + return; + } + // @ts-expect-error need to improve object and array + // operatorSlug typing and use operator slug instead of string + return model.createFilter(field, operator, value, propertySchema); +}; + +export const createFilter = ( + schema: JSONSchema, + formData: FormData[], +): JSONSchema | undefined => { + const { properties } = schema; + if (!properties) { + return; + } + const anyOf: JSONSchema[] = formData + .map(({ field, operator, value }) => { + if (!field || !operator) { + return {}; + } + const propertySchema = properties[field] as JSONSchema; + const operators = getAllOperators(propertySchema); + const operatorLabel = operators[operator as keyof typeof operators]; + const filter = createModelFilter(propertySchema, { + field, + operator, + value, + }); + if ( + !filter || + typeof filter !== 'object' || + !Object.keys(filter).length + ) { + return {}; + } + return { + $id: randomUUID(), + title: operator, + description: JSON.stringify({ + schema: propertySchema, + field, + operator: operatorLabel, + value, + }), + type: 'object', + ...filter, + }; + }) + .filter((f) => isJSONSchema(f) && f.properties); + + if (anyOf.length < 1) { + enqueueSnackbar({ + key: 'filter-create', + message: + 'An error occurred while creating the filter. If you need assistance, please contact our support team and provide the error details from your browser console.', + variant: 'error', + preventDuplicate: true, + }); + console.error(`ERROR filter creation`, formData, schema); + return; + } + + return { + $id: randomUUID(), + anyOf, + }; +}; + +export const createFullTextSearchFilter = ( + schema: JSONSchema, + term: string, +) => { + if (!isJSONSchema(schema.properties)) { + return; + } + const items: FormData[] = []; + for (const [key, value] of Object.entries(schema.properties)) { + if ( + isJSONSchema(value) && + (value.type?.includes('boolean') || isDateTimeFormat(value.format)) + ) { + continue; + } + items.push({ + field: key, + operator: FULL_TEXT_SLUG, + value: term, + }); + } + const filter = createFilter(schema, items); + return filter + ? { + $id: randomUUID(), + title: FULL_TEXT_SLUG, + description: JSON.stringify({ + field: 'Any field', + operator: FULL_TEXT_SLUG, + value: term, + }), + anyOf: [filter], + } + : null; +}; + +export const convertSchemaToDefaultValue = ( + propertySchema: JSONSchema, +): any => { + if (!isJSONSchema(propertySchema)) { + return null; + } + switch (propertySchema.type) { + case 'string': + return ''; + case 'boolean': + return false; + case 'number': + return 0; + case 'array': + if (isJSONSchema(propertySchema.items)) { + return [convertSchemaToDefaultValue(propertySchema.items)]; + } + return []; + case 'object': + if ( + propertySchema.properties && + typeof propertySchema.properties === 'object' + ) { + const defaultObject: Record = {}; + for (const [key, value] of Object.entries(propertySchema.properties)) { + if (isJSONSchema(value)) { + defaultObject[key] = convertSchemaToDefaultValue(value); + } + } + return defaultObject; + } + return {}; + default: + return null; + } +}; + +export const parseFilterDescription = ( + filter: JSONSchema, +): FilterDescription | undefined => { + if (!filter.description) { + return; + } + try { + return JSON.parse(filter.description); + } catch { + return; + } +}; diff --git a/src/components/RJST/components/Filters/Search.tsx b/src/components/RJST/components/Filters/Search.tsx new file mode 100644 index 00000000..375c6858 --- /dev/null +++ b/src/components/RJST/components/Filters/Search.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch'; +import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes'; +import type { TextFieldProps } from '@mui/material'; +import { IconButton, InputAdornment, TextField } from '@mui/material'; + +interface SearchProps extends TextFieldProps<'standard'> { + placeholder?: string; + value: string; + dark?: boolean; + onEnter?: (event: React.KeyboardEvent) => void; + onChange?: ( + event?: React.ChangeEvent, + ) => void; +} + +export const Search = ({ + placeholder, + value, + onEnter, + onChange, + InputProps, + dark, + ...textFieldProps +}: SearchProps) => { + const onKeyPress = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + onEnter?.(event); + } + }, + [onEnter], + ); + + return ( + + + + ), + ...(value.length > 0 && { + endAdornment: ( + + { + onChange?.(undefined); + }} + > + + + + ), + }), + // TODO remove when we have implemented a dark theme + ...(dark && { + sx: { + color: 'white', + '&::before': { + borderBottom: 'solid rgba(255, 255, 255, 0.6) 1px', + }, + '&:hover:not(.Mui-disabled, .Mui-error)::before': { + borderBottom: 'solid white 1px', + }, + }, + }), + ...InputProps, + }} + {...textFieldProps} + /> + ); +}; diff --git a/src/components/RJST/components/Filters/Summary.tsx b/src/components/RJST/components/Filters/Summary.tsx new file mode 100644 index 00000000..cfd25b4e --- /dev/null +++ b/src/components/RJST/components/Filters/Summary.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import { useTranslation } from '../../../../hooks/useTranslations'; +import { FilterDescription } from './FilterDescription'; +import { DialogWithCloseButton } from '../../../DialogWithCloseButton'; +import type { IChangeEvent } from '../../../Form'; +import { RJSForm } from '../../../Form'; +import { + Box, + Button, + DialogActions, + DialogContent, + Typography, +} from '@mui/material'; + +interface ViewData { + name: string; +} +interface SummaryProps { + filters: JSONSchema[]; + onEdit: (filter: JSONSchema) => void; + onDelete: (filter: JSONSchema) => void; + onClearFilters: () => void; + showSaveView?: boolean; + dark?: boolean; + onSaveView: (viewData: ViewData) => void; +} + +const schema: JSONSchema = { + type: 'object', + properties: { + name: { + title: 'View name', + type: 'string', + }, + }, +}; + +export const Summary = ({ + filters, + showSaveView, + dark, + onClearFilters, + onSaveView, + onEdit, + onDelete, +}: SummaryProps) => { + const { t } = useTranslation(); + const [showViewForm, setShowViewForm] = React.useState(false); + const [viewData, setViewData] = React.useState(); + + return ( + + + + + {t('labels.filter_other')} + + ({filters.length}) + + + {showSaveView && ( + + )} + + + {filters.map((filter, index) => ( + { + onEdit(filter); + }} + onClose={() => { + onDelete(filter); + }} + /> + ))} + + { + setShowViewForm(false); + setViewData(undefined); + }} + > + + { + setViewData(formData); + }} + schema={schema} + formData={viewData} + /> + + + + + + + ); +}; diff --git a/src/components/RJST/components/Filters/Views.tsx b/src/components/RJST/components/Filters/Views.tsx new file mode 100644 index 00000000..a76986f4 --- /dev/null +++ b/src/components/RJST/components/Filters/Views.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { faChartPie } from '@fortawesome/free-solid-svg-icons/faChartPie'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTrash } from '@fortawesome/free-solid-svg-icons/faTrash'; +import { useTranslation } from '../../../../hooks/useTranslations'; +import type { SxProps } from '@mui/material'; +import { + Box, + IconButton, + Typography, + useMediaQuery, + useTheme, +} from '@mui/material'; +import { color } from '@balena/design-tokens'; +import type { DropDownButtonProps } from '../../../DropDownButton'; +import { DropDownButton } from '../../../DropDownButton'; +import { stopEvent } from '../../utils'; +import { Tooltip } from '../../../Tooltip'; + +interface FiltersView { + id: string; + eventName: string; + name: string; + filters: JSONSchema[]; +} + +export interface ViewsProps { + views: FiltersView[] | undefined; + setFilters: (filters: JSONSchema[]) => void; + deleteView: ( + view: FiltersView, + + event?: React.MouseEvent, + ) => void; + dark?: boolean; +} + +// TODO remove when we have implemented a dark theme +const darkStyles: SxProps = { + // Using !important to override disabled styles on the primary variant + backgroundColor: 'white !important', + border: 'none !important', + color: `${color.text.value} !important`, +}; + +export const Views = ({ views, setFilters, deleteView, dark }: ViewsProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const matches = useMediaQuery(theme.breakpoints.up('sm')); + + const memoizedViews = React.useMemo< + DropDownButtonProps['items'] | null + >(() => { + return ( + views?.map((view) => { + return { + ...view, + eventName: `Open saved view`, + children: ( + + + {view.name} + + {view.filters.length}{' '} + {view.filters.length === 1 + ? t('labels.filter_one').toLowerCase() + : t('labels.filter_other').toLowerCase()} + + + | undefined) => { + if (event) { + stopEvent(event); + } + deleteView(view, event); + }} + > + + + + ), + onClick: () => { + const filters = view.filters; + setFilters(filters); + }, + }; + }) ?? null + ); + }, [views, deleteView, setFilters, t]); + + return ( + + + disabled={!memoizedViews?.length} + items={memoizedViews ?? []} + variant={!memoizedViews?.length && dark ? 'contained' : 'outlined'} + color={!memoizedViews?.length && dark ? 'primary' : 'secondary'} + startIcon={matches ? : null} + sx={{ ...(dark && darkStyles) }} + > + {matches ? t('labels.views') : } + + + ); +}; diff --git a/src/components/RJST/components/Filters/index.tsx b/src/components/RJST/components/Filters/index.tsx new file mode 100644 index 00000000..208cbc2b --- /dev/null +++ b/src/components/RJST/components/Filters/index.tsx @@ -0,0 +1,315 @@ +import React from 'react'; +import { useTranslation } from '../../../../hooks/useTranslations'; +import { Search } from './Search'; +import { Views } from './Views'; +import { FiltersDialog } from './FiltersDialog'; +import { + type FormData, + createFullTextSearchFilter, + randomUUID, + FULL_TEXT_SLUG, + parseFilterDescription, +} from './SchemaSieve'; +import { Summary } from './Summary'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faFilter } from '@fortawesome/free-solid-svg-icons/faFilter'; +import { useClickOutsideOrEsc } from '../../../../hooks/useClickOutsideOrEsc'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import { getPropertySchemaAndModel } from '../../DataTypes'; +import { getFromLocalStorage, setToLocalStorage } from '../../utils'; +import { color } from '@balena/design-tokens'; +import { useAnalyticsContext } from '../../../../contexts/AnalyticsContext'; +import type { SxProps } from '@mui/material'; +import { Box, Button, useMediaQuery, useTheme } from '@mui/material'; +import { isJSONSchema } from '../../schemaOps'; + +const getViews = (views?: FiltersView[], viewsRestorationKey?: string) => { + if (views?.length) { + return views; + } + if (viewsRestorationKey) { + return getFromLocalStorage(viewsRestorationKey) ?? []; + } + return []; +}; + +export interface FiltersView { + id: string; + eventName: string; + name: string; + filters: JSONSchema[]; +} + +export type FilterRenderMode = + | 'all' + | 'add' + | 'search' + | 'views' + | 'summary' + | 'summaryWithSaveViews'; + +export interface FiltersProps { + schema: JSONSchema; + dark?: boolean; + filters?: JSONSchema[]; + views?: FiltersView[]; + viewsRestorationKey?: string; + onFiltersChange?: (filters: JSONSchema[]) => void; + onViewsChange?: (views: FiltersView[]) => void; + renderMode?: FilterRenderMode | FilterRenderMode[]; + onSearch?: (searchTerm: string) => React.ReactElement | null; +} + +// TODO remove when we have implemented a dark theme +const darkStyles: SxProps = { + color: color.text.value, + backgroundColor: 'white', + border: 'none', + '&:hover': { + backgroundColor: 'white', + }, +}; + +export const Filters = ({ + schema, + dark, + filters, + views, + viewsRestorationKey, + onFiltersChange, + onViewsChange, + renderMode, + onSearch, +}: FiltersProps) => { + const { t } = useTranslation(); + const { state: analytics } = useAnalyticsContext(); + const theme = useTheme(); + const matches = useMediaQuery(theme.breakpoints.up('sm')); + const [showFilterDialog, setShowFilterDialog] = React.useState(false); + const [showSearchDropDown, setShowSearchDropDown] = React.useState(false); + const [searchString, setSearchString] = React.useState(''); + const [editFilter, setEditFilter] = React.useState(); + const searchBarRef = useClickOutsideOrEsc(() => { + setShowSearchDropDown(false); + }); + const [storedViews, setStoredViews] = React.useState( + getViews(views, viewsRestorationKey), + ); + + React.useEffect(() => { + setStoredViews(getViews(views, viewsRestorationKey)); + }, [views, viewsRestorationKey]); + + const viewsUpdate = React.useCallback( + (newViews: FiltersView[]) => { + if (viewsRestorationKey) { + setToLocalStorage(viewsRestorationKey, newViews); + } + setStoredViews(newViews); + onViewsChange?.(newViews); + + analytics.webTracker?.track('Update table views', { + current_url: location.origin + location.pathname, + ...newViews, + }); + }, + [viewsRestorationKey, setStoredViews, onViewsChange, analytics.webTracker], + ); + + const formData = React.useMemo(() => { + if (!editFilter) { + return; + } + if (editFilter.title === FULL_TEXT_SLUG) { + const description = parseFilterDescription(editFilter); + if (!description) { + return; + } + return { + field: description.field, + operator: 'contains', + value: description.value, + }; + } + return editFilter.anyOf + ?.map((f) => { + if (!isJSONSchema(f)) { + return; + } + const description = parseFilterDescription(f); + if (!description) { + return; + } + const { model } = getPropertySchemaAndModel(description?.field, schema); + + // We need to recalculate the operator, as it is provided as a value in the description instead of as a key. + // TODO: The operator should be passed as an object in the description, not as a value. + const operator = model + ? (Object.entries(model.operators).find( + ([key, value]) => + value === description.operator || key === description.operator, + )?.[0] ?? description.operator) + : description.operator; + return { + field: description.field, + operator, + value: description.value, + }; + }) + .filter((f) => !!f) as FormData[]; + }, [editFilter, schema]); + + const handleFilterChange = React.useCallback( + (filter: JSONSchema | null) => { + if (!filter) { + return; + } + let existingFilters = filters; + if (editFilter && filters) { + existingFilters = filters.filter((f) => f.$id !== editFilter.$id); + } + onFiltersChange?.([...(existingFilters ?? []), filter]); + }, + [filters, onFiltersChange, editFilter], + ); + + const handleDeleteFilter = React.useCallback( + (filter: JSONSchema) => { + onFiltersChange?.(filters?.filter((f) => f.$id !== filter.$id) ?? []); + }, + [filters, onFiltersChange], + ); + + const handleSaveView = React.useCallback( + (name: string) => { + const view: FiltersView = { + id: randomUUID(), + name, + eventName: `Saving filters view ${name}`, + filters: filters ?? [], + }; + const newViews = [...(storedViews ?? []), view]; + viewsUpdate?.(newViews); + }, + [filters, storedViews, viewsUpdate], + ); + + const handleDeleteView = React.useCallback( + (view: FiltersView) => { + viewsUpdate?.(storedViews?.filter((v) => v.id !== view.id) ?? []); + }, + [storedViews, viewsUpdate], + ); + + return ( + <> + + {(!renderMode || renderMode.includes('search')) && ( + + { + if (!e) { + setSearchString(''); + setShowSearchDropDown(false); + return; + } + setShowSearchDropDown(e.target.value !== ''); + setSearchString(e.target.value); + }} + onEnter={(event) => { + event.preventDefault(); + event.stopPropagation(); + if (!searchString) { + return; + } + const filter = createFullTextSearchFilter(schema, searchString); + handleFilterChange(filter ?? null); + setSearchString(''); + }} + value={searchString} + /> + {searchString && showSearchDropDown && onSearch?.(searchString)} + + )} + {(!renderMode || renderMode.includes('add')) && ( + + )} + {(!renderMode || renderMode.includes('views')) && ( + { + onFiltersChange?.(updatedFilters); + }} + deleteView={handleDeleteView} + dark={dark} + /> + )} + + {(!renderMode || + renderMode.includes('summary') || + renderMode.includes('summaryWithSaveViews') || + renderMode.includes('all')) && + !!filters?.length && ( + { + setEditFilter(filter); + }} + onDelete={handleDeleteFilter} + onClearFilters={() => { + onFiltersChange?.([]); + }} + showSaveView={ + !renderMode || + renderMode?.includes('views') || + renderMode?.includes('summaryWithSaveViews') || + renderMode?.includes('all') + } + onSaveView={({ name }) => { + handleSaveView(name); + }} + filters={filters} + dark={dark} + /> + )} + {(showFilterDialog || formData) && ( + { + handleFilterChange(filter ?? null); + setShowFilterDialog(false); + setEditFilter(undefined); + }} + schema={schema} + data={formData} + /> + )} + + ); +}; diff --git a/src/components/RJST/components/Table/AddTagHandler.tsx b/src/components/RJST/components/Table/AddTagHandler.tsx new file mode 100644 index 00000000..f376eb7a --- /dev/null +++ b/src/components/RJST/components/Table/AddTagHandler.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react'; +import { useTranslation } from '../../../../hooks/useTranslations'; +import type { RJSTEntityPropertyDefinition } from '../..'; +import { DialogWithCloseButton } from '../../../DialogWithCloseButton'; +import { + Button, + Checkbox, + DialogActions, + DialogContent, + FormControlLabel, + FormGroup, + Tooltip, + Typography, +} from '@mui/material'; + +interface AddTagHandlerProps { + columns: Array>; + tagKeys: string[]; + onClose: (selectedTagColumns: string[]) => void; +} + +export const AddTagHandler = ({ + columns, + tagKeys, + onClose, +}: AddTagHandlerProps) => { + const { t } = useTranslation(); + const tagColumnSet = React.useMemo( + () => + new Set( + columns + .filter( + (c) => typeof c.label === 'string' && c.label.startsWith('Tag:'), + ) + .map((c) => c.title), + ), + [columns], + ); + const [tagColumns, setTagColumns] = useState(new Set()); + + const handleToggle = React.useCallback( + (tagKey: string) => { + setTagColumns((prevTagColumns) => { + const newSet = new Set(prevTagColumns); + if (tagColumnSet.has(tagKey) || newSet.has(tagKey)) { + newSet.delete(tagKey); + } else { + newSet.add(tagKey); + } + return newSet; + }); + }, + [tagColumnSet], + ); + + return ( + { + onClose([]); + }} + open + > + + + {tagKeys.map((tagKey, i) => ( + + { + handleToggle(tagKey); + }} + /> + } + label={ + + {tagKey} + + } + /> + + ))} + + + + + + + ); +}; diff --git a/src/components/RJST/components/Table/TableActions.tsx b/src/components/RJST/components/Table/TableActions.tsx new file mode 100644 index 00000000..5c10e3fd --- /dev/null +++ b/src/components/RJST/components/Table/TableActions.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCog } from '@fortawesome/free-solid-svg-icons/faCog'; +import type { RJSTEntityPropertyDefinition } from '../..'; +import type { MenuItemProps } from '@mui/material'; +import { + Checkbox, + Divider, + FormControlLabel, + FormGroup, + IconButton, + Menu, + MenuItem, +} from '@mui/material'; +import { color } from '@balena/design-tokens'; + +interface TableActionsProps { + columns: Array>; + actions?: MenuItemProps[]; + onColumnPreferencesChange?: ( + columns: Array>, + ) => void; +} + +export const TableActions = ({ + columns, + actions, + onColumnPreferencesChange, +}: TableActionsProps) => { + const [anchorEl, setAnchorEl] = React.useState(); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(undefined); + }; + const handleColumnSelection = React.useCallback( + (column: RJSTEntityPropertyDefinition) => { + if (!onColumnPreferencesChange) { + return; + } + if (typeof column.label === 'string' && column.label.startsWith('Tag:')) { + onColumnPreferencesChange(columns.filter((c) => c.key !== column.key)); + return; + } + const newColumns = columns.map((c) => + c.key === column.key ? { ...c, selected: !c.selected } : c, + ); + onColumnPreferencesChange(newColumns); + }, + [onColumnPreferencesChange, columns], + ); + return ( + <> + + + + + + {columns.map((column) => ( + + {/* Needed to expand the checkbox click to MenuItem */} + { + handleColumnSelection(column); + }} + checked={column.selected} + /> + } + label={ + typeof column.label === 'string' ? column.label : column.title + } + /> + + ))} + + {actions?.map(({ onClick, ...menuItemProps }, index) => [ + , + { + onClick?.(e); + handleClose(); + }} + />, + ])} + + + ); +}; diff --git a/src/components/RJST/components/Table/TableCell.tsx b/src/components/RJST/components/Table/TableCell.tsx new file mode 100644 index 00000000..f635f697 --- /dev/null +++ b/src/components/RJST/components/Table/TableCell.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import type { RJSTEntityPropertyDefinition } from '../..'; +import { Link } from '@mui/material'; + +export interface TableCellProps { + href: string | undefined; + onRowClick: + | ((entity: T, event: React.MouseEvent) => void) + | undefined; + row: T; + rowKey: keyof T; + column: RJSTEntityPropertyDefinition; + + url: URL | null; +} + +function TableCellComponent({ + href, + onRowClick, + row, + rowKey, + column, + url, +}: TableCellProps) { + return ( + ({ + px: theme.spacing(2), + color: 'inherit', + textDecoration: 'none', + display: 'table-cell', + verticalAlign: 'middle', + whiteSpace: 'nowrap', + boxSizing: 'border-box', + cursor: href ? 'pointer' : 'default', + '&:hover': { + color: 'inherit', + }, + height: '50px', + })} + href={href} + data-key={row[rowKey]} + onClick={(event) => { + onRowClick?.(row, event); + }} + target={url ? '_blank' : undefined} + > + {column.render(row[column.field], row)} + + ); +} + +export const TableCell = React.memo(TableCellComponent) as ( + props: TableCellProps, +) => JSX.Element; diff --git a/src/components/RJST/components/Table/TableHeader.tsx b/src/components/RJST/components/Table/TableHeader.tsx new file mode 100644 index 00000000..165395bc --- /dev/null +++ b/src/components/RJST/components/Table/TableHeader.tsx @@ -0,0 +1,105 @@ +import type * as React from 'react'; +import { visuallyHidden } from '@mui/utils'; +import type { CheckedState, Order } from './utils'; +import type { RJSTEntityPropertyDefinition } from '../..'; +import { + Box, + Checkbox, + TableCell, + TableHead, + TableSortLabel, +} from '@mui/material'; + +interface TableHeaderProps { + columns: Array>; + data: T[]; + isServerSide?: boolean; + numSelected?: number; + checkedState?: CheckedState; + onRequestSort: ( + event: React.MouseEvent, + column: RJSTEntityPropertyDefinition, + ) => void; + onSelectAllClick?: ( + selected: T[] | undefined, + allChecked: CheckedState, + ) => void; + order: Order; + orderBy: string; + rowCount: number; +} + +export const TableHeader = ({ + data, + isServerSide, + columns, + onSelectAllClick, + order, + orderBy, + numSelected, + checkedState, + rowCount, + onRequestSort, +}: TableHeaderProps) => { + return ( + + {onSelectAllClick && ( + + 0 && numSelected === rowCount) || + checkedState === 'all' + } + onChange={() => { + const state = + checkedState === 'some' + ? 'none' + : checkedState === 'all' + ? 'none' + : 'all'; + const clientData = state === 'all' ? data : undefined; + onSelectAllClick?.(isServerSide ? undefined : clientData, state); + }} + inputProps={{ + 'aria-label': 'select all rows', + }} + /> + + )} + {columns.map( + (column, index) => + column.selected && ( + + {column.sortable ? ( + { + onRequestSort(event, column); + }} + > + {column.label ?? column.title} + {orderBy === column.key ? ( + + {order === 'desc' + ? 'sorted descending' + : 'sorted ascending'} + + ) : null} + + ) : ( + (column.label ?? column.title) + )} + + ), + )} + + ); +}; diff --git a/src/components/RJST/components/Table/TableRow.tsx b/src/components/RJST/components/Table/TableRow.tsx new file mode 100644 index 00000000..0233dca8 --- /dev/null +++ b/src/components/RJST/components/Table/TableRow.tsx @@ -0,0 +1,86 @@ +import type * as React from 'react'; +import type { CheckedState } from './utils'; +import { TableCell, type TableCellProps } from './TableCell'; +import type { RJSTEntityPropertyDefinition } from '../..'; +import { + TableCell as MaterialTableCell, + TableRow as MaterialTableRow, + Checkbox, +} from '@mui/material'; + +export interface TableRowProps { + row: T; + rowKey: keyof T; + rowIndex: number; + handleToggleCheck?: (row: T) => ( + event: React.MouseEvent & { + target: { + checked: boolean; + }; + }, + ) => void; + checkedState?: CheckedState; + checked: boolean; + labelId: string; + columns: Array>; + href: string | undefined; + onRowClick: TableCellProps['onRowClick']; + + url: URL | null; +} + +export const TableRow = ({ + row, + rowKey, + handleToggleCheck, + checkedState, + checked, + labelId, + columns, + href, + onRowClick, + url, +}: TableRowProps) => { + return ( + + {handleToggleCheck && ( + + + + )} + {columns.map((c: any, columnIndex: number) => { + return c.selected ? ( + + ) : null; + })} + + ); +}; diff --git a/src/components/RJST/components/Table/TableToolbar.tsx b/src/components/RJST/components/Table/TableToolbar.tsx new file mode 100644 index 00000000..0e4b2be1 --- /dev/null +++ b/src/components/RJST/components/Table/TableToolbar.tsx @@ -0,0 +1,48 @@ +import type { MenuItemProps } from '@mui/material'; +import { Stack, Typography } from '@mui/material'; +import type { RJSTEntityPropertyDefinition } from '../..'; +import { TableActions } from './TableActions'; +import { color } from '@balena/design-tokens'; + +interface TableToolbarProps { + numSelected?: number; + columns: Array>; + actions?: MenuItemProps[]; + onColumnPreferencesChange?: ( + columns: Array>, + ) => void; +} + +export const TableToolbar = ({ + numSelected = 0, + columns, + actions, + onColumnPreferencesChange, +}: TableToolbarProps) => { + return ( + + {numSelected > 0 && ( + ({ + alignSelf: 'flex-end', + px: theme.spacing(2), + py: theme.spacing(1), + borderRadius: '4px 4px 0 0', + background: color.bg.subtle.value, + boxShadow: 'inset 0px -1px 1px rgba(0,0,0,0.05)', + })} + > + {numSelected} selected + + )} + + + ); +}; diff --git a/src/components/RJST/components/Table/index.tsx b/src/components/RJST/components/Table/index.tsx new file mode 100644 index 00000000..51767a80 --- /dev/null +++ b/src/components/RJST/components/Table/index.tsx @@ -0,0 +1,293 @@ +import * as React from 'react'; +import { TableHeader } from './TableHeader'; +import { TableToolbar } from './TableToolbar'; +import type { CheckedState, Pagination, TableSortOptions } from './utils'; + +import { TableRow } from './TableRow'; +import type { MenuItemProps } from '@mui/material'; +import { + styled, + Table as MaterialTable, + TableContainer, + Box, + TableBody, + TablePagination, +} from '@mui/material'; +import type { RJSTEntityPropertyDefinition } from '../..'; +import { useAnalyticsContext } from '../../../../contexts/AnalyticsContext'; +import { DEFAULT_ITEMS_PER_PAGE } from '../../utils'; +import { color } from '@balena/design-tokens'; + +const StyledMaterialTable = styled(MaterialTable)(() => ({ + '& [data-table="table_cell__sticky"]': { + position: 'sticky', + left: 0, + zIndex: 9, + backgroundColor: 'inherit', + }, + '& [data-table="table_cell__sticky_header"]': { + position: 'sticky', + left: 0, + zIndex: 10, + }, +})); + +interface TableProps { + rowKey: keyof T; + data: T[]; + checkedItems?: T[]; + checkedState?: CheckedState; + columns: Array>; + pagination: Pagination; + sort: TableSortOptions; + actions?: MenuItemProps[]; + onCheck?: (selected: T[] | undefined, allChecked?: CheckedState) => void; + onSort?: (sort: TableSortOptions) => void; + getRowHref: ((entry: any) => string) | undefined; + onRowClick?: (entity: T, event: React.MouseEvent) => void; + onPageChange?: (page: number, itemsPerPage: number) => void; + onColumnPreferencesChange?: ( + columns: Array>, + ) => void; +} + +export const Table = ({ + rowKey, + data, + checkedItems = [], + checkedState, + columns, + pagination, + sort, + actions, + onCheck, + onSort, + onRowClick, + getRowHref, + onPageChange, + onColumnPreferencesChange, +}: TableProps) => { + const { state: analytics } = useAnalyticsContext(); + const lastSelected = React.useRef(); + + const totalItems = pagination?.totalItems ?? data.length; + + const numSelected = React.useMemo(() => { + if (checkedState === 'none') { + return 0; + } + if (checkedState === 'all') { + // Using || here ensures that the pineFilter case works correctly, + // as checkedItems is an empty array in this scenario. + return checkedItems?.length || totalItems; + } + return checkedItems?.length; + }, [checkedState, checkedItems, totalItems]); + + const checkedRowsMap = React.useMemo(() => { + if (!rowKey || !checkedItems?.length) { + return new Map(); + } + return new Map(checkedItems.map((x) => [x[rowKey], x])); + }, [checkedItems, rowKey]); + + const isChecked = React.useCallback( + (item: T) => { + const identifier = item[rowKey]; + return checkedRowsMap.has(identifier); + }, + [checkedRowsMap, rowKey], + ); + + const visibleRows = React.useMemo( + () => + data.length > pagination.itemsPerPage + ? data.slice( + pagination.currentPage * pagination.itemsPerPage, + pagination.currentPage * pagination.itemsPerPage + + pagination.itemsPerPage, + ) + : data, + [data, pagination.currentPage, pagination.itemsPerPage], + ); + + const visibleRowsMap = React.useMemo(() => { + return visibleRows + ? new Map(visibleRows.map((row) => [row[rowKey], row])) + : null; + }, [visibleRows, rowKey]); + + const handleOnSort = React.useCallback( + ( + _event: React.MouseEvent, + { key, field, refScheme }: RJSTEntityPropertyDefinition, + ) => { + if (!sort) { + return; + } + const isAsc = sort.key === key && sort.direction === 'asc'; + const newOrder = isAsc ? 'desc' : 'asc'; + // Passing the entire column is not possible because the label might be an HTML element. + // This can cause errors when attempting to save it to localStorage. + const sortObj: TableSortOptions = { + direction: newOrder, + field: field, + key: key, + refScheme: refScheme, + }; + onSort?.(sortObj); + }, + [onSort, sort], + ); + + const handleToggleCheck = React.useCallback( + (row: T) => { + return ( + event: React.MouseEvent & { + target: { checked: boolean }; + }, + ) => { + if (!visibleRowsMap) { + return; + } + const lastSelectedRow = lastSelected.current; + const isDifferentRowSelected = + lastSelectedRow && lastSelectedRow[rowKey] !== row[rowKey]; + const isShiftClick = + event.shiftKey && + isDifferentRowSelected && + visibleRowsMap.has(lastSelectedRow[rowKey]); + // handle multiple selection + if (isShiftClick) { + let isInSelection = false; + for (const [key, value] of visibleRowsMap) { + if (row[rowKey] === key || lastSelectedRow[rowKey] === key) { + isInSelection = !isInSelection; + } + if ( + (isInSelection || row[rowKey] === key) && + event.target.checked + ) { + checkedRowsMap.set(key, value); + } else if (!event.target.checked) { + checkedRowsMap.delete(key); + } + } + lastSelected.current = undefined; + // Handle select all case + } else if (checkedState === 'all') { + for (const [key, value] of visibleRowsMap) { + if (row[rowKey] === key) { + continue; + } + checkedRowsMap.set(key, value); + } + } else { + // handle single selection + if (event.target.checked) { + checkedRowsMap.set(row[rowKey], row); + } else { + checkedRowsMap.delete(row[rowKey]); + } + lastSelected.current = row; + } + + const filteredArray = Array.from(checkedRowsMap.values()); + onCheck?.(filteredArray, filteredArray.length > 0 ? 'some' : 'none'); + }; + }, + [visibleRowsMap, checkedRowsMap, rowKey, checkedState, onCheck], + ); + + return ( + + + + + + + {visibleRows.map((row, rowIndex) => { + const labelId = `enhanced-table-checkbox-${rowIndex}`; + const checked = isChecked(row); + const href = getRowHref?.(row); + + let url: URL | null; + try { + url = new URL(href ?? ''); + } catch { + url = null; + } + return ( + + ); + })} + + + + { + onPageChange?.(page, pagination.itemsPerPage); + }} + onRowsPerPageChange={(event) => { + const newRowsPerPage = Number(event.target.value); + + onPageChange?.(pagination.currentPage, newRowsPerPage); + + analytics.webTracker?.track('Change table rows per page', { + rowPerPage: newRowsPerPage, + totalItems, + path: window.location.pathname, + }); + }} + /> + + ); +}; diff --git a/src/components/RJST/components/Table/useColumns.ts b/src/components/RJST/components/Table/useColumns.ts new file mode 100644 index 00000000..0a54b104 --- /dev/null +++ b/src/components/RJST/components/Table/useColumns.ts @@ -0,0 +1,58 @@ +import type React from 'react'; +import { useState, useEffect } from 'react'; +import type { TagField } from './utils'; +import type { RJSTEntityPropertyDefinition } from '../..'; +import { getFromLocalStorage, setToLocalStorage } from '../../utils'; + +export const TAG_COLUMN_PREFIX = 'tag_column_'; +// Move columns logic inside a dedicated hook to make the refactor easier and move this logic without effort. +export function useColumns( + resourceName: string, + defaultColumns: Array>, + tagKeyRender: ( + key: string, + ) => (tags: TagField[] | undefined) => React.ReactElement | null, +) { + const [columns, setColumns] = useState(() => { + const storedColumns = getFromLocalStorage< + Array> + >(`${resourceName}__columns`); + if (storedColumns) { + const storedColumnsMap = new Map(storedColumns.map((s) => [s.key, s])); + + const tagColumns = storedColumns.filter((c) => + c.key.startsWith(TAG_COLUMN_PREFIX), + ); + + const cols = [...defaultColumns, ...tagColumns].map((d) => { + const storedColumn = storedColumnsMap.get(d.key); + return { + ...d, + render: d.key.startsWith(TAG_COLUMN_PREFIX) + ? tagKeyRender(d.title) + : d.render, + selected: storedColumn?.selected ?? d.selected, + }; + }); + // Remove incorrectly saved column configurations and handle any structural changes. + const safeFilteredCols = cols.filter( + (c) => typeof c.render === 'function', + ); + + return safeFilteredCols; + } else { + return defaultColumns; + } + }); + useEffect(() => { + setToLocalStorage( + `${resourceName}__columns`, + columns.map((c) => ({ + ...c, + label: typeof c.label === 'string' ? c.label : null, + })), + ); + }, [resourceName, columns]); + + return [columns, setColumns] as const; +} diff --git a/src/components/RJST/components/Table/useTagActions.tsx b/src/components/RJST/components/Table/useTagActions.tsx new file mode 100644 index 00000000..cca76503 --- /dev/null +++ b/src/components/RJST/components/Table/useTagActions.tsx @@ -0,0 +1,60 @@ +import { useMemo, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus'; +import uniq from 'lodash/uniq'; +import type { RJSTContext } from '../../schemaOps'; +import { useQuery } from 'react-query'; +import { Stack } from '@mui/material'; + +export function useTagActions( + rjstContext: RJSTContext, + data: T[] | undefined, +) { + const [showAddTagDialog, setShowAddTagDialog] = useState(false); + + const { data: tagKeys } = useQuery({ + queryKey: ['useTagActions', rjstContext.sdk?.tags, data], + queryFn: async () => { + if (!rjstContext.sdk?.tags || !('getAll' in rjstContext.sdk.tags)) { + return null; + } + const tags = (await rjstContext.sdk.tags.getAll(data)).flatMap( + (d: T) => { + // TODO: check if there is any safer way to get the tag key + const tagKey = Object.keys(d).find((key) => key.endsWith('_tag')); + return tagKey ? d[tagKey as keyof T] : []; + }, + // TODO: improve typing + ) as Array<{ + tag_key: string; + }>; + return uniq(tags.map((tag) => tag.tag_key)) ?? null; + }, + }); + + const actions = useMemo(() => { + if (!rjstContext.tagField) { + return []; + } + + return [ + { + disabled: !tagKeys?.length, + onClick: () => { + if (!tagKeys?.length) { + return []; + } + setShowAddTagDialog(true); + }, + children: ( + + + Add tag column + + ), + }, + ]; + }, [tagKeys, rjstContext.tagField]); + + return { actions, showAddTagDialog, setShowAddTagDialog, tagKeys }; +} diff --git a/src/components/RJST/components/Table/utils.ts b/src/components/RJST/components/Table/utils.ts new file mode 100644 index 00000000..1af10f16 --- /dev/null +++ b/src/components/RJST/components/Table/utils.ts @@ -0,0 +1,22 @@ +export type Order = 'asc' | 'desc'; + +export type CheckedState = 'none' | 'some' | 'all'; + +export interface TableSortOptions { + direction: Order; + field: string; + key: string; + refScheme?: string; +} + +export type Pagination = { + itemsPerPage: number; + serverSide: boolean; + currentPage: number; + totalItems: number; +}; + +export type TagField = { + tag_key: string; + value: string; +}; diff --git a/src/components/RJST/components/Widget/Formats/BooleanAsIconWidget.tsx b/src/components/RJST/components/Widget/Formats/BooleanAsIconWidget.tsx new file mode 100644 index 00000000..d7264ed8 --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/BooleanAsIconWidget.tsx @@ -0,0 +1,19 @@ +import { faCheckCircle } from '@fortawesome/free-solid-svg-icons/faCheckCircle'; +import { faTimesCircle } from '@fortawesome/free-solid-svg-icons/faTimesCircle'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { JsonTypes, widgetFactory } from '../utils'; +import { Box, Typography } from '@mui/material'; + +export const BooleanAsIconWidget = widgetFactory('BooleanAsIcon', {}, [ + JsonTypes.boolean, + JsonTypes.number, + JsonTypes.null, +])(({ value }) => { + const text = value ? 'true' : 'false'; + return ( + + {' '} + {text} + + ); +}); diff --git a/src/components/RJST/components/Widget/Formats/CodeWidget.tsx b/src/components/RJST/components/Widget/Formats/CodeWidget.tsx new file mode 100644 index 00000000..784ac79e --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/CodeWidget.tsx @@ -0,0 +1,16 @@ +import { Tooltip } from '@mui/material'; +import { Copy } from '../../../../Copy'; +import { JsonTypes, widgetFactory } from '../utils'; +import { Code } from '../../../../Code'; + +export const CodeWidget = widgetFactory('Code', {}, [JsonTypes.string])(({ + value, +}) => { + return ( + + + {value} + + + ); +}); diff --git a/src/components/RJST/components/Widget/Formats/DisabledTextWidget.tsx b/src/components/RJST/components/Widget/Formats/DisabledTextWidget.tsx new file mode 100644 index 00000000..045bf73a --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/DisabledTextWidget.tsx @@ -0,0 +1,20 @@ +import { Tooltip, Typography } from '@mui/material'; +import { useTranslation } from '../../../../../hooks/useTranslations'; +import { JsonTypes, widgetFactory } from '../utils'; + +export const DisabledTextWidget = widgetFactory('DisabledText', {}, [ + JsonTypes.string, + JsonTypes.number, + JsonTypes.null, +])(({ value }) => { + const { t } = useTranslation(); + const val = + value != null && typeof value !== 'string' ? value.toString() : value; + return ( + + + {val ?? t('info.not_defined')} + + + ); +}); diff --git a/src/components/RJST/components/Widget/Formats/DurationWidget.tsx b/src/components/RJST/components/Widget/Formats/DurationWidget.tsx new file mode 100644 index 00000000..5b298335 --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/DurationWidget.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { intervalToDuration } from 'date-fns'; +import { JsonTypes, widgetFactory } from '../utils'; +import { Typography } from '@mui/material'; + +export const DurationWidget = widgetFactory('Duration', {}, [JsonTypes.object])( + ({ + value, + }: { + value: { start?: number | Date | null; end?: number | Date | null }; + }) => { + const duration = React.useMemo(() => { + if (!value.start || !value.end) { + return ''; + } + const interval = intervalToDuration({ + start: new Date(value.start), + end: new Date(value.end), + }); + if (!interval) { + return ''; + } + const customInterval: { [key: string]: string } = {}; + for (const [key, intervalValue] of Object.entries(interval)) { + if (intervalValue == null) { + continue; + } + customInterval[key] = + intervalValue < 10 ? `0${intervalValue}` : `${intervalValue}`; + } + let durationText = ''; + if (interval.years) { + durationText += `${customInterval.years}y `; + } + if (durationText.length > 0 || !!interval.months) { + durationText += `${customInterval.months}m `; + } + if (durationText.length > 0 || !!interval.days) { + durationText += `${customInterval.days}d `; + } + customInterval.hours ??= '00'; + customInterval.minutes ??= '00'; + customInterval.seconds ??= '00'; + durationText += `${customInterval.hours}:${customInterval.minutes}:${customInterval.seconds}`; + return durationText; + }, [value.end, value.start]); + + return {duration}; + }, +); diff --git a/src/components/RJST/components/Widget/Formats/ElapsedTimeWidget.tsx b/src/components/RJST/components/Widget/Formats/ElapsedTimeWidget.tsx new file mode 100644 index 00000000..b23d997d --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/ElapsedTimeWidget.tsx @@ -0,0 +1,27 @@ +import { + UiOption, + JsonTypes, + widgetFactory, + formatTimestamp, + timeSince, +} from '../utils'; +import { Material, Tooltip } from '../../../../..'; +const { Typography } = Material; + +export const ElapsedTimeWidget = widgetFactory( + 'ElapsedTime', + { + dtFormat: UiOption.string, + }, + [JsonTypes.string, JsonTypes.number], +)(({ value }) => { + if (!value) { + return null; + } + + return ( + + {timeSince(value)} + + ); +}); diff --git a/src/components/RJST/components/Widget/Formats/HashWidget.tsx b/src/components/RJST/components/Widget/Formats/HashWidget.tsx new file mode 100644 index 00000000..18f00302 --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/HashWidget.tsx @@ -0,0 +1,13 @@ +import { Code } from '@mui/icons-material'; +import { Copy } from '../../../../Copy'; +import { JsonTypes, truncateHash, widgetFactory } from '../utils'; + +export const HashWidget = widgetFactory('Hash', {}, [JsonTypes.string])(({ + value, +}) => { + return ( + + {truncateHash(value)} + + ); +}); diff --git a/src/components/RJST/components/Widget/Formats/PercentageWidget.tsx b/src/components/RJST/components/Widget/Formats/PercentageWidget.tsx new file mode 100644 index 00000000..cb7504a8 --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/PercentageWidget.tsx @@ -0,0 +1,8 @@ +import { JsonTypes, widgetFactory } from '../utils'; + +export const PercentageWidget = widgetFactory('Percentage', {}, [ + JsonTypes.string, + JsonTypes.number, +])(({ value }) => { + return <>{value ? `${value}%` : '-'}; +}); diff --git a/src/components/RJST/components/Widget/Formats/PlaceholderTextWidget.tsx b/src/components/RJST/components/Widget/Formats/PlaceholderTextWidget.tsx new file mode 100644 index 00000000..dcd28938 --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/PlaceholderTextWidget.tsx @@ -0,0 +1,24 @@ +import { Typography } from '@mui/material'; +import { JsonTypes, widgetFactory } from '../utils'; + +export const PlaceholderTextWidget = widgetFactory('PlaceholderText', {}, [ + JsonTypes.string, + JsonTypes.number, + JsonTypes.null, +])(({ value }) => { + const val = + value === null || value === '' + ? 'Empty' + : typeof value !== 'string' + ? value.toString() + : value; + return ( + + {val} + + ); +}); diff --git a/src/components/RJST/components/Widget/Formats/TemperatureWidget.tsx b/src/components/RJST/components/Widget/Formats/TemperatureWidget.tsx new file mode 100644 index 00000000..04b85acc --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/TemperatureWidget.tsx @@ -0,0 +1,7 @@ +import { JsonTypes, widgetFactory } from '../utils'; + +export const TemperatureWidget = widgetFactory('Temperature', {}, [ + JsonTypes.number, +])(({ value }) => { + return <>{value ? `~${value}°C` : '-'}; +}); diff --git a/src/components/RJST/components/Widget/Formats/TxtWidget.tsx b/src/components/RJST/components/Widget/Formats/TxtWidget.tsx new file mode 100644 index 00000000..e640e865 --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/TxtWidget.tsx @@ -0,0 +1,65 @@ +import get from 'lodash/get'; +import invokeMap from 'lodash/invokeMap'; +import isArray from 'lodash/isArray'; +import type { UiSchema, Value } from '../utils'; +import { UiOption, JsonTypes, widgetFactory, formatTimestamp } from '../utils'; +import { Truncate } from '../../../../Truncate'; +import { Typography } from '@mui/material'; + +const getArrayValue = (value: Value[], uiSchema?: UiSchema): string | null => { + // Trim array if the 'truncate' option was provided, + // then comma-join the items into a single string. + const maxItems = get(uiSchema, ['ui:options', 'truncate'], value.length); + if (typeof maxItems !== 'number') { + return ''; + } + let arrayString = invokeMap(value.slice(0, maxItems), 'toString').join(', '); + if (maxItems && maxItems < value.length) { + arrayString += ` and ${value.length - maxItems} more...`; + } + return arrayString; +}; + +const DATE_TIME_FORMATS = ['date-time', 'date', 'time']; + +const TxtWidget = widgetFactory( + 'Txt', + { + dtFormat: UiOption.string, + align: { + ...UiOption.string, + enum: ['inherit', 'left', 'center', 'right', 'justify'], + }, + gutterBottom: UiOption.bolean, + noWrap: UiOption.boolean, + paragraph: UiOption.boolean, + sx: UiOption.object, + variant: UiOption.string, + }, + [ + JsonTypes.string, + JsonTypes.null, + JsonTypes.integer, + JsonTypes.number, + JsonTypes.boolean, + JsonTypes.array, + ], +)(({ value, schema, uiSchema, ...props }) => { + let displayValue = isArray(value) + ? getArrayValue(value as Array>, uiSchema) + : value?.toString(); + if (DATE_TIME_FORMATS.includes(schema?.format ?? '')) { + displayValue = + displayValue != null ? formatTimestamp(displayValue, uiSchema) : ''; + } + const lineCamp = get(uiSchema, ['ui:options', 'lineCamp']); + return typeof lineCamp === 'number' ? ( + + {displayValue ?? ''} + + ) : ( + {displayValue ?? ''} + ); +}); + +export default TxtWidget; diff --git a/src/components/RJST/components/Widget/Formats/WrapWidget.tsx b/src/components/RJST/components/Widget/Formats/WrapWidget.tsx new file mode 100644 index 00000000..142071f4 --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/WrapWidget.tsx @@ -0,0 +1,12 @@ +import { Typography } from '@mui/material'; +import { JsonTypes, widgetFactory } from '../utils'; + +export const WrapWidget = widgetFactory('Wrap', {}, [JsonTypes.string])(({ + value, +}) => { + return ( + + {value} + + ); +}); diff --git a/src/components/RJST/components/Widget/Formats/index.ts b/src/components/RJST/components/Widget/Formats/index.ts new file mode 100644 index 00000000..d26692c8 --- /dev/null +++ b/src/components/RJST/components/Widget/Formats/index.ts @@ -0,0 +1,79 @@ +import TxtWidget from './TxtWidget'; +import { ElapsedTimeWidget } from './ElapsedTimeWidget'; +import { DurationWidget } from './DurationWidget'; +import { HashWidget } from './HashWidget'; +import { TemperatureWidget } from './TemperatureWidget'; +import { PercentageWidget } from './PercentageWidget'; +import { DisabledTextWidget } from './DisabledTextWidget'; +import { BooleanAsIconWidget } from './BooleanAsIconWidget'; +import { PlaceholderTextWidget } from './PlaceholderTextWidget'; +import { WrapWidget } from './WrapWidget'; +import type { Format, Widget } from '../utils'; +import { CodeWidget } from './CodeWidget'; + +type Widgets = { + [key: string]: Widget; +}; + +export const defaultFormats = [ + { + name: 'elapsed-date-time', + format: '.*', + widget: ElapsedTimeWidget, + }, + { + name: 'duration', + format: '.*', + widget: DurationWidget, + }, + { + name: 'code', + format: '.*', + widget: CodeWidget, + }, + { + name: 'hash', + format: '.*', + widget: HashWidget, + }, + { + name: 'temperature', + format: '.*', + widget: TemperatureWidget, + }, + { + name: 'percentage', + format: '.*', + widget: PercentageWidget, + }, + { + name: 'disabled-text', + format: '.*', + widget: DisabledTextWidget, + }, + { + name: 'wrap', + format: '.*', + widget: WrapWidget, + }, + { + name: 'boolean-as-icon', + format: '.*', + widget: BooleanAsIconWidget, + }, + { + name: 'placeholder-text', + format: '.*', + widget: PlaceholderTextWidget, + }, +] as Format[]; + +/* eslint-disable id-denylist */ +export const typeWidgets: Widgets = { + string: TxtWidget, + null: TxtWidget, + integer: TxtWidget, + number: TxtWidget, + boolean: TxtWidget, + default: TxtWidget, +}; diff --git a/src/components/RJST/components/Widget/index.tsx b/src/components/RJST/components/Widget/index.tsx new file mode 100644 index 00000000..8a7bae59 --- /dev/null +++ b/src/components/RJST/components/Widget/index.tsx @@ -0,0 +1,80 @@ +import castArray from 'lodash/castArray'; +import type { Format, UiSchema, Value, WidgetProps } from './utils'; +import { JsonTypes } from './utils'; +import { typeWidgets } from './Formats'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import { getSchemaFormat, getSubSchemaFromRefScheme } from '../../schemaOps'; + +const getValue = (value?: Value, schema?: JSONSchema, uiSchema?: UiSchema) => { + const calculatedValue = uiSchema?.['ui:value']; + // Fall back to schema's default value if value is undefined + return calculatedValue !== undefined + ? calculatedValue + : value !== undefined + ? value + : schema?.default; +}; + +const getType = (value?: Value) => { + if (value === undefined) { + return 'default'; + } + if (value === null) { + return 'null'; + } + return Array.isArray(value) ? 'array' : typeof value; +}; + +const getWidget = ( + value?: Value, + format?: string, + uiSchemaWidget?: UiSchema['ui:widget'], + extraFormats?: Format[], +) => { + const valueType = getType(value); + + const extraFormat = extraFormats?.find( + (exf) => + (exf.name === format || exf.name === uiSchemaWidget) && + exf.widget?.supportedTypes?.includes(valueType), + ); + + return extraFormat?.widget ?? typeWidgets[valueType] ?? typeWidgets.default; +}; + +export const Widget = ({ + value, + extraContext, + schema = {}, + extraFormats, + uiSchema, +}: WidgetProps) => { + const format = getSchemaFormat(schema); + if (!format) { + return <>{value}; + } + + const processedValue = getValue(value, schema, uiSchema); + const subSchema = getSubSchemaFromRefScheme(schema); + const types = subSchema?.type != null ? castArray(subSchema.type) : []; + + if (processedValue == null && !types.includes(JsonTypes.null)) { + return null; + } + + const WidgetComponent = getWidget( + processedValue, + format, + undefined, + extraFormats, + ); + + return ( + + ); +}; diff --git a/src/components/RJST/components/Widget/utils.ts b/src/components/RJST/components/Widget/utils.ts new file mode 100644 index 00000000..92848e94 --- /dev/null +++ b/src/components/RJST/components/Widget/utils.ts @@ -0,0 +1,142 @@ +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import type { UiSchema as rjsfUiSchema } from '@rjsf/utils'; +import get from 'lodash/get'; +import { format, formatDistance } from 'date-fns'; + +const DATE_FORMAT = 'MMM do yyyy'; +const TIME_FORMAT = 'h:mm a'; + +export type DefinedValue = + | number + | string + | boolean + | { [key: string]: any } + | any[] + | undefined; + +export type Value = DefinedValue | null; + +export interface UiSchema extends rjsfUiSchema { + 'ui:title'?: string; + 'ui:description'?: string; + 'ui:value'?: Value; +} + +export interface Format { + name: string; + format: string; + widget?: Widget; +} + +export interface WidgetProps { + value: Value; + schema: JSONSchema | undefined; + extraFormats?: Format[]; + uiSchema?: UiSchema; + extraContext?: T; +} + +export interface Widget { + uiOptions?: UiOptions; + supportedTypes?: string[]; + displayName: string; + (props: WidgetProps & ExtraProps): JSX.Element | null; +} + +/* eslint-disable id-denylist */ +export const JsonTypes = { + array: 'array', + object: 'object', + number: 'number', + integer: 'integer', + string: 'string', + boolean: 'boolean', + null: 'null', +} as const; + +/* eslint-disable id-denylist */ +export interface JsonTypesTypeMap { + array: unknown[]; + object: object; + number: number; + integer: number; + string: string; + boolean: boolean; + null: null; +} + +export type UiOptions = { + [key: string]: JSONSchema; +}; + +// Convenience object for common UI option schemas +export const UiOption: UiOptions = { + string: { + type: 'string', + }, + boolean: { + type: 'boolean', + }, + number: { + type: 'number', + }, + integer: { + type: 'integer', + }, + object: { + type: 'object', + }, +}; + +// TODO: Replace the HOF with a plain function once TS supports optional generic types +// See: https://github.com/microsoft/TypeScript/issues/14400 +// TODO: convert the fn args to an object once we bump TS +export const widgetFactory = >( + displayName: string, + uiOptions: Widget['uiOptions'], + supportedTypes: ST, +) => { + return < + T extends object, + ExtraProps extends object = object, + V extends WidgetProps['value'] | null = JsonTypesTypeMap[ST[number]], + >( + widgetFn: ( + props: Overwrite, { value: V }> & ExtraProps, + ) => JSX.Element | null, + ): Widget => { + const widget = widgetFn as Widget; + Object.assign(widget, { + displayName, + uiOptions, + supportedTypes, + }); + return widget; + }; +}; + +export const formatTimestamp = ( + timestamp: string | number, + uiSchema: UiSchema = {}, +) => { + if (!timestamp) { + return ''; + } + const uiFormat = + get(uiSchema, ['ui:options', 'dtFormat']) ?? + `${DATE_FORMAT}, ${TIME_FORMAT}`; + if (typeof uiFormat !== 'string') { + throw new Error(`dtFormat must be a string instead of ${typeof uiFormat}`); + } + return format(new Date(timestamp), uiFormat); +}; + +export const truncateHash = (str: string, maxLength = 7) => { + if (!str || str.length < maxLength) { + return str; + } + return str.substring(0, maxLength); +}; + +export const timeSince = (timestamp: string | number, suffix = true) => + formatDistance(new Date(timestamp), new Date(), { addSuffix: suffix }); diff --git a/src/components/RJST/index.tsx b/src/components/RJST/index.tsx new file mode 100644 index 00000000..f27671e1 --- /dev/null +++ b/src/components/RJST/index.tsx @@ -0,0 +1,870 @@ +import React from 'react'; +import type { + RJSTContext, + ActionData, + Priorities, + RJSTSdk, + RJSTAction, + RJSTModel, + RJSTBaseResource, + RJSTRawModel, +} from './schemaOps'; +import { + getFieldForFormat, + rjstJsonSchemaPick, + rjstAdaptRefScheme, + rjstAddToSchema, + generateSchemaFromRefScheme, + getHeaderLink, + getPropertyScheme, + getSubSchemaFromRefScheme, + parseDescription, + parseDescriptionProperty, + isJSONSchema, +} from './schemaOps'; +import { LensSelection } from './Lenses/LensSelection'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import isEqual from 'lodash/isEqual'; +import { Filters } from './Filters/Filters'; +import { Tags } from './Actions/Tags'; +import { Update } from './Actions/Update'; +import { Create } from './Actions/Create'; +import { + rjstDefaultPermissions, + rjstGetModelForCollection, + rjstRunTransformers, +} from './models/helpers'; +import { + rjstGetDisabledReason, + getFromLocalStorage, + getTagsDisabledReason, + setToLocalStorage, + getSelected, + getSortingFunction, + DEFAULT_ITEMS_PER_PAGE, +} from './utils'; +import type { LensTemplate } from './Lenses'; +import { getLenses } from './Lenses'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import type { CollectionLensRendererProps } from './Lenses/types'; +import pickBy from 'lodash/pickBy'; +import { NoRecordsFoundView } from './NoRecordsFoundView'; +import type { BoxProps } from '@mui/material'; +import { Box, Link, styled, Tooltip } from '@mui/material'; +import type { Format } from './components/Widget/utils'; +import type { + CheckedState, + Pagination, + TableSortOptions, +} from './components/Table/utils'; +import type { TFunction } from '../../hooks/useTranslations'; +import { useTranslation } from '../../hooks/useTranslations'; +import { useAnalyticsContext } from '../../contexts/AnalyticsContext'; +import { useHistory } from '../../hooks/useHistory'; +import type { FiltersView } from './components/Filters'; +import type { PineFilterObject } from './oData/jsonToOData'; +import { convertToPineClientFilter, orderbyBuilder } from './oData/jsonToOData'; +import { ajvFilter } from './components/Filters/SchemaSieve'; +import { Spinner } from '../Spinner'; +import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'; +import { FocusSearch } from './components/Filters/FocusSearch'; +import { Widget } from './components/Widget'; +import { defaultFormats } from './components/Widget/Formats'; + +const HeaderGrid = styled(Box)(({ theme }) => ({ + display: 'flex', + rowGap: theme.spacing(2), + columnGap: theme.spacing(2), + '> *': { + '&:first-child': { + marginRight: 1, + }, + '&:not(:last-child):not(:first-child)': { + marginRight: 1, + marginLeft: 1, + }, + '&:last-child': { + marginLeft: 1, + }, + }, +})); + +export interface NoDataInfo { + title?: string | React.ReactElement; + subtitle?: string | React.ReactElement; + info?: string | React.ReactElement; + description?: string | React.ReactElement; + docsLink?: string; + docsLabel?: string; +} + +export interface RJSTProps extends Omit { + /** Model is the property that describe the data to display with a JSON structure */ + model: RJSTModel; + /** Array of data or data entity to display */ + data: T[] | undefined; + /** Formats are custom widgets to render in the table cell. The type of format to display is described in the model. */ + formats?: Format[]; + /** Actions is an array of actions applicable on the selected items */ + actions?: Array>; + /** The sdk is used to pass the method to handle tags when added removed or updated */ + sdk?: RJSTSdk; + /** Dictionary of {[column_property]: customFunction} where the customFunction is the function to sort data on column header click */ + customSort?: + | Dictionary<(a: T, b: T) => number> + | Dictionary; + // TODO: Ideally the base URL is autogenerated, but there are some issues with that currently (eg. instead of application we have apps in the URL) + /** Redirect on entity click */ + getBaseUrl?: (entry: T) => string; + /** Method to refresh the rendered data when something is changed */ + refresh?: () => void; + /** Event emitted on entity click */ + onEntityClick?: ( + entry: T, + event: React.MouseEvent, + ) => void; + // TODO: onChange should also be called when data in the table is sorted and when columns change + /** Function that gets called when filters change */ + onChange?: (changes: { + filters?: JSONSchema[]; + page: number; + itemsPerPage?: number; + oData?: { + $top?: number; + $skip?: number; + $filter?: any; + $orderby?: any; + } | null; + }) => void; + /** Information from a server side pagination */ + pagination?: Pagination; + /** All the lenses available for this RJST component. Any default lenses will automatically be added to this array. */ + customLenses?: LensTemplate[]; + /** Loading property to show the Spinner */ + loading?: boolean; + rowKey?: keyof T; + noDataInfo?: NoDataInfo; + persistFilters?: boolean; +} + +// TODO: Refactor into multiple layers: one for handling +// loading and another for managing all necessary data, +// to achieve a cleaner architecture. This will also improve the +// any typing we have in lenses today. + +export const RJST = >({ + model: modelRaw, + data, + formats, + actions, + sdk, + customSort, + refresh, + getBaseUrl, + onEntityClick, + onChange, + pagination, + customLenses, + loading = false, + rowKey, + noDataInfo, + persistFilters = true, + ...boxProps +}: RJSTProps) => { + const { t } = useTranslation(); + const { state: analytics } = useAnalyticsContext(); + const history = useHistory(); + + const modelRef = React.useRef(modelRaw); + // This allows the component to work even if + // consumers are passing a new model object every time. + const model = React.useMemo(() => { + if (isEqual(modelRaw, modelRef.current)) { + return modelRef.current; + } + return modelRaw; + }, [modelRaw]); + + const [filters, setFilters] = React.useState([]); + // TODO: this logic should be moved in the lens/table.tsx. + // With the layer refactor we should have a lens.data.renderer should + // only handle a onChange event that has all the info. the lens should handle all cases + const [sort, setSort] = React.useState(null); + const [internalPagination, setInternalPagination] = React.useState< + Pick + >({ + currentPage: pagination?.currentPage ?? 0, + // In the first case we need the || as the result might be NaN (Number(undefined)) or 0 Number(null)). + itemsPerPage: + (Number(getFromLocalStorage(`${model.resource}__items_per_page`)) || + pagination?.itemsPerPage) ?? + DEFAULT_ITEMS_PER_PAGE, + }); + const [views, setViews] = React.useState([]); + const [selected, setSelected] = React.useState([]); + const [checkedState, setCheckedState] = React.useState('none'); + const [internalPineFilter, setInternalPineFilter] = React.useState< + PineFilterObject | null | undefined + >(); + const [isBusyMessage, setIsBusyMessage] = React.useState< + string | undefined + >(); + const [actionData, setActionData] = React.useState< + ActionData | undefined + >(); + + const internalOnChange = React.useCallback( + ( + updatedFilters: JSONSchema[], + sortInfo: TableSortOptions | null, + page: number, + itemsPerPage: number, + ) => { + if (!onChange) { + return; + } + const pineFilter = pagination?.serverSide + ? convertToPineClientFilter([], updatedFilters) + : null; + const oData = pagination?.serverSide + ? pickBy( + { + $filter: pineFilter, + $orderby: orderbyBuilder(sortInfo, customSort), + $top: itemsPerPage, + $skip: page * itemsPerPage, + }, + (v) => v != null, + ) + : null; + setInternalPineFilter(pineFilter); + onChange?.({ + filters: updatedFilters, + page, + itemsPerPage, + oData, + }); + }, + [customSort, onChange, pagination?.serverSide], + ); + + const $setFilters = React.useCallback( + (updatedFilters: JSONSchema[]) => { + setFilters(updatedFilters); + internalOnChange( + updatedFilters, + sort, + internalPagination.currentPage, + internalPagination.itemsPerPage, + ); + }, + [ + setFilters, + internalOnChange, + internalPagination.itemsPerPage, + internalPagination.currentPage, + sort, + ], + ); + + const $setSelected = React.useCallback< + CollectionLensRendererProps['changeSelected'] + >( + (items, newCheckedState = undefined) => { + setSelected(items); + setCheckedState(newCheckedState ?? 'none'); + setActionData((oldState) => + oldState + ? { + ...oldState, + affectedEntries: items, + checkedState: newCheckedState, + } + : undefined, + ); + }, + [setSelected, setActionData], + ); + + const serverSide = pagination?.serverSide; + const totalItems = serverSide ? pagination.totalItems : data?.length; + + const hideUtils = React.useMemo( + () => + (!filters || filters.length === 0) && + data?.length === 0 && + (!serverSide || !totalItems), + [data?.length, filters, serverSide, totalItems], + ); + + const filtered = React.useMemo(() => { + if (pagination?.serverSide) { + return data ?? []; + } + return Array.isArray(data) ? ajvFilter(filters, data) : []; + }, [pagination?.serverSide, data, filters]); + + React.useEffect(() => { + $setSelected([], 'none'); + }, [filters, $setSelected]); + + const onActionTriggered = React.useCallback( + (acData: ActionData) => { + setActionData(acData); + if (acData.action.actionFn) { + acData.action.actionFn({ + affectedEntries: acData.affectedEntries, + checkedState: checkedState || 'none', + setSelected: $setSelected, + }); + } + }, + [$setSelected, checkedState], + ); + + const defaultLensSlug = getFromLocalStorage(`${model.resource}__view_lens`); + + const lenses = React.useMemo(() => { + return getLenses(data, customLenses); + }, [data, customLenses]); + + const [lens, setLens] = React.useState(lenses?.[0]); + + React.useEffect(() => { + const foundLens = + lenses?.find((l) => l.slug === defaultLensSlug) ?? + lenses?.find((l) => l.default) ?? + lenses?.[0]; + if (lens?.slug === foundLens?.slug) { + return; + } + setLens(foundLens); + }, [lenses, defaultLensSlug, lens?.slug]); + + const internalEntityClick = React.useCallback< + NonNullable + >( + (row, event) => { + onEntityClick?.(row, event); + + if (event.isPropagationStopped() && event.isDefaultPrevented()) { + return; + } + + if (getBaseUrl && !event.ctrlKey && !event.metaKey && history) { + event.preventDefault(); + try { + const url = new URL(getBaseUrl(row)); + window.open(url.toString(), '_blank'); + } catch { + if (typeof history === 'function') { + history(getBaseUrl(row)); + } + } + } + }, + [onEntityClick, getBaseUrl, history], + ); + + const rjstContext = React.useMemo((): RJSTContext => { + const tagField = getFieldForFormat(model.schema, 'tag'); + const sdkTags = sdk?.tags; + const tagsAction: RJSTAction | null = sdkTags + ? { + title: t('actions.manage_tags'), + type: 'update', + section: 'settings', + renderer: ({ affectedEntries, onDone }) => { + return ( + (!!affectedEntries || (sdkTags && 'getAll' in sdkTags)) && ( + + ) + ); + }, + isDisabled: async ({ + affectedEntries, + checkedState: rendererCheckedState, + }) => + await getTagsDisabledReason( + affectedEntries, + tagField as keyof T, + rendererCheckedState, + sdkTags, + t, + ), + } + : null; + + return { + resource: model.resource, + idField: 'id', + nameField: (model.priorities?.primary[0] as string) ?? 'id', + tagField, + getBaseUrl, + onEntityClick, + actions: tagsAction ? (actions ?? []).concat(tagsAction) : actions, + customSort, + sdk, + internalPineFilter, + checkedState, + }; + }, [ + model, + getBaseUrl, + onEntityClick, + refresh, + t, + actions, + customSort, + sdk, + internalPineFilter, + checkedState, + ]); + + const properties = React.useMemo( + () => + getColumnsFromSchema({ + t, + schema: model.schema, + idField: rjstContext.idField, + isServerSide: pagination?.serverSide ?? false, + customSort: rjstContext.customSort, + priorities: model.priorities, + formats, + }), + [ + model.schema, + rjstContext.idField, + rjstContext.customSort, + model.priorities, + pagination?.serverSide, + t, + formats, + ], + ); + + const hasUpdateActions = React.useMemo( + () => + !!actions?.filter((action) => action.type !== 'create')?.length || + !!sdk?.tags, + [actions, sdk?.tags], + ); + + if (loading && data == null) { + return ( + + ); + } + + return ( + + + + { + // We need to mount the Filters component so that it can load the filters + // & pagination state from the url (or use defaults) and provide them to + // the parent component (via $setFilters -> onChange) to use them for the + // initial data fetching request. + (data == null || Array.isArray(data)) && ( + <> + {!hideUtils ? ( + + + + + + + ( + + )} + /> + + {lenses && lenses.length > 1 && lens && ( + { + setLens(updatedLens); + setToLocalStorage( + `${model.resource}__view_lens`, + updatedLens.slug, + ); + + analytics.webTracker?.track('Change lens', { + current_url: location.origin + location.pathname, + resource: model.resource, + lens: updatedLens.slug, + }); + }} + /> + )} + + + + ) : rjstContext.actions?.filter( + (action) => action.type === 'create', + ).length ? ( + + ) : ( + t('no_data.no_resource_data', { + resource: t( + `resource.${model.resource}_other`, + ).toLowerCase(), + }) + )} + + ) + } + + {lens && !hideUtils && ( + { + setSort(sortInfo); + internalOnChange( + filters, + sortInfo, + internalPagination.currentPage, + internalPagination.itemsPerPage, + ); + setToLocalStorage(`${rjstContext.resource}__sort`, sortInfo); + }} + data={data} + rjstContext={rjstContext} + onEntityClick={ + !!onEntityClick || !!getBaseUrl + ? internalEntityClick + : undefined + } + model={model} + onPageChange={(currentPage, itemsPerPage) => { + setInternalPagination({ + currentPage, + itemsPerPage, + }); + internalOnChange(filters, sort, currentPage, itemsPerPage); + setToLocalStorage( + `${model.resource}__items_per_page`, + itemsPerPage, + ); + analytics.webTracker?.track('Change table page', { + current_url: location.origin + location.pathname, + resource: model.resource, + page: currentPage, + itemsPerPage, + }); + }} + pagination={ + { ...(pagination ?? {}), ...internalPagination } as Pagination + } + rowKey={rowKey} + /> + )} + + {actionData?.action?.renderer?.({ + schema: actionData.schema, + affectedEntries: actionData.affectedEntries, + onDone: () => { + setActionData(undefined); + }, + setSelected: $setSelected, + })} + + + + ); +}; + +export { + rjstRunTransformers, + rjstDefaultPermissions, + rjstGetModelForCollection, + rjstAddToSchema, + type RJSTAction, + type RJSTBaseResource, + type RJSTRawModel, + type RJSTModel, + rjstJsonSchemaPick, + rjstGetDisabledReason, + getPropertyScheme, + getSubSchemaFromRefScheme, + parseDescription, + parseDescriptionProperty, + generateSchemaFromRefScheme, +}; + +export type RJSTEntityPropertyDefinition = { + title: string; + label: string | JSX.Element; + field: Extract; + key: string; + selected: boolean; + sortable: boolean | ((a: T, b: T) => number); + render: ( + value: any, + row: T, + ) => string | number | JSX.Element | null | undefined; + + type: string; + priority: string; + refScheme?: string; +}; + +const getTitleAndLabel = ( + t: TFunction, + jsonSchema: JSONSchema, + propertyKey: string, + refScheme: string | undefined, +) => { + const subSchema = getSubSchemaFromRefScheme(jsonSchema, refScheme); + const title = subSchema?.title ?? jsonSchema.title ?? propertyKey; + const headerLink = getHeaderLink(subSchema); + let label: RJSTEntityPropertyDefinition['label'] = title; + + if (headerLink?.href || headerLink?.tooltip) { + label = ( + <> + + { + event.stopPropagation(); + }} + {...(headerLink?.href ? { href: headerLink.href } : {})} + > + + + + {title} + + ); + } + return { + title, + label, + }; +}; + +const hasPropertyEnabled = ( + value: string[] | boolean | undefined | null, + propertyKey: string, +) => { + if (!value) { + return false; + } + return Array.isArray(value) && value.some((v) => v === propertyKey) + ? true + : typeof value === 'boolean' + ? true + : false; +}; + +const getColumnsFromSchema = >({ + t, + schema, + idField, + isServerSide, + customSort, + priorities, + formats, +}: { + t: TFunction; + schema: JSONSchema; + idField: RJSTContext['idField']; + isServerSide: boolean; + customSort?: RJSTContext['customSort']; + priorities?: Priorities; + formats?: Format[]; +}) => + Object.entries(schema.properties ?? {}) + .filter(([_keyBy, val]) => isJSONSchema(val)) + .flatMap(([key, val]) => { + const refScheme = getPropertyScheme(val); + if (!refScheme || refScheme.length <= 1 || typeof val !== 'object') { + return [[key, val]]; + } + const entityFilterOnly = parseDescriptionProperty(val, 'x-filter-only'); + return refScheme.map((propKey: string) => { + const referenceSchema = generateSchemaFromRefScheme(val, key, propKey); + const referenceSchemaFilterOnly = parseDescriptionProperty( + referenceSchema, + 'x-filter-only', + ); + const xFilterOnly = + hasPropertyEnabled(referenceSchemaFilterOnly, propKey) || + hasPropertyEnabled(entityFilterOnly, propKey); + const xNoSort = + hasPropertyEnabled( + parseDescriptionProperty(val, 'x-no-sort'), + propKey, + ) || + hasPropertyEnabled( + parseDescriptionProperty(referenceSchema, 'x-no-sort'), + propKey, + ); + const description = JSON.stringify({ + 'x-ref-scheme': [propKey], + ...(xFilterOnly && { 'x-filter-only': 'true' }), + ...(xNoSort && { 'x-no-sort': 'true' }), + }); + return [key, { ...val, description }]; + }); + }) + .filter(([key, val]) => { + const entryDescription = parseDescription(val as JSONSchema); + return ( + key !== idField && + (!entryDescription || !('x-filter-only' in entryDescription)) + ); + }) + .map(([key, val], index) => { + if (typeof val !== 'object') { + return; + } + const xNoSort = parseDescriptionProperty(val, 'x-no-sort'); + const definedPriorities = priorities ?? ({} as Priorities); + const refScheme = getPropertyScheme(val); + const priority = definedPriorities.primary.find( + (prioritizedKey) => prioritizedKey === key, + ) + ? 'primary' + : definedPriorities.secondary.find( + (prioritizedKey) => prioritizedKey === key, + ) + ? 'secondary' + : 'tertiary'; + + const widgetSchema = { ...val, title: undefined }; + // TODO: Refactor this logic to create an object structure and retrieve the correct property using the refScheme. + // The customSort should look like: { user: { owns_items: [{ uuid: 'xx09x0' }] } } + // The refScheme will reference the property path, e.g., owns_items[0].uuid. + const fieldCustomSort = + customSort?.[`${key}_${refScheme}`] ?? customSort?.[key as string]; + if (fieldCustomSort != null) { + if ( + isServerSide && + typeof fieldCustomSort !== 'string' && + !Array.isArray(fieldCustomSort) + ) { + // We are also checking this in `orderbyBuilder()` to make TS happy, but better throw upfront + // when the model is invalid rather than only when the user issues a sorting based on + // an incorrectly configured property. + throw new Error( + `Field ${key} error: custom sort for this field must be of type string or string array, ${typeof fieldCustomSort} is not accepted.`, + ); + } else if (!isServerSide && typeof fieldCustomSort !== 'function') { + throw new Error( + `Field ${key} error: custom sort for this field must be a function, ${typeof fieldCustomSort} is not accepted.`, + ); + } + } + return { + ...getTitleAndLabel(t, val, key as string, refScheme?.[0]), + field: key, + // This is used for storing columns and views + key: refScheme ? `${key}_${refScheme[0]}_${index}` : `${key}_${index}`, + selected: getSelected(key as keyof T, priorities), + priority, + type: 'predefined', + refScheme: refScheme?.[0], + sortable: + xNoSort || val.format === 'tag' + ? false + : typeof fieldCustomSort === 'function' + ? fieldCustomSort + : getSortingFunction(key as string, val), + render: (fieldVal: string, entry: T) => { + const calculatedField = rjstAdaptRefScheme(fieldVal, val); + return ( + + ); + }, + }; + }) + .filter( + (columnDef): columnDef is NonNullable => !!columnDef, + ) as Array>; diff --git a/src/components/RJST/models/example.ts b/src/components/RJST/models/example.ts new file mode 100644 index 00000000..965fe1c1 --- /dev/null +++ b/src/components/RJST/models/example.ts @@ -0,0 +1,120 @@ +import type { RJSTBaseResource, RJSTRawModel } from '../schemaOps'; +import { rjstDefaultPermissions } from './helpers'; + +export interface AugmentedSshKey extends RJSTBaseResource { + title: string; + description: string; + public_key: string; + id: number; + fingerprint?: string; +} + +export const model: RJSTRawModel = { + resource: 'sshKey', + schema: { + type: 'object', + required: ['title', 'public_key'], + properties: { + id: { + title: 'Id', + type: 'number', + }, + title: { + title: 'Title', + type: 'string', + }, + description: { + title: 'Description', + type: 'string', + }, + fingerprint: { + title: 'Fingerprint', + type: 'string', + }, + public_key: { + title: 'Public key', + type: 'string', + format: 'public-key', + pattern: + '^(ssh-rsa|ssh-dss|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521) [A-Za-z0-9+/=]+( [^\b\n\v\f\r]+)*$', + }, + }, + }, + permissions: { + default: rjstDefaultPermissions, + administrator: { + read: ['id', 'title', 'description', 'public_key', 'fingerprint'], + create: ['title', 'public_key'], + update: ['title', 'public_key'], + delete: true, + }, + }, + priorities: { + primary: ['title'], + secondary: ['description', 'public_key', 'fingerprint'], + tertiary: [], + }, +}; + +export const transformers = { + __permissions: () => model.permissions.administrator, +}; + +export const dataExample = [ + { + id: 1, + title: 'test1', + description: 'Description key test1', + public_key: + 'ecdsa-sha2-nistp521 faMAKw1VP0720N3IledQ38PA9RcnuUIR7wIprQK45y6QfxssSwNTI4r3AI9pJM', + fingerprint: 'e1:c23:77:24:16:33:12:54:c3:98:9z:fr:0x:57:dg:0h', + }, + { + id: 2, + title: 'test2', + description: 'Description key test2', + public_key: + 'ecdsa-sha2-nistp521 faMAKw1VP0720N3IledQ38PA9RcnuUIR7wIprQK45y6QfxssSwNTI4r3AI9pJM', + fingerprint: 'e1:c23:77:24:16:33:12:54:c3:98:9z:fr:0x:57:dg:0h', + }, + { + id: 3, + title: 'test3', + description: 'Description key test3', + public_key: + 'ecdsa-sha2-nistp521 faMAKw1VP0720N3IledQ38PA9RcnuUIR7wIprQK45y6QfxssSwNTI4r3AI9pJM', + fingerprint: 'e1:c23:77:24:16:33:12:54:c3:98:9z:fr:0x:57:dg:0h', + }, + { + id: 4, + title: 'test4', + description: 'Description key test4', + public_key: + 'ecdsa-sha2-nistp521 faMAKw1VP0720N3IledQ38PA9RcnuUIR7wIprQK45y6QfxssSwNTI4r3AI9pJM', + fingerprint: 'e1:c23:77:24:16:33:12:54:c3:98:9z:fr:0x:57:dg:0h', + }, + { + id: 5, + title: 'test5', + description: 'Description key test5', + public_key: + 'ecdsa-sha2-nistp521 faMAKw1VP0720N3IledQ38PA9RcnuUIR7wIprQK45y6QfxssSwNTI4r3AI9pJM', + fingerprint: 'e1:c23:77:24:16:33:12:54:c3:98:9z:fr:0x:57:dg:0h', + }, + { + id: 6, + title: 'test6', + description: 'Description key test6', + public_key: + 'ecdsa-sha2-nistp521 faMAKw1VP0720N3IledQ38PA9RcnuUIR7wIprQK45y6QfxssSwNTI4r3AI9pJM', + fingerprint: 'e1:c23:77:24:16:33:12:54:c3:98:9z:fr:0x:57:dg:0h', + }, + { + id: 7, + title: 'test7', + description: 'Description key test7', + public_key: + 'ecdsa-sha2-nistp521 faMAKw1VP0720N3IledQ38PA9RcnuUIR7wIprQK45y6QfxssSwNTI4r3AI9pJM', + fingerprint: 'e1:c23:77:24:16:33:12:54:c3:98:9z:fr:0x:57:dg:0h', + }, +]; diff --git a/src/components/RJST/models/helpers.ts b/src/components/RJST/models/helpers.ts new file mode 100644 index 00000000..37ca5eb9 --- /dev/null +++ b/src/components/RJST/models/helpers.ts @@ -0,0 +1,78 @@ +import isEmpty from 'lodash/isEmpty'; +import type { RJSTModel, RJSTRawModel } from '../schemaOps'; +import { rjstJsonSchemaPick } from '../schemaOps'; + +type Transformers< + T extends Dictionary, + TTransformer extends Dictionary, + TContext, +> = { + [field in keyof TTransformer]: ( + entry: T, + context?: TContext, + ) => TTransformer[field]; +}; + +export const rjstDefaultPermissions = { + read: [], + create: [], + update: [], + delete: false, +}; + +export const rjstRunTransformers = < + T extends Dictionary, + TResult extends T, + TContext, +>( + data: T | undefined, + transformers: Transformers, TContext>, + context?: TContext, +): TResult | undefined => { + if (!data) { + return data; + } + + if (!transformers || isEmpty(transformers)) { + return data as TResult; + } + + const transformEntry = (entry: TResult) => { + Object.entries(transformers).forEach( + ([fieldName, transformer]: [keyof TResult, any]) => { + entry[fieldName] = transformer(entry, context); + }, + ); + }; + + // We mutate the data for performance reasons, it shouldn't matter as it is just a middleware. + const mutatedData = data as TResult; + if (Array.isArray(mutatedData)) { + mutatedData.forEach(transformEntry); + } else { + transformEntry(mutatedData); + } + return mutatedData; +}; + +// This transformation would happen elsewhere, and it wouldn't be part of RJST +export const rjstGetModelForCollection = ( + model: RJSTRawModel, + context?: { accessRole?: string | null }, +): RJSTModel => { + const accessRole = context?.accessRole; + const schema = model.priorities + ? rjstJsonSchemaPick(model.schema, [ + ...model.priorities.primary, + ...model.priorities.secondary, + ...model.priorities.tertiary, + ]) + : model.schema; + return { + ...model, + permissions: + (accessRole != null && model.permissions[accessRole]) || + model.permissions['default'], + schema, + }; +}; diff --git a/src/components/RJST/oData/converter.spec.ts b/src/components/RJST/oData/converter.spec.ts new file mode 100644 index 00000000..beb1c9db --- /dev/null +++ b/src/components/RJST/oData/converter.spec.ts @@ -0,0 +1,418 @@ +// import type { JSONSchema7 as JSONSchema } from 'json-schema'; +// import { convertToPineClientFilter } from './jsonToOData'; + +// type FilterTest = { +// testCase: string; +// filters: JSONSchema[]; +// expected: any; +// }; + +// const filterTests: FilterTest[] = [ +// { +// testCase: 'should convert string "contains" filter to pine $filter', +// filters: [ +// { +// $id: 'cdhQsy7pZgpcXvuM', +// anyOf: [ +// { +// $id: 'NoKJ2cRMrj1CovBc', +// title: 'contains', +// description: 'Release contains ', +// type: 'object', +// properties: { +// commit: { +// type: 'string', +// description: '', +// pattern: 'test', +// }, +// }, +// required: ['commit'], +// }, +// ], +// }, +// ], +// expected: { commit: { $contains: 'test' } }, +// }, +// { +// testCase: 'should convert string "not contains" filter to pine $filter', +// filters: [ +// { +// $id: 'xJFFoZ0t84xbCw4v', +// anyOf: [ +// { +// $id: 'Hwqs5HvzI1jKyq0Z', +// title: 'not_contains', +// description: 'Release does not contains', +// type: 'object', +// properties: { +// commit: { +// not: { +// type: 'string', +// // @ts-expect-error regexp is an ajv specific property +// regexp: { +// pattern: 'test', +// flags: 'i', +// }, +// }, +// }, +// }, +// required: ['commit'], +// }, +// ], +// }, +// ], +// expected: { +// $not: { +// $contains: [{ $tolower: { $: 'commit' } }, 'test'], +// }, +// }, +// }, +// { +// testCase: 'should convert number "is_more_then" filter to pine $filter', +// filters: [ +// { +// $id: 'D86bWslC9A2ySi6q', +// anyOf: [ +// { +// $id: 'xh9KwGN5sOne9TQF', +// title: 'is_more_than', +// description: +// '{"title":"Total devices","field":"total_devices","operator":{"slug":"is_more_than","label":"is more than"},"value":"10"}', +// type: 'object', +// properties: { +// total_devices: { +// type: 'number', +// exclusiveMinimum: 10, +// }, +// }, +// required: ['total_devices'], +// }, +// ], +// }, +// ], +// expected: { total_devices: { $gt: 10 } }, +// }, +// { +// testCase: 'should convert number "is_not" filter to pine $filter', +// filters: [ +// { +// $id: 'fkP80BWWcIPrkFX5', +// anyOf: [ +// { +// $id: '8A79u1MS8MimDNzR', +// title: 'is_not', +// description: +// '{"title":"Total devices","field":"total_devices","operator":{"slug":"is_not","label":"is not"},"value":"0"}', +// type: 'object', +// properties: { +// total_devices: { +// type: 'number', +// not: { +// const: 0, +// }, +// }, +// }, +// required: ['total_devices'], +// }, +// ], +// }, +// ], +// expected: { total_devices: { $ne: 0 } }, +// }, +// { +// testCase: 'should convert boolean "is" filter to pine $filter', +// filters: [ +// { +// $id: 'do3507QsNAZo0XTj', +// anyOf: [ +// { +// $id: 'D1pKThm7QFAIICXC', +// title: 'is', +// description: +// '{"title":"Is public","field":"is_public","operator":{"slug":"is","label":"is"},"value":"true"}', +// type: 'object', +// properties: { +// is_public: { +// const: true, +// }, +// }, +// required: ['is_public'], +// }, +// ], +// }, +// ], +// expected: { is_public: true }, +// }, +// { +// testCase: 'should convert object "is" filter to pine $filter', +// filters: [ +// { +// $id: '86Wyfaw4m5jwUjyz', +// anyOf: [ +// { +// $id: 'gaisWBbrv5vY03C7', +// title: 'is', +// description: +// '{"title":"Tags","field":"application_tag","operator":{"slug":"is","label":"is"},"value":{"tag_key":"test","value":"test"}}', +// type: 'object', +// properties: { +// application_tag: { +// contains: { +// title: 'Tags', +// properties: { +// tag_key: { +// const: 'test', +// }, +// value: { +// const: 'test', +// }, +// }, +// }, +// }, +// }, +// required: ['application_tag'], +// }, +// ], +// }, +// ], +// expected: { +// application_tag: { +// $any: { +// $alias: 'at', +// $expr: { +// $and: [{ at: { tag_key: 'test' } }, { at: { value: 'test' } }], +// }, +// }, +// }, +// }, +// }, +// { +// testCase: 'should convert object "is_not" filter to pine $filter', +// filters: [ +// { +// $id: 'G4odbfzGrpMnrjk5', +// anyOf: [ +// { +// $id: 'OGZJ96jybeYW6Fbz', +// title: 'is_not', +// description: +// '{"title":"Tags","field":"application_tag","operator":{"slug":"is_not","label":"is not"},"value":{"tag_key":"test","value":"test"}}', +// type: 'object', +// properties: { +// application_tag: { +// not: { +// contains: { +// title: 'Tags', +// properties: { +// tag_key: { +// const: 'test', +// }, +// value: { +// const: 'test', +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// ], +// }, +// ], +// expected: { +// $not: { +// application_tag: { +// $any: { +// $alias: 'at', +// $expr: { +// $and: [{ at: { tag_key: 'test' } }, { at: { value: 'test' } }], +// }, +// }, +// }, +// }, +// }, +// }, +// { +// testCase: 'should convert array "is" filter to pine $filter', +// filters: [ +// { +// $id: 'JYoijEvWurOUHNjV', +// anyOf: [ +// { +// $id: 'cJDDgBlHZYmqWnXy', +// title: 'is', +// description: +// '{"title":"Device type","field":"is_for__device_type","operator":{"slug":"is","label":"is"},"value":"bananapi-m1-plus","refScheme":"slug"}', +// type: 'object', +// properties: { +// is_for__device_type: { +// type: 'array', +// minItems: 1, +// contains: { +// properties: { +// slug: { +// const: 'bananapi-m1-plus', +// }, +// }, +// required: ['slug'], +// }, +// }, +// }, +// required: ['is_for__device_type'], +// }, +// ], +// }, +// ], +// expected: { +// is_for__device_type: { +// $any: { +// $alias: 'ifdt', +// $expr: { +// ifdt: { +// slug: 'bananapi-m1-plus', +// }, +// }, +// }, +// }, +// }, +// }, +// { +// testCase: 'should convert array "is_not" filter to pine $filter', +// filters: [ +// { +// $id: 'DEVF5Y2fWZd84xWq', +// title: 'is_not', +// description: +// '{"title":"Device type","field":"is_for__device_type","operator":{"slug":"is_not","label":"is not"},"value":"fincm3","refScheme":"slug"}', +// type: 'object', +// properties: { +// is_for__device_type: { +// type: 'array', +// minItems: 1, +// contains: { +// properties: { +// slug: { +// not: { +// const: 'fincm3', +// }, +// }, +// }, +// }, +// }, +// }, +// required: ['is_for__device_type'], +// }, +// ], +// expected: { +// is_for__device_type: { +// $any: { +// $alias: 'ifdt', +// $expr: { +// ifdt: { +// slug: { +// $ne: 'fincm3', +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// { +// testCase: 'should convert enum "is" "null" filter to pine $filter', // is default +// filters: [ +// { +// $id: 'EEVF5Y2fWZd84xWq', +// title: 'is', +// description: +// '{"title":"Release policy","field":"should_be_running__release","operator":{"slug":"is","label":"is"},"value":null}', +// type: 'object', +// properties: { +// should_be_running__release: { +// const: null, +// }, +// }, +// required: ['should_be_running__release'], +// }, +// ], +// expected: { +// should_be_running__release: null, +// }, +// }, +// { +// testCase: 'should convert enum "is" "not null" filter to pine $filter', // is pinned +// filters: [ +// { +// $id: 'FEVF5Y2fWZd84xWq', +// title: 'is', +// description: +// '{"title":"Release policy","field":"should_be_running__release","operator":{"slug":"is","label":"is"},"value":{"not":null}}', +// type: 'object', +// properties: { +// should_be_running__release: { +// not: { const: null }, +// }, +// }, +// required: ['should_be_running__release'], +// }, +// ], +// expected: { +// $not: { +// should_be_running__release: null, +// }, +// }, +// }, +// { +// testCase: 'should convert enum "is_not" "null" filter to pine $filter', // is not pinned +// filters: [ +// { +// $id: 'GEVF5Y2fWZd84xWq', +// title: 'is_not', +// description: +// '{"title":"Release policy","field":"should_be_running__release","operator":{"slug":"is_not","label":"is not"},"value":null}', +// type: 'object', +// properties: { +// should_be_running__release: { +// const: null, +// }, +// }, +// required: ['should_be_running__release'], +// }, +// ], +// expected: { +// should_be_running__release: null, +// }, +// }, +// { +// testCase: 'should convert enum "is_not" "not null" filter to pine $filter', // is not default +// filters: [ +// { +// $id: 'HEVF5Y2fWZd84xWq', +// title: 'is_not', +// description: +// '{"title":"Release policy","field":"should_be_running__release","operator":{"slug":"is_not","label":"is not"},"value":{"not":null}}', +// type: 'object', +// properties: { +// should_be_running__release: { +// not: { const: null }, +// }, +// }, +// required: ['should_be_running__release'], +// }, +// ], +// expected: { +// $not: { +// should_be_running__release: null, +// }, +// }, +// }, +// ]; + +// describe('JSONSchema to Pine client converter', () => { +// filterTests.forEach((test) => { +// it(test.testCase, () => { +// const $filter = convertToPineClientFilter([], test.filters); +// expect($filter).toStrictEqual(test.expected); +// }); +// }); +// }); diff --git a/src/components/RJST/oData/jsonToOData.ts b/src/components/RJST/oData/jsonToOData.ts new file mode 100644 index 00000000..54079d23 --- /dev/null +++ b/src/components/RJST/oData/jsonToOData.ts @@ -0,0 +1,297 @@ +import type { TableSortOptions } from '../components/Table/utils'; +import type { + JSONSchema7 as JSONSchema, + JSONSchema7Definition as JSONSchemaDefinition, +} from 'json-schema'; +import type { RJSTContext } from '../schemaOps'; +import { isJSONSchema } from '../schemaOps'; +import { regexUnescape } from '../DataTypes/utils'; + +export type PineFilterObject = Record; + +interface FilterMutation extends JSONSchema { + $and?: any[]; + $or?: any[]; +} + +const maybePluralOp = ( + $op: string, + filters: JSONSchemaDefinition[] | PineFilterObject[] | boolean[], +): PineFilterObject => { + const filtered = filters.filter((f) => f != null); + if (filtered.length === 1) { + return filtered[0] as PineFilterObject; + } + return { [$op]: filtered }; +}; +const createAlias = (prop: string) => + prop + .split('_') + .filter((c) => c) + .map((word) => word[0]) + .join(''); + +const comparisonOperatorMap = { + $lt: ['exclusiveMaximum', 'formatExclusiveMaximum'], + $le: ['maximum', 'formatMaximum'], + $gt: ['exclusiveMinimum', 'formatExclusiveMinimum'], + $ge: ['minimum', 'formatMinimum'], +} as const; + +// TODO: When using the searchbar, for properties that are enums or oneOf, then we can prefilter (ignore case) the possible values (by labels for oneOf) and only pass those to the final json schema filter object +// { enum: device.dtmode.oneOf.filter(f => f.title.toLowerCase().includes(search.toLowerCase()).map(f => f.slug) } + +// TODO: When ref-scheme or foreign-scheme are used, then we should treat the filter creation as if was a plain direct property and the filter modal should show the appropriate input based on the referenced leaf property schema. +// eg: if the leaf prop is a number, the modal should show =, >, <.$count + +const handlePrimitiveFilter = ( + parentKeys: string[], + value: JSONSchema & { + // ajv extensions + regexp?: { pattern?: string; flags?: string; description?: string }; + formatMinimum?: string; + formatMaximum?: string; + formatExclusiveMaximum?: string; + formatExclusiveMinimum?: string; + }, +): PineFilterObject => { + if (value.const !== undefined) { + return wrapValue(parentKeys, value.const); + } + if (value.enum !== undefined) { + return wrapValue(parentKeys, { $in: value.enum }); + } + const regexp = + value.regexp ?? + (value.pattern != null ? { pattern: value.pattern } : undefined); + if (regexp != null) { + if (regexp.pattern == null) { + throw new Error( + `Regex object defined but the pattern property was empty`, + ); + } + if (regexp.flags != null && regexp.flags !== 'i') { + throw new Error(`Regex flag ${regexp.flags} is not supported`); + } + if ( + value.$comment === 'starts_with' || + value.$comment === 'not_starts_with' + ) { + return { + $startswith: [ + { $: parentKeys.length === 1 ? parentKeys[0] : parentKeys }, + regexp.pattern.replace('^', ''), + ], + }; + } + + if (value.$comment === 'ends_with' || value.$comment === 'not_ends_with') { + return { + $endswith: [ + { $: parentKeys.length === 1 ? parentKeys[0] : parentKeys }, + regexp.pattern.replace(/\$(?=[^$]*$)/, ''), + ], + }; + } + if (regexp.flags === 'i') { + return { + $contains: [ + { + $tolower: { + $: parentKeys.length === 1 ? parentKeys[0] : parentKeys, + }, + }, + regexUnescape(regexp.pattern.toLowerCase()), + ], + }; + } + return wrapValue(parentKeys, { $contains: regexp.pattern }); + } + + const filters: any[] = []; + for (const [$op, jsonSchemaProps] of Object.entries(comparisonOperatorMap)) { + for (const jsonSchemaProp of jsonSchemaProps) { + if (value[jsonSchemaProp] != null) { + filters.push(wrapValue(parentKeys, { [$op]: value[jsonSchemaProp] })); + } + } + } + if (filters.length === 0) { + throw new Error( + `Cannot find a primitive filter able to handle ${JSON.stringify( + value, + )}, coming from keys: ${parentKeys}`, + ); + } + return maybePluralOp('$and', filters); +}; + +const wrapValue = (parentKeys: string[], value: any): PineFilterObject => { + for (let i = parentKeys.length - 1; i >= 0; i--) { + value = { [parentKeys[i]]: value }; + } + return value; +}; + +const handleFilterArray = ( + parentKeys: string[], + filterObj: JSONSchema, +): PineFilterObject => { + const filters: PineFilterObject[] = []; + if (filterObj.minItems != null && filterObj.minItems > 1) { + const field = parentKeys[parentKeys.length - 1]; + const parent$alias = parentKeys[parentKeys.length - 2] + ? createAlias(parentKeys[parentKeys.length - 2]) + : null; + filters.push({ + $ge: [ + wrapValue([...(parent$alias != null ? [parent$alias] : []), field], { + $count: {}, + }), + filterObj.minItems, + ], + }); + } + // Post-MVP: maxItems + if (filterObj.contains) { + const parentKey = parentKeys[parentKeys.length - 1]; + const $alias = createAlias(parentKey); + const nestedFilters = convertToPineClientFilter( + [$alias], + filterObj.contains as JSONSchema, + ); + if (nestedFilters) { + filters.push({ + [parentKey]: { + $any: { $alias, $expr: nestedFilters }, + }, + }); + } + } + + if (filters.length === 0) { + filters.push({ 1: 1 }); + } + return maybePluralOp('$and', filters); +}; + +const handleOperators = ( + parentKeys: string[], + filter: JSONSchema, +): PineFilterObject | undefined => { + if (!filter.anyOf && !filter.allOf) { + throw new Error('Calling handleOperators without anyOf and allOf'); + } + const operator = (filter.anyOf ?? filter.oneOf) ? '$or' : '$and'; + const filters = filter.anyOf ?? filter.oneOf ?? filter.allOf; + return convertToPineClientFilter( + parentKeys, + maybePluralOp(operator, filters ?? []), + ); +}; + +export const convertToPineClientFilter = ( + parentKeys: string[], + filter: FilterMutation | FilterMutation[], +): PineFilterObject | undefined => { + if (!filter) { + return; + } + + if (Array.isArray(filter)) { + return filter.length > 1 + ? { $and: filter.map((f) => convertToPineClientFilter(parentKeys, f)) } + : convertToPineClientFilter(parentKeys, filter[0]); + } + + // TODO: Check if possible to remove and improve + if (filter.$or || filter.$and) { + const operator = filter.$or ? '$or' : '$and'; + const filters = filter.$or ?? filter.$and; + return { + [operator]: filters?.map((f: any) => + convertToPineClientFilter(parentKeys, f), + ), + }; + } + + if (filter.anyOf || filter.oneOf || filter.allOf) { + return handleOperators(parentKeys, filter); + } + + if (filter.contains != null || filter.items != null) { + return handleFilterArray(parentKeys, filter); + } + + if (filter.properties != null) { + const propFilters = Object.entries(filter.properties).map( + ([key, value]) => { + if (!isJSONSchema(value)) { + return value; + } + return convertToPineClientFilter([...parentKeys, key], value); + }, + ); + // Check if we can remove this cast by filtering out undefined values. + // We should be able to remove boolean case. + return maybePluralOp('$and', (propFilters as PineFilterObject[]) ?? []); + } + + // Tiny optimization + if (typeof filter.not !== 'boolean' && filter.not?.const != null) { + return wrapValue(parentKeys, { $ne: filter.not.const }); + } + if (filter.not != null && typeof filter.not !== 'boolean') { + // !properties && !contains && type = string | number | boolean + return { $not: convertToPineClientFilter(parentKeys, filter.not) }; + } + + return handlePrimitiveFilter(parentKeys, filter); +}; + +export const orderbyBuilder = ( + sortInfo: TableSortOptions | null, + customSort: RJSTContext['customSort'], +) => { + if (!sortInfo) { + return null; + } + + const { field, direction, refScheme } = sortInfo; + if (!field) { + return null; + } + // TODO: Refactor this logic to create an object structure and retrieve the correct property using the refScheme. + // The customSort should look like: { user: { owns_items: [{ uuid: 'xx09x0' }] } } + // The refScheme will reference the property path, e.g., owns_items[0].uuid. + const customOrderByKey = + customSort?.[`${field}_${refScheme}`] ?? customSort?.[field]; + + if (typeof customOrderByKey === 'string') { + return [`${customOrderByKey} ${direction}`, `id ${direction}`]; + } + if (Array.isArray(customOrderByKey)) { + if ( + customOrderByKey.length === 0 || + customOrderByKey.some((k) => typeof k !== 'string') + ) { + throw new Error( + `Field ${field} error: custom sort for this field must be of type string or a non empty string array, ${customOrderByKey.join(',')} is not accepted.`, + ); + } + return [ + ...customOrderByKey.map((k) => `${k} ${direction}`), + `id ${direction}`, + ]; + } + if (customOrderByKey != null && typeof customOrderByKey !== 'string') { + throw new Error( + `Field ${field} error: custom sort for this field must be of type string or a non empty string array, ${typeof customOrderByKey} is not accepted.`, + ); + } + let fieldPath: string = field; + if (refScheme) { + fieldPath += `/${refScheme.replace(/\[(.*?)\]/g, '').replace(/\./g, '/')}`; + } + return [`${fieldPath} ${direction}`, `id ${direction}`]; +}; diff --git a/src/components/RJST/schemaOps.ts b/src/components/RJST/schemaOps.ts new file mode 100644 index 00000000..9edb977c --- /dev/null +++ b/src/components/RJST/schemaOps.ts @@ -0,0 +1,377 @@ +import type { + JSONSchema7 as JSONSchema, + JSONSchema7Definition as JSONSchemaDefinition, +} from 'json-schema'; +import get from 'lodash/get'; +import pick from 'lodash/pick'; +import { findInObject } from './utils'; +import type { ResourceTagModelService } from '../TagManagementDialog/tag-management-service'; +import type { CheckedState } from './components/Table/utils'; +import type { PineFilterObject } from './oData/jsonToOData'; + +export interface RJSTBaseResource { + id: number; + __permissions: Permissions; +} + +type XHeaderLink = { + tooltip: string; + href: string; +}; + +export interface Permissions { + read: Array; + create: Array; + update: Array; + delete: boolean; +} + +export interface Priorities { + primary: Array; + secondary: Array; + tertiary: Array; +} + +// This is a raw form that would not be exposed to the UI and would live either in the SDK or backend. +export interface RJSTRawModel { + resource: string; + schema: JSONSchema; + permissions: Dictionary>; + priorities?: Priorities; +} + +export interface RJSTModel { + resource: string; + schema: JSONSchema; + permissions: Permissions; + priorities?: Priorities; +} + +export interface CustomSchemaDescription { + 'x-ref-scheme'?: string[]; + 'x-foreign-key-scheme'?: string[]; + 'x-filter-only'?: boolean | string[]; + 'x-no-filter'?: boolean | string[]; + 'x-no-sort'?: boolean | string[]; +} + +export type RJSTTagsSdk = + | (ResourceTagModelService & { + getAll: (itemsOrFilters: any) => T[] | Promise; + canAccess: (param: { + checkedState?: CheckedState; + selected?: T[]; + }) => Promise; + }) + | (ResourceTagModelService & object); + +export interface RJSTAction { + title: string; + type: 'create' | 'update' | 'delete'; + section?: string; + renderer?: (props: { + schema: JSONSchema; + affectedEntries: T[] | undefined; + onDone: () => void; + /** setSelected can be can be used for delete function on server side pagination */ + setSelected?: ( + selected: Array> | undefined, + allChecked?: CheckedState, + ) => void; + }) => React.ReactNode; + actionFn?: (props: { + /** affectedEntries will be undefined if pagination is server side and checkedState is "all" */ + affectedEntries: T[] | undefined; + /** checkState can be undefined only for entity case, since card does not have a selection event */ + checkedState?: CheckedState; + /** setSelected can be can be used for delete function on server side pagination */ + setSelected?: ( + selected: Array> | undefined, + allChecked?: CheckedState, + ) => void; + }) => void; + isDisabled?: (props: { + /** affectedEntries will be undefined if pagination is server side and checkedState is "all" */ + affectedEntries: T[] | undefined; + /** checkState can be undefined only for entity case, since card does not have a selection event */ + checkedState?: CheckedState; + }) => MaybePromise; + isDangerous?: boolean; +} + +export interface RJSTSdk { + tags?: RJSTTagsSdk; + inputSearch?: (filter?: PineFilterObject) => Promise>>; +} + +export interface RJSTContext { + resource: string; + getBaseUrl?: (entry: T) => string; + onEntityClick?: ( + entity: T, + event: React.MouseEvent, + ) => void; + idField: string; + nameField?: string; + tagField?: Extract; + geolocation?: { + latitudeField?: string; + longitudeField?: string; + }; + actions?: Array>; + customSort?: Dictionary<((a: T, b: T) => number) | string | string[]>; + sdk?: RJSTSdk; + internalPineFilter?: PineFilterObject | null; + checkedState: CheckedState; +} + +export interface ActionData { + action: RJSTAction; + schema: JSONSchema; + affectedEntries?: T[]; + checkedState?: CheckedState; +} + +export const isJson = (str: string) => { + try { + JSON.parse(str); + return true; + } catch { + return false; + } +}; + +// The implementation lacks handling of nested schemas and some edge cases, but is enough for now. +export const rjstJsonSchemaPick = ( + schema: JSONSchema, + selectors: Array, +): JSONSchema => { + const res: JSONSchema = { + ...schema, + properties: pick(schema.properties ?? {}, selectors as string[]), + required: [], + }; + + res.required = schema.required?.filter((requiredField) => + (selectors as string[]).includes(requiredField), + ); + + return res; +}; + +export const getFieldForFormat = ( + schema: JSONSchema, + format: string, +): Extract | undefined => { + let propertyKeyWithFormat: Extract | undefined; + + Object.entries(schema.properties ?? {}).forEach(([key, val]) => { + if (typeof val === 'object' && val.format === format) { + propertyKeyWithFormat = key as Extract; + } + }); + + return propertyKeyWithFormat; +}; + +export const getRefSchemePrefix = (schema: JSONSchema) => { + return schema.items + ? 'items.properties.' + : schema.properties + ? 'properties.' + : ''; +}; + +export const getRefSchemeTitle = ( + refScheme: string | undefined, + schema: JSONSchema, +) => { + const key = `${getRefSchemePrefix(schema)}${refScheme}.title`; + return refScheme ? get(schema, key) : schema.title; +}; + +export const isJSONSchema = ( + value: + | JSONSchema + | JSONSchemaDefinition + | JSONSchemaDefinition[] + | undefined + | null, +): value is JSONSchema => { + return ( + typeof value === 'object' && + value !== null && + typeof value !== 'boolean' && + !!Object.keys(value).length + ); +}; + +export const parseDescription = ( + filter: JSONSchema, +): CustomSchemaDescription | null => { + if (!filter.description) { + return null; + } + try { + return JSON.parse(filter.description); + } catch { + return null; + } +}; + +export const parseDescriptionProperty = ( + schemaValue: JSONSchema, + property: string, +) => { + const description = parseDescription(schemaValue); + return description && property in description + ? description[property as keyof typeof description] + : null; +}; + +export const rjstAddToSchema = ( + schema: JSONSchema, + schemaProperty: string, + property: string, + value: unknown, +): JSONSchema => { + return { + ...schema, + properties: { + ...schema.properties, + [schemaProperty]: { + ...(schema.properties?.[schemaProperty] as JSONSchema), + [property]: value, + }, + }, + }; +}; + +export const getHeaderLink = ( + schemaValue: JSONSchema | JSONSchemaDefinition, +): XHeaderLink | null => { + if (typeof schemaValue === 'boolean' || !schemaValue.description) { + return null; + } + try { + const json = JSON.parse(schemaValue.description); + return json['x-header-link']; + } catch { + return null; + } +}; + +// TODO: This atm doesn't support ['my property'] +export const convertRefSchemeToSchemaPath = (refScheme: string | undefined) => { + return refScheme + ?.split('.') + .join('.properties.') + .replace(/\[\d+\]/g, '.items'); +}; + +export const getRefSchemaPrefix = (propertySchema: JSONSchema) => { + return propertySchema.type === 'array' ? `items.properties.` : `properties.`; +}; + +export const generateSchemaFromRefScheme = ( + schema: JSONSchema, + parentProperty: string, + refScheme: string | undefined, +): JSONSchema => { + const propertySchema = + (schema.properties?.[parentProperty] as JSONSchema) ?? schema; + if (!refScheme) { + return propertySchema; + } + const convertedRefScheme = `${getRefSchemaPrefix( + propertySchema, + )}${convertRefSchemeToSchemaPath(refScheme)}`; + const typePaths: string[][] = []; + const ongoingIncrementalPath: string[] = []; + convertedRefScheme.split('.').forEach((key) => { + if (['properties', 'items'].includes(key)) { + typePaths.push([...ongoingIncrementalPath, 'type']); + } + ongoingIncrementalPath.push(key); + }); + if (ongoingIncrementalPath.length) { + typePaths.push(ongoingIncrementalPath); + } + return { + ...propertySchema, + description: JSON.stringify({ 'x-ref-scheme': [refScheme] }), + title: + (get(propertySchema, convertedRefScheme) as JSONSchema)?.title ?? + propertySchema.title, + ...pick(propertySchema, typePaths), + }; +}; + +export const getRefSchema = ( + schema: JSONSchema, + refSchemePrefix: string, +): JSONSchema => { + const refScheme = parseDescriptionProperty(schema, 'x-ref-scheme'); + return refScheme + ? ((get(schema, `${refSchemePrefix}${refScheme}`) as JSONSchema) ?? schema) + : schema; +}; + +export const getPropertyScheme = ( + schemaValue: JSONSchema | JSONSchemaDefinition | null, +): string[] | null => { + const json = isJSONSchema(schemaValue) ? parseDescription(schemaValue) : null; + return json?.['x-foreign-key-scheme'] ?? json?.['x-ref-scheme'] ?? null; +}; + +export const getSubSchemaFromRefScheme = ( + schema: JSONSchema | JSONSchemaDefinition, + refScheme?: string, +): JSONSchema => { + const referenceScheme = refScheme ?? getPropertyScheme(schema)?.[0]; + const convertedRefScheme = convertRefSchemeToSchemaPath(referenceScheme); + if (!convertedRefScheme || !isJSONSchema(schema)) { + return schema as JSONSchema; + } + const properties = findInObject(schema, 'properties'); + return get(properties, convertedRefScheme) as JSONSchema; +}; + +export const getSchemaFormat = (schema: JSONSchema) => { + const property = getSubSchemaFromRefScheme(schema); + return property.format ?? schema.format; +}; + +export const getSchemaTitle = ( + jsonSchema: JSONSchema, + propertyKey: string, + refScheme?: string, +) => { + return ( + (refScheme + ? getSubSchemaFromRefScheme(jsonSchema, refScheme).title + : jsonSchema?.title) ?? propertyKey + ); +}; + +export const rjstAdaptRefScheme = ( + value: unknown, + property: JSONSchemaDefinition, +) => { + if (!property || value == null) { + return null; + } + if (typeof property === 'boolean') { + return value; + } + if ( + !property.description?.includes('x-ref-scheme') || + !isJson(property.description) + ) { + return value; + } + const refScheme = getPropertyScheme(property); + const transformed = + (Array.isArray(value) && value.length <= 1 ? value[0] : value) ?? null; + return refScheme ? (get(transformed, refScheme[0]) ?? null) : transformed; +}; diff --git a/src/components/RJST/utils.ts b/src/components/RJST/utils.ts new file mode 100644 index 00000000..11013d33 --- /dev/null +++ b/src/components/RJST/utils.ts @@ -0,0 +1,201 @@ +import type { + RJSTBaseResource, + Permissions, + Priorities, + RJSTTagsSdk, +} from './schemaOps'; +import { getPropertyScheme } from './schemaOps'; +import castArray from 'lodash/castArray'; +import get from 'lodash/get'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; +import type { CheckedState } from './components/Table/utils'; +import type { TFunction } from '../../hooks/useTranslations'; +import { JsonTypes } from './components/Widget/utils'; + +export const DEFAULT_ITEMS_PER_PAGE = 50; + +export const diff = (a: T, b: T) => { + if (a === b) { + return 0; + } + return a > b ? 1 : -1; +}; + +export const stopEvent = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); +}; + +export const getFromLocalStorage = (key: string): T | undefined => { + try { + const val = localStorage.getItem(key); + if (val != null) { + return JSON.parse(val); + } + return undefined; + } catch (err) { + console.error(err); + return undefined; + } +}; + +export const setToLocalStorage = (key: string, value: unknown) => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (err) { + console.error(err); + } +}; + +export const findInObject = (obj: Record, key: string): any => { + let result; + for (const property in obj) { + if (Object.prototype.hasOwnProperty.call(obj, property)) { + if (property === key) { + return obj[key]; + } else if (typeof obj[property] === 'object') { + result = findInObject(obj[property], key); + if (typeof result !== 'undefined') { + return result; + } + } + } + } +}; + +export const ObjectFromEntries = (entries: Array<[string, any]>) => { + const obj: Record = {}; + for (const [key, value] of entries) { + obj[key] = value; + } + return obj; +}; + +export const getTagsDisabledReason = async >( + selected: T[] | undefined, + tagField: keyof T, + checkedState: CheckedState | undefined, + tagsSdk: RJSTTagsSdk, + t: TFunction, +) => { + if (checkedState !== 'all' && (!selected || selected.length === 0)) { + return t('info.no_selected'); + } + + const lacksPermissionsOnSelected = + tagsSdk && 'canAccess' in tagsSdk + ? !(await tagsSdk.canAccess({ checkedState, selected })) + : selected?.some((entry) => { + return ( + !entry.__permissions.delete && + !entry.__permissions.create.includes(tagField) && + !entry.__permissions.update.includes(tagField) + ); + }); + + if (lacksPermissionsOnSelected) { + return t('info.edit_tag_no_permissions', { resource: 'item' }); + } + return null; +}; + +export const getCreateDisabledReason = >( + permissions: Permissions, + hasOngoingAction: boolean, + t: TFunction, +) => { + if (hasOngoingAction) { + return t('info.ongoing_action_wait'); + } + + if (!permissions.create?.length) { + return t('info.create_item_no_permissions', { resource: 'item' }); + } +}; + +export const rjstGetDisabledReason = >( + selected: T[] | undefined, + checkedState: CheckedState | undefined, + hasOngoingAction: boolean, + actionType: 'update' | 'delete' | null, + t: TFunction, +) => { + if ((!selected && checkedState === 'none') || selected?.length === 0) { + return t('info.no_selected'); + } + + if (hasOngoingAction) { + return t('info.ongoing_action_wait'); + } + + if (!selected || !actionType) { + return; + } + + const lacksPermissionsOnSelected = selected.some((entry) => { + return ( + !entry.__permissions[actionType] || + (Array.isArray(entry.__permissions[actionType]) && + (entry.__permissions[actionType] as Array).length <= 0) + ); + }); + + if (lacksPermissionsOnSelected) { + return t('info.update_item_no_permissions', { + action: actionType, + resource: 'item', + }); + } +}; + +const splitPath = (path: string) => { + const regex = /([^.[]+)|\[(\d+)\]/g; + const parts = []; + let match; + + while ((match = regex.exec(path)) !== null) { + parts.push(match[1] || match[2]); + } + + return parts; +}; + +const sortFn = (a: unknown, b: unknown, ref: string | string[]) => { + const aa = get(a, ref) ?? ''; + const bb = get(b, ref) ?? ''; + if (typeof aa === 'string' && typeof bb === 'string') { + return aa.toLowerCase().localeCompare(bb.toLowerCase()); + } + return diff(aa, bb); +}; + +export const getSortingFunction = ( + schemaKey: keyof T, + schemaValue: JSONSchema, +) => { + const types = castArray(schemaValue.type); + const refScheme = getPropertyScheme(schemaValue); + const splitRefScheme = refScheme?.[0] ? splitPath(refScheme[0]) : null; + if (types.includes(JsonTypes.string)) { + return (a: T, b: T) => sortFn(a, b, schemaKey as string); + } + if (types.includes(JsonTypes.object) && splitRefScheme) { + return (a: T, b: T) => + sortFn(a, b, [schemaKey as string, ...splitRefScheme]); + } + if (types.includes(JsonTypes.array) && splitRefScheme) { + return (a: T, b: T) => + sortFn(a, b, [schemaKey as string, '0', ...splitRefScheme]); + } + return (a: T, b: T) => diff(a[schemaKey], b[schemaKey]); +}; + +export const getSelected = ( + key: K, + priorities?: Priorities, +) => { + if (!priorities) { + return true; + } + return priorities.primary.includes(key) || priorities.secondary.includes(key); +}; diff --git a/src/components/Tag/index.tsx b/src/components/Tag/index.tsx index b8599d78..2289e020 100644 --- a/src/components/Tag/index.tsx +++ b/src/components/Tag/index.tsx @@ -1,7 +1,14 @@ import React from 'react'; import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Box, type BoxProps, Button, Tooltip, Typography } from '@mui/material'; +import { + Box, + type BoxProps, + Button, + IconButton, + Tooltip, + Typography, +} from '@mui/material'; // Prevent the tags taking up too much horizontal space const MAX_ITEMS_TO_DISPLAY = 3; @@ -125,20 +132,28 @@ export const Tag = ({ return ( - {onClick ? : tagContent} + {onClick ? ( + + ) : ( + tagContent + )} {onClose && ( - + )} ); diff --git a/src/contexts/TranslationContext.tsx b/src/contexts/TranslationContext.tsx deleted file mode 100644 index f66e65f6..00000000 --- a/src/contexts/TranslationContext.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import React from 'react'; -import type { TFunction } from '../hooks/useTranslations'; - -export const TranslationContext = React.createContext(null); diff --git a/src/contexts/UiSharedComponentsContextProvider.tsx b/src/contexts/UiSharedComponentsContextProvider.tsx new file mode 100644 index 00000000..62178e42 --- /dev/null +++ b/src/contexts/UiSharedComponentsContextProvider.tsx @@ -0,0 +1,25 @@ +import { createContext } from 'react'; +import type { TFunction } from '../hooks/useTranslations'; +export interface UiSharedComponentsContextProviderInterface { + history: unknown; + t?: TFunction; + externalTranslationMap?: Dictionary; +} + +export const UiSharedComponentsContextProvider = + createContext({ + history: {}, + }); + +export const UiSharedComponentsProvider = ({ + children, + ...otherProps +}: UiSharedComponentsContextProviderInterface & { + children: React.ReactNode; +}) => { + return ( + + {children} + + ); +}; diff --git a/src/hooks/useClickOutsideOrEsc.tsx b/src/hooks/useClickOutsideOrEsc.tsx new file mode 100644 index 00000000..0d0ea8e2 --- /dev/null +++ b/src/hooks/useClickOutsideOrEsc.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +export const useClickOutsideOrEsc = ( + handler: () => void, +): React.RefObject => { + const domNodeRef = React.useRef(null); + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent | KeyboardEvent) => { + if (!domNodeRef.current?.contains(event.target as Node)) { + handler(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handler(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [handler]); + + return domNodeRef; +}; diff --git a/src/hooks/useHistory.tsx b/src/hooks/useHistory.tsx new file mode 100644 index 00000000..0762c58c --- /dev/null +++ b/src/hooks/useHistory.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { UiSharedComponentsContextProvider } from '../contexts/UiSharedComponentsContextProvider'; + +export const useHistory = () => { + const { history } = React.useContext(UiSharedComponentsContextProvider); + return history; +}; diff --git a/src/hooks/useTranslations.ts b/src/hooks/useTranslations.ts index 07f807ef..5b4ac2b2 100644 --- a/src/hooks/useTranslations.ts +++ b/src/hooks/useTranslations.ts @@ -1,6 +1,6 @@ import React from 'react'; import template from 'lodash/template'; -import { TranslationContext } from '../contexts/TranslationContext'; +import { UiSharedComponentsContextProvider } from '../contexts/UiSharedComponentsContextProvider'; export type TFunction = (str: string, options?: any) => string; @@ -57,6 +57,52 @@ const translationMap = { 'Must be greater than or equal to {{minimum}}', 'fields_errors.does_not_satisfy_maximum': 'Must be less than or equal to {{maximum}}', + + // RJST + 'info.update_item_no_permissions': + "You don't have permission to {{action}} the selected {{resource}}", + 'info.ongoing_action_wait': 'There is an ongoing action, please wait', + 'info.create_item_no_permissions': + "You don't have permission to create a new {{resource}}", + 'info.edit_tag_no_permissions': + "You don't have permission to edit the tags on the selected {{resource}}", + 'info.no_selected': "You haven't selected anything yet", + 'info.click_to_read_more': 'Read more about {{title}}', + 'info.already_visible': 'This column is already visible', + 'labels.lenses': 'Lenses', + 'labels.modify': 'Modify', + 'loading.resource': 'Loading {{resource}}', + 'no_data.no_resource_data': "You don't have any {{resource}} yet.", + 'no_data.no_resource_data_title': 'This is where all your {{resource}} live.', + 'no_data.no_resource_data_description': + "This is a bit empty right now, let's go ahead and add one", + 'questions.how_about_adding_one': 'How about adding one?', + 'resource.item_other': 'Items', + 'success.resource_added_successfully': '{{name}} added successfully', + 'success.tags_updated_successfully': 'Tags updated successfully', + + 'actions.add_filter': 'Add filter', + 'labels.filter_by': 'Filter by', + 'labels.info_no_views': "You haven't created any views yet", + 'labels.views': 'Views', + 'labels.filter_one': 'Filter', + 'labels.filter_other': 'Filters', + + 'labels.save_current_view': 'Save current view', + + 'aria_labels.remove_view': 'Delete the selected view', + 'aria_labels.create_view': 'Create named view', + 'aria_labels.add_filter_in_or': 'Add filter with OR condition', + 'aria_labels.remove_filter': 'Remove selected filter', + 'aria_labels.save_tag_columns': 'Save added tag columns', + + 'actions.clear_all': 'Clear filters', + 'actions.save_view': 'Save view', + 'actions.add_alternative': 'Add alternative', + 'actions.remove_filter': 'Remove filter', + 'actions.add_columns': 'Add columns', + + 'commons.or': 'or', }; const getTranslation = (str: string, opts?: any) => { @@ -75,7 +121,7 @@ const getTranslation = (str: string, opts?: any) => { }; export const useTranslation = () => { - const externalT = React.useContext(TranslationContext); + const { t: externalT } = React.useContext(UiSharedComponentsContextProvider); const t: TFunction = (str: string, opts?: any) => { let result = str; if (!!externalT && typeof externalT === 'function') { diff --git a/src/index.tsx b/src/index.tsx index dab01e26..7e8df679 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -109,6 +109,72 @@ export { useAnalyticsContext, AnalyticsStoreActions, } from './contexts/AnalyticsContext'; + +export { + RJST, + type RJSTProps, + rjstRunTransformers, + rjstDefaultPermissions, + rjstGetModelForCollection, + rjstAddToSchema, + type RJSTAction, + type RJSTBaseResource, + type RJSTRawModel, + type RJSTModel, + rjstJsonSchemaPick, + rjstGetDisabledReason, + type NoDataInfo, + getPropertyScheme, + getSubSchemaFromRefScheme, + parseDescription, + parseDescriptionProperty, + generateSchemaFromRefScheme, +} from './components/RJST'; + +export { + removeFieldsWithNoFilter, + modifySchemaWithRefSchemes, + removeRefSchemeSeparatorsFromFilters, +} from './components/RJST/Filters/utils'; + +export { + type FormData, + FULL_TEXT_SLUG, + ajvFilter, + getPropertySchema, + parseFilterDescription, + createModelFilter, + createFilter, + createFullTextSearchFilter, + convertSchemaToDefaultValue, +} from './components/RJST/components/Filters/SchemaSieve'; + +export { + Filters, + type FiltersProps, + type FiltersView, +} from './components/RJST/components/Filters'; + +export type { + Widget as WidgetType, + Format, + JsonTypesTypeMap, +} from './components/RJST/components/Widget/utils'; +export { + widgetFactory, + JsonTypes, +} from './components/RJST/components/Widget/utils'; +export type { Permissions, RJSTContext } from './components/RJST/schemaOps'; +export type { LensTemplate } from './components/RJST/Lenses'; +export type { Pagination } from './components/RJST/components/Table/utils'; +export { UiSharedComponentsProvider } from './contexts/UiSharedComponentsContextProvider'; + +export { + listFilterQuery, + PersistentFilters, +} from './components/RJST/Filters/PersistentFilters'; + +export * from './components/RJST/Lenses/types'; export * as AnalyticsClient from 'analytics-client'; export * as Material from '@mui/material'; export * as MaterialLab from '@mui/lab'; diff --git a/src/typings/common.d.ts b/src/typings/common.d.ts index 2c8494b4..ea5e17bb 100644 --- a/src/typings/common.d.ts +++ b/src/typings/common.d.ts @@ -1 +1,12 @@ type MaybePromise = T | Promise; + +type Overwrite = Pick> & U; +interface Dictionary { + [key: string]: T; +} + +type Subset = { + [K in keyof T]: T[K]; +}; + +type MaybePromise = T | Promise;