From 43805d415b2d8fd9bf46e743f3f0bade3fc91110 Mon Sep 17 00:00:00 2001 From: Gali Ainouz Baum Date: Wed, 31 Jul 2024 10:16:54 +0300 Subject: [PATCH] feat(web,novui): initial implementation of var autocomplete in controls (#6097) Co-authored-by: Joel Anton Co-authored-by: Richard Fontein <32132657+rifont@users.noreply.github.com> --- .cspell.json | 1 + .../src/app/events/e2e/bridge-trigger.e2e.ts | 2 +- .../LocalStudioHeader/LocalStudioHeader.tsx | 2 + .../LocalStudioSidebarOrganizationDisplay.tsx | 5 +- apps/web/src/index.tsx | 1 - .../node-view/WorkflowFloatingMenu.tsx | 3 +- .../WorkflowStepEditorControlsPanel.tsx | 12 + apps/web/src/studio/utils/index.ts | 1 + apps/web/src/studio/utils/variables.ts | 14 + libs/novui/package.json | 17 +- libs/novui/src/index.css | 2 + .../JsonSchemaForm.stories.tsx | 13 +- .../json-schema-components/JsonSchemaForm.tsx | 15 +- .../widgets/InputEditorWidget.tsx | 134 +++ .../widgets/InputWidget.tsx | 24 - .../widgets/TextareaWidget.tsx | 24 - .../widgets/VariableSuggestionList.tsx | 91 ++ .../json-schema-components/widgets/index.ts | 3 +- libs/novui/src/panda-preset.ts | 9 +- libs/novui/src/recipes/index.ts | 2 + .../src/recipes/input-editor-widget.recipe.ts | 50 ++ libs/novui/src/recipes/input.recipe.ts | 2 + .../variable-suggestion-list.recipe.ts | 29 + .../novui/src/tokens/semanticColors.tokens.ts | 15 + libs/novui/src/tokens/semanticSizes.tokens.ts | 10 + packages/framework/src/client.test.ts | 8 +- packages/framework/src/client.ts | 2 +- pnpm-lock.yaml | 791 +++++++++++++----- 28 files changed, 1009 insertions(+), 273 deletions(-) create mode 100644 apps/web/src/studio/utils/variables.ts create mode 100644 libs/novui/src/json-schema-components/widgets/InputEditorWidget.tsx delete mode 100644 libs/novui/src/json-schema-components/widgets/InputWidget.tsx delete mode 100644 libs/novui/src/json-schema-components/widgets/TextareaWidget.tsx create mode 100644 libs/novui/src/json-schema-components/widgets/VariableSuggestionList.tsx create mode 100644 libs/novui/src/recipes/input-editor-widget.recipe.ts create mode 100644 libs/novui/src/recipes/variable-suggestion-list.recipe.ts diff --git a/.cspell.json b/.cspell.json index 25da9e54a74..0d43f085485 100644 --- a/.cspell.json +++ b/.cspell.json @@ -52,6 +52,7 @@ "bcast", "behaviour", "bestguess", + "tiptap", "binipdisplay", "bitauth", "bitjson", diff --git a/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts b/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts index 4f79ef9e201..562c0fa099a 100644 --- a/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts +++ b/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts @@ -583,7 +583,7 @@ contexts.forEach((context: Context) => { controlSchema: { type: 'object', properties: { - name: { type: 'string', default: 'Hello {{name}}' }, + name: { type: 'string', default: 'Hello {{payload.name}}' }, }, } as const, } diff --git a/apps/web/src/components/layout/components/LocalStudioHeader/LocalStudioHeader.tsx b/apps/web/src/components/layout/components/LocalStudioHeader/LocalStudioHeader.tsx index 7f024e2e821..7c50982761f 100644 --- a/apps/web/src/components/layout/components/LocalStudioHeader/LocalStudioHeader.tsx +++ b/apps/web/src/components/layout/components/LocalStudioHeader/LocalStudioHeader.tsx @@ -24,6 +24,8 @@ export const LocalStudioHeader: FC = () => { borderBottom: 'none !important', zIndex: 'sticky', padding: '50', + // TODO: because this component is directly from mantine, it doesn't respect layer styles + bgColor: 'surface.page !important', })} > diff --git a/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarOrganizationDisplay.tsx b/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarOrganizationDisplay.tsx index d67894fb205..20e328c79e5 100644 --- a/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarOrganizationDisplay.tsx +++ b/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarOrganizationDisplay.tsx @@ -2,7 +2,7 @@ import { LocalizedMessage, Text } from '@novu/novui'; import { Flex, Stack } from '@novu/novui/jsx'; import { FC } from 'react'; import { css } from '@novu/novui/css'; -import { Popover, Tooltip, useColorScheme } from '@novu/design-system'; +import { Popover, useColorScheme } from '@novu/design-system'; import { useDisclosure } from '@mantine/hooks'; type LocalStudioSidebarOrganizationDisplayProps = { @@ -24,6 +24,9 @@ export const LocalStudioSidebarOrganizationDisplay: FC { diff --git a/apps/web/src/studio/components/workflows/node-view/WorkflowFloatingMenu.tsx b/apps/web/src/studio/components/workflows/node-view/WorkflowFloatingMenu.tsx index b0a3a319658..7fec9b1939d 100644 --- a/apps/web/src/studio/components/workflows/node-view/WorkflowFloatingMenu.tsx +++ b/apps/web/src/studio/components/workflows/node-view/WorkflowFloatingMenu.tsx @@ -110,8 +110,7 @@ function WorkflowFloatingMenuButton({ Icon, tooltipLabel, onClick }: IWorkflowFl padding: '75 !important', borderRadius: '100', _hover: { - // TODO: this doesn't work due to all the !important in novui... need to fix layer styles - bg: 'legacy.B30 !important', + bg: 'select.option.surface.selected', '& svg': { color: 'typography.text.main !important', }, diff --git a/apps/web/src/studio/components/workflows/step-editor/WorkflowStepEditorControlsPanel.tsx b/apps/web/src/studio/components/workflows/step-editor/WorkflowStepEditorControlsPanel.tsx index 0e566313cfc..e39cfd360f2 100644 --- a/apps/web/src/studio/components/workflows/step-editor/WorkflowStepEditorControlsPanel.tsx +++ b/apps/web/src/studio/components/workflows/step-editor/WorkflowStepEditorControlsPanel.tsx @@ -8,6 +8,7 @@ import { css } from '@novu/novui/css'; import { Container, Flex } from '@novu/novui/jsx'; import { useDebouncedCallback } from '@novu/novui'; import { useTelemetry } from '../../../../hooks/useNovuAPI'; +import { getSuggestionVariables, subscriberVariables } from '../../../utils'; export type OnChangeType = 'step' | 'payload'; @@ -43,6 +44,16 @@ export const WorkflowStepEditorControlsPanel: FC { + const payloadObject = + workflow?.payload?.schema?.properties || + workflow?.options?.payloadSchema?.properties || + workflow?.payloadSchema?.properties || + {}; + + return getSuggestionVariables(payloadObject, 'payload'); + }, [workflow?.payload?.schema, workflow?.options?.payloadSchema, workflow?.payloadSchema]); + const haveControlProperties = useMemo(() => { return Object.keys(step?.controls?.schema?.properties || step?.inputs?.schema?.properties || {}).length > 0; }, [step?.controls?.schema, step?.inputs?.schema]); @@ -87,6 +98,7 @@ export const WorkflowStepEditorControlsPanel: FC handleOnChange('step', data, id)} schema={step?.controls?.schema || step?.inputs?.schema || {}} formData={defaultControls || {}} + variables={[...(subscriberVariables || []), ...(payloadProperties || [])]} /> diff --git a/apps/web/src/studio/utils/index.ts b/apps/web/src/studio/utils/index.ts index 161ba393c74..7cd5e5a56c1 100644 --- a/apps/web/src/studio/utils/index.ts +++ b/apps/web/src/studio/utils/index.ts @@ -1 +1,2 @@ export * from './routing'; +export * from './variables'; diff --git a/apps/web/src/studio/utils/variables.ts b/apps/web/src/studio/utils/variables.ts new file mode 100644 index 00000000000..98044aba704 --- /dev/null +++ b/apps/web/src/studio/utils/variables.ts @@ -0,0 +1,14 @@ +import { SystemVariablesWithTypes } from '@novu/shared'; + +export function getSuggestionVariables(schemaObject, namespace: string) { + return Object.keys(schemaObject).flatMap((name) => { + const schemaItem = schemaObject[name]; + if (schemaItem?.type === 'object') { + return getSuggestionVariables(schemaItem.properties, `${namespace}.${name}`); + } + + return `${namespace}.${name}`; + }); +} + +export const subscriberVariables = getSuggestionVariables(SystemVariablesWithTypes.subscriber, 'subscriber'); diff --git a/libs/novui/package.json b/libs/novui/package.json index 72fcb8fdaf7..6fbd93777a1 100644 --- a/libs/novui/package.json +++ b/libs/novui/package.json @@ -51,8 +51,7 @@ "require": "./styled-system/jsx/index.js", "import": "./styled-system/jsx/index.js" }, - "./styles.css": "./styled-system/styles.css", - "./components.css": "./node_modules/@mantine/core/styles.layer.css" + "./styles.css": "./src/index.css" }, "scripts": { "dev": "pnpm build && pnpm storybook", @@ -130,13 +129,21 @@ } }, "dependencies": { - "@mantine/code-highlight": "^7.10.2", - "@mantine/core": "^7.10.0", - "@mantine/hooks": "^7.10.0", + "@mantine/code-highlight": "^7.11.2", + "@mantine/core": "^7.11.2", + "@mantine/hooks": "^7.11.2", + "@mantine/tiptap": "^7.11.2", "@rjsf/core": "^5.17.1", "@rjsf/utils": "^5.17.1", "@rjsf/validator-ajv8": "^5.17.1", "@tanstack/react-table": "^8.17.3", + "@tiptap/extension-document": "^2.5.0", + "@tiptap/extension-mention": "^2.5.0", + "@tiptap/extension-paragraph": "^2.5.0", + "@tiptap/extension-text": "^2.5.0", + "@tiptap/pm": "^3.0.0", + "@tiptap/react": "^3.0.0", + "@tiptap/suggestion": "^2.5.0", "react-icons": "^5.0.1" } } diff --git a/libs/novui/src/index.css b/libs/novui/src/index.css index ab87315d934..e294e60949e 100644 --- a/libs/novui/src/index.css +++ b/libs/novui/src/index.css @@ -1,3 +1,5 @@ @layer reset, base, mantine, tokens, recipes, utilities; @import '@mantine/core/styles.layer.css'; +@import '@mantine/tiptap/styles.layer.css'; +@import '../styled-system/styles.css'; diff --git a/libs/novui/src/json-schema-components/JsonSchemaForm.stories.tsx b/libs/novui/src/json-schema-components/JsonSchemaForm.stories.tsx index e63873cb187..4504715dccd 100644 --- a/libs/novui/src/json-schema-components/JsonSchemaForm.stories.tsx +++ b/libs/novui/src/json-schema-components/JsonSchemaForm.stories.tsx @@ -27,7 +27,18 @@ const Template: StoryFn = ({ colorPalette, ...args }) => Save - + ); }; diff --git a/libs/novui/src/json-schema-components/JsonSchemaForm.tsx b/libs/novui/src/json-schema-components/JsonSchemaForm.tsx index 4cfa5d9fe1e..828a9fef0a1 100644 --- a/libs/novui/src/json-schema-components/JsonSchemaForm.tsx +++ b/libs/novui/src/json-schema-components/JsonSchemaForm.tsx @@ -8,15 +8,15 @@ import { CoreProps } from '../types'; import { ArrayFieldItemTemplate, ArrayFieldTemplate, ArrayFieldTitleTemplate } from './templates/ArrayFieldTemplate'; import { AddButton, MoveDownButton, MoveUpButton, RemoveButton } from './templates/IconButton'; import { ObjectFieldTemplate } from './templates/ObjectFieldTemplate'; -import { CheckboxWidget, InputWidget, SelectWidget, TextareaWidget } from './widgets'; +import { CheckboxWidget, SelectWidget, InputEditorWidget } from './widgets'; import { JSON_SCHEMA_FORM_ID_DELIMITER } from './utils'; const WIDGETS: RegistryWidgetsType = { CheckboxWidget: CheckboxWidget, SelectWidget: SelectWidget, - TextWidget: TextareaWidget, - URLWidget: InputWidget, - EmailWidget: InputWidget, + TextWidget: InputEditorWidget, + URLWidget: InputEditorWidget, + EmailWidget: InputEditorWidget, }; const UI_SCHEMA: UiSchema = { @@ -31,13 +31,15 @@ const UI_SCHEMA: UiSchema = { export type JsonSchemaFormProps = JsxStyleProps & CoreProps & - Pick, 'onChange' | 'onSubmit' | 'onBlur' | 'schema' | 'formData' | 'tagName'>; + Pick, 'onChange' | 'onSubmit' | 'onBlur' | 'schema' | 'formData' | 'tagName'> & { + variables?: string[]; + }; /** * Specialized form editor for data passed as JSON. */ export function JsonSchemaForm(props: JsonSchemaFormProps) { - const [cssProps, { className, ...formProps }] = splitCssProps(props); + const [cssProps, { className, variables, ...formProps }] = splitCssProps(props); return (
(props: JsonSchemaFormProps { + const { value, label, formContext, onChange, required, readonly, rawErrors, options, schema } = props; + const [variantProps, inputProps] = input.splitVariantProps({}); + const [cssProps] = splitCssProps(inputProps); + const classNames = input(variantProps); + + const { variables = [] } = formContext; + const reactRenderer = useRef>(null); + + const variablesList = useMemo(() => { + return variables?.map((variable: string) => { + return { label: variable, id: variable }; + }); + }, [variables]); + + const baseExtensions: Extensions = [Document, Paragraph, Text]; + + if (variables.length) { + baseExtensions.push( + Mention.configure({ + HTMLAttributes: { + class: 'suggestion', + }, + renderHTML: ({ options, node }) => { + return [ + 'span', + options.HTMLAttributes, + `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}${AUTOCOMPLETE_CLOSE_TAG}`, + ]; + }, + renderText: ({ options, node }) => { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}${AUTOCOMPLETE_CLOSE_TAG}`; + }, + suggestion: { + items: ({ query }) => { + return variablesList?.filter((item) => item.label.toLowerCase().includes(query.toLowerCase())); + }, + char: AUTOCOMPLETE_OPEN_TAG, + render() { + return { + onStart: (props) => { + reactRenderer.current = new ReactRenderer(VariableSuggestionList, { + props, + editor: props.editor, + }); + }, + onUpdate(props) { + reactRenderer.current?.updateProps(props); + }, + onKeyDown(props) { + if (!reactRenderer.current?.ref) { + return false; + } + + return reactRenderer.current?.ref.onKeyDown(props); + }, + onExit() { + reactRenderer.current?.destroy(); + }, + }; + }, + }, + }) + ); + } + + const editor = useEditor({ + extensions: baseExtensions, + content: '', + editable: !readonly, + onFocus: () => { + reactRenderer.current?.ref?.focus(); + }, + onUpdate: ({ editor }) => { + const content = editor.isEmpty ? undefined : editor.getText(); + onChange(content); + }, + }); + + useEffect(() => { + if (editor) { + const newValue = value + ?.toString() + .replace( + AUTOCOMPLETE_REGEX, + '$1' + ); + + editor.commands.setContent(newValue); + } + }, []); + + return ( + 0 && rawErrors} + > + + + + + ); +}; diff --git a/libs/novui/src/json-schema-components/widgets/InputWidget.tsx b/libs/novui/src/json-schema-components/widgets/InputWidget.tsx deleted file mode 100644 index 4d2e38efd23..00000000000 --- a/libs/novui/src/json-schema-components/widgets/InputWidget.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { getInputProps, WidgetProps } from '@rjsf/utils'; -import { Input, TextInputType } from '../../components/input/Input'; - -export const InputWidget = (props: WidgetProps) => { - const { type, value, label, schema, onChange, options, required, readonly, rawErrors, disabled } = props; - const inputProps = getInputProps(schema, type, options); - - return ( - { - event.preventDefault(); - onChange(event.target.value); - }} - value={value || value === 0 ? value : ''} - required={required} - label={label} - type={inputProps.type as TextInputType} - error={rawErrors?.length > 0 && rawErrors} - readOnly={readonly} - disabled={disabled} - /> - ); -}; diff --git a/libs/novui/src/json-schema-components/widgets/TextareaWidget.tsx b/libs/novui/src/json-schema-components/widgets/TextareaWidget.tsx deleted file mode 100644 index 485a77a4d61..00000000000 --- a/libs/novui/src/json-schema-components/widgets/TextareaWidget.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { getInputProps, WidgetProps } from '@rjsf/utils'; -import { TextInputType, Textarea } from '../../components'; - -export const TextareaWidget = (props: WidgetProps) => { - const { type, value, label, schema, onChange, options, required, readonly, rawErrors, disabled } = props; - const inputProps = getInputProps(schema, type, options); - - return ( -