Skip to content

Commit

Permalink
feat(web,novui): initial implementation of var autocomplete in contro…
Browse files Browse the repository at this point in the history
…ls (#6097)

Co-authored-by: Joel Anton <joel@novu.co>
Co-authored-by: Richard Fontein <32132657+rifont@users.noreply.github.com>
  • Loading branch information
3 people authored Jul 31, 2024
1 parent aee2429 commit 43805d4
Show file tree
Hide file tree
Showing 28 changed files with 1,009 additions and 273 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"bcast",
"behaviour",
"bestguess",
"tiptap",
"binipdisplay",
"bitauth",
"bitjson",
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/events/e2e/bridge-trigger.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})}
>
<HStack justifyContent="space-between" width="full" display="flex">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -24,6 +24,9 @@ export const LocalStudioSidebarOrganizationDisplay: FC<LocalStudioSidebarOrganiz
offset={0}
withinPortal
title="Novu Local Studio"
classNames={{
dropdown: css({ bg: 'surface.popover !important', border: 'none !important', shadow: 'medium !important' }),
}}
target={
<Flex gap="50" py="75" px="100" onMouseEnter={open} onMouseLeave={close}>
<img
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import reportWebVitals from './reportWebVitals';
import { LAUNCH_DARKLY_CLIENT_SIDE_ID } from './config';

import './index.css';
import '@novu/novui/components.css';
import '@novu/novui/styles.css';

(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -43,6 +44,16 @@ export const WorkflowStepEditorControlsPanel: FC<IWorkflowStepEditorControlsPane
);
}, [workflow?.payload?.schema, workflow?.options?.payloadSchema, workflow?.payloadSchema]);

const payloadProperties = useMemo(() => {
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]);
Expand Down Expand Up @@ -87,6 +98,7 @@ export const WorkflowStepEditorControlsPanel: FC<IWorkflowStepEditorControlsPane
onChange={(data, id) => handleOnChange('step', data, id)}
schema={step?.controls?.schema || step?.inputs?.schema || {}}
formData={defaultControls || {}}
variables={[...(subscriberVariables || []), ...(payloadProperties || [])]}
/>
</When>
<When truthy={!haveControlProperties}>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/studio/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './routing';
export * from './variables';
14 changes: 14 additions & 0 deletions apps/web/src/studio/utils/variables.ts
Original file line number Diff line number Diff line change
@@ -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');
17 changes: 12 additions & 5 deletions libs/novui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
2 changes: 2 additions & 0 deletions libs/novui/src/index.css
Original file line number Diff line number Diff line change
@@ -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';
13 changes: 12 additions & 1 deletion libs/novui/src/json-schema-components/JsonSchemaForm.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,18 @@ const Template: StoryFn<typeof JsonSchemaForm> = ({ colorPalette, ...args }) =>
Save
</Button>
</HStack>
<JsonSchemaForm {...args} />
<JsonSchemaForm
{...args}
variables={[
'fakeautocomplete.foo',
'fakeautocomplete.bar',
'fakeautocomplete.fizz',
'fakeautocomplete.buzz',
'fakeautocomplete.croissantia',
'fakeautocomplete.olympics',
'fakeautocomplete.reallylongstringthatshouldoverflow',
]}
/>
</form>
);
};
Expand Down
15 changes: 9 additions & 6 deletions libs/novui/src/json-schema-components/JsonSchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -31,13 +31,15 @@ const UI_SCHEMA: UiSchema = {

export type JsonSchemaFormProps<TFormData = any> = JsxStyleProps &
CoreProps &
Pick<FormProps<TFormData>, 'onChange' | 'onSubmit' | 'onBlur' | 'schema' | 'formData' | 'tagName'>;
Pick<FormProps<TFormData>, 'onChange' | 'onSubmit' | 'onBlur' | 'schema' | 'formData' | 'tagName'> & {
variables?: string[];
};

/**
* Specialized form editor for data passed as JSON.
*/
export function JsonSchemaForm<TFormData = any>(props: JsonSchemaFormProps<TFormData>) {
const [cssProps, { className, ...formProps }] = splitCssProps(props);
const [cssProps, { className, variables, ...formProps }] = splitCssProps(props);

return (
<Form
Expand All @@ -60,6 +62,7 @@ export function JsonSchemaForm<TFormData = any>(props: JsonSchemaFormProps<TForm
widgets={WIDGETS}
validator={validator}
autoComplete={'false'}
formContext={{ variables }}
idSeparator={JSON_SCHEMA_FORM_ID_DELIMITER}
liveValidate
templates={{
Expand Down
134 changes: 134 additions & 0 deletions libs/novui/src/json-schema-components/widgets/InputEditorWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useEffect, useMemo, useRef } from 'react';

import { WidgetProps } from '@rjsf/utils';

import { Input } from '@mantine/core';
import { RichTextEditor } from '@mantine/tiptap';

import { type Extensions, useEditor } from '@tiptap/react';
import Text from '@tiptap/extension-text';
import Paragraph from '@tiptap/extension-paragraph';
import { ReactRenderer } from '@tiptap/react';
import Document from '@tiptap/extension-document';
import Mention from '@tiptap/extension-mention';

import { css, cx } from '../../../styled-system/css';
import { input, inputEditorWidget } from '../../../styled-system/recipes';
import { splitCssProps } from '../../../styled-system/jsx';

import { VariableSuggestionList, SuggestionListRef, VariableItem } from './VariableSuggestionList';

const inputEditorClassNames = inputEditorWidget();

const AUTOCOMPLETE_OPEN_TAG = '{{';
const AUTOCOMPLETE_CLOSE_TAG = '}}';

const AUTOCOMPLETE_REGEX = new RegExp(`${AUTOCOMPLETE_OPEN_TAG}(.*?(.*?))${AUTOCOMPLETE_CLOSE_TAG}`, 'gm');

export const InputEditorWidget = (props: WidgetProps) => {
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<ReactRenderer<SuggestionListRef>>(null);

const variablesList = useMemo<VariableItem[]>(() => {
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,
'<span data-id="$1" contenteditable="false" class="suggestion" data-type="mention">$1</span>'
);

editor.commands.setContent(newValue);
}
}, []);

return (
<Input.Wrapper
classNames={classNames}
className={cx('group', css(cssProps))}
required={required}
label={label}
description={props.schema.description}
error={rawErrors?.length > 0 && rawErrors}
>
<RichTextEditor classNames={inputEditorClassNames} editor={editor}>
<RichTextEditor.Content />
</RichTextEditor>
</Input.Wrapper>
);
};
24 changes: 0 additions & 24 deletions libs/novui/src/json-schema-components/widgets/InputWidget.tsx

This file was deleted.

Loading

0 comments on commit 43805d4

Please sign in to comment.