From c7f67c1fabf2222219ab380b08a9026e0610a9a6 Mon Sep 17 00:00:00 2001 From: ainouzgali Date: Wed, 17 Jul 2024 18:25:26 +0300 Subject: [PATCH 01/29] feat(web,novui): initial implementation of var autocomplete --- .../WorkflowStepEditorControlsPanel.tsx | 29 + libs/novui/package.json | 9 + .../json-schema-components/JsonSchemaForm.tsx | 5 +- .../widgets/MentionList.tsx | 91 ++ .../widgets/TextareaWidget.tsx | 144 ++- packages/framework/src/client.ts | 3 +- pnpm-lock.yaml | 823 ++++++++++++++++-- 7 files changed, 1020 insertions(+), 84 deletions(-) create mode 100644 libs/novui/src/json-schema-components/widgets/MentionList.tsx 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 616b2efc356..df025a36397 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 } from '@novu/novui/jsx'; import { useDebouncedCallback } from '@novu/novui'; import { useTelemetry } from '../../../../hooks/useNovuAPI'; +import { SystemVariablesWithTypes } from '@novu/shared'; export type OnChangeType = 'step' | 'payload'; @@ -42,11 +43,38 @@ export const WorkflowStepEditorControlsPanel: FC 0 ); }, [workflow?.payload?.schema, workflow?.options?.payloadSchema, workflow?.payloadSchema]); + const systemVars = Object.keys(SystemVariablesWithTypes) + .filter((name) => name === 'subscriber') + .flatMap((name) => { + const type = SystemVariablesWithTypes[name]; + if (typeof type === 'object') { + return Object.keys(type).map((subName) => { + return `${name}.${subName}`; + }); + } + + return name; + }); + + const payloadProperties = useMemo(() => { + return Object.keys( + workflow?.payload?.schema?.properties || + workflow?.options?.payloadSchema?.properties || + workflow?.payloadSchema?.properties || + {} + ).map((name) => `payload.${name}`); + }, [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]); + const controlProperties = useMemo(() => { + return Object.keys(step?.controls?.schema?.properties || step?.inputs?.schema?.properties || {}).map( + (name) => `controls.${name}` + ); + }, [step?.controls?.schema, step?.inputs?.schema]); + const handleOnChange = useDebouncedCallback(async (type: OnChangeType, data: any, id?: string) => { onChange(type, data, id); }, TYPING_DEBOUNCE_TIME_MS); @@ -86,6 +114,7 @@ export const WorkflowStepEditorControlsPanel: FC handleOnChange('step', data, id)} schema={step?.controls?.schema || step?.inputs?.schema || {}} formData={defaultControls || {}} + variables={[...(systemVars || []), ...(payloadProperties || []), ...(controlProperties || [])]} /> diff --git a/libs/novui/package.json b/libs/novui/package.json index 946aa87dbe3..310f9a588fc 100644 --- a/libs/novui/package.json +++ b/libs/novui/package.json @@ -132,10 +132,19 @@ "@mantine/code-highlight": "^7.10.2", "@mantine/core": "^7.10.0", "@mantine/hooks": "^7.10.0", + "@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/starter-kit": "^3.0.0", + "@tiptap/suggestion": "^2.5.0", "react-icons": "^5.0.1" } } diff --git a/libs/novui/src/json-schema-components/JsonSchemaForm.tsx b/libs/novui/src/json-schema-components/JsonSchemaForm.tsx index 9360832a6d5..fc4b13fb14f 100644 --- a/libs/novui/src/json-schema-components/JsonSchemaForm.tsx +++ b/libs/novui/src/json-schema-components/JsonSchemaForm.tsx @@ -30,7 +30,9 @@ 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. @@ -59,6 +61,7 @@ export function JsonSchemaForm(props: JsonSchemaFormProps; + +export const MentionList = forwardRef( + ({ clientRect, command, query, items }, ref) => { + const handleCommand = (id: string) => { + const item = items.find((item) => item.id === id); + if (!item) return; + command(item); + }; + + return createPortal( + + +
+ + + + {items.map((item) => { + return ( + handleCommand(item.id)}> + {item.label} + + ); + })} + +
, + document.body + ); + } +); +const stylesTry = { + root: css({ + background: 'input.surface !important', + borderColor: 'input.border !important', + }), + item: css({ + padding: '50 !important', + marginY: '25', + borderRadius: '50 !important', + + overflow: 'none !important', + textOverflow: 'ellipsis', + color: 'typography.text.main !important', + _hover: { + bg: 'select.option.surface.hover !important', + }, + _selected: { + fontWeight: 'strong', + bg: 'select.option.surface.selected !important', + }, + }), + dropdown: css({ + bg: 'surface.popover !important', + borderRadius: 'input !important', + padding: '25', + marginY: '25', + border: 'none !important', + boxShadow: 'medium !important', + color: 'typography.text.main', + maxHeight: '[200px]', + overflow: 'none !important', + overflowY: 'auto', + textOverflow: 'ellipsis', + }), +}; diff --git a/libs/novui/src/json-schema-components/widgets/TextareaWidget.tsx b/libs/novui/src/json-schema-components/widgets/TextareaWidget.tsx index 485a77a4d61..87359be00b0 100644 --- a/libs/novui/src/json-schema-components/widgets/TextareaWidget.tsx +++ b/libs/novui/src/json-schema-components/widgets/TextareaWidget.tsx @@ -1,24 +1,140 @@ import { getInputProps, WidgetProps } from '@rjsf/utils'; import { TextInputType, Textarea } from '../../components'; +import '@mantine/tiptap/styles.css'; +import { RichTextEditor } from '@mantine/tiptap'; +import { useEditor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Text from '@tiptap/extension-text'; +import Paragraph from '@tiptap/extension-paragraph'; +import { ReactRenderer } from '@tiptap/react'; + +import { MentionList } from './MentionList'; +import { Input } from '@mantine/core'; +import { css, cx } from '../../../styled-system/css'; +import Document from '@tiptap/extension-document'; +import Mention from '@tiptap/extension-mention'; + +import { input } from '../../../styled-system/recipes'; +import { splitCssProps } from '../../../styled-system/jsx'; export const TextareaWidget = (props: WidgetProps) => { - const { type, value, label, schema, onChange, options, required, readonly, rawErrors, disabled } = props; - const inputProps = getInputProps(schema, type, options); + const { type, value, label, schema, formContext, onChange, options, required, readonly, rawErrors, disabled } = props; + const inputProps1 = getInputProps(schema, type, options); + const [variantProps, inputProps] = input.splitVariantProps({}); + const [cssProps, localProps] = splitCssProps(inputProps); + const variables = formContext; + // const { onChange, className, rightSection, ...otherProps } = localProps; + const classNames = input(variantProps); + + const editor = useEditor({ + extensions: [ + // StarterKit, + Document, + Paragraph, + Text, + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + renderHTML: ({ options, node }) => { + const closeTag = '}}'; + + return [ + 'span', + { + style: `border: 1px solid #ccc; padding: 2px 4px; border-radius: 4px;`, + }, + `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}${closeTag}`, + ]; + }, + renderText: ({ options, node }) => { + const closeTag = '}}'; + + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}${closeTag}`; + }, + suggestion: { + items: ({ query }) => { + return variables + ?.map((variable) => { + return { label: variable, id: variable }; + }) + .filter((item) => item.label.includes(query)); + }, + char: '{{', + render() { + let reactRenderer: ReactRenderer | null = null; + + return { + onStart: (props) => { + reactRenderer = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }); + }, + onUpdate(props) { + reactRenderer?.updateProps(props); + }, + onExit() { + reactRenderer?.destroy(); + }, + }; + }, + }, + }), + ], + content: value, + onUpdate: ({ editor }) => { + const content = editor.isEmpty ? undefined : editor.getText(); + onChange(content); + }, + }); + const error = rawErrors?.length > 0 && rawErrors; return ( -