Skip to content

Commit

Permalink
feat(novui, web, framework): Step control autocomplete (novuhq#6330)
Browse files Browse the repository at this point in the history
Co-authored-by: Joel Anton <[email protected]>
Co-authored-by: Richard Fontein <[email protected]>
  • Loading branch information
3 people authored Aug 27, 2024
1 parent cc519c5 commit 5149f3d
Show file tree
Hide file tree
Showing 40 changed files with 1,715 additions and 84 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
56 changes: 56 additions & 0 deletions apps/api/src/app/events/e2e/bridge-trigger.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,62 @@ contexts.forEach((context: Context) => {
});

it(`should trigger the bridge workflow with control default and payload data [${context.name}]`, async () => {
const workflowId = `default-payload-params-workflow-${context.name + '-' + uuidv4()}`;
const newWorkflow = workflow(
workflowId,
async ({ step, payload }) => {
await step.email(
'send-email',
async (controls) => {
return {
subject: 'prefix ' + controls.name,
body: 'Body result',
};
},
{
controlSchema: {
type: 'object',
properties: {
name: { type: 'string', default: 'Hello {{payload.name}}' },
},
} as const,
}
);
},
{
payloadSchema: {
type: 'object',
properties: {
name: { type: 'string', default: 'default_name' },
},
required: [],
additionalProperties: false,
} as const,
}
);

await bridgeServer.start({ workflows: [newWorkflow] });

if (context.isStateful) {
await discoverAndSyncBridge(session, workflowsRepository, workflowId, bridgeServer);
}

await triggerEvent(session, workflowId, subscriber, {}, bridge);
await session.awaitRunningJobs();
await triggerEvent(session, workflowId, subscriber, { name: 'payload_name' }, bridge);
await session.awaitRunningJobs();

const sentMessage = await messageRepository.find({
_environmentId: session.environment._id,
_subscriberId: subscriber._id,
channel: StepTypeEnum.EMAIL,
});

expect(sentMessage.length).to.be.eq(2);
expect(sentMessage[1].subject).to.include('prefix Hello default_name');
expect(sentMessage[0].subject).to.include('prefix Hello payload_name');
});
it(`should trigger the bridge workflow with control default and payload data [${context.name}] - with backwards compatability for payload variable`, async () => {
const workflowId = `default-payload-params-workflow-${context.name + '-' + uuidv4()}`;
const newWorkflow = workflow(
workflowId,
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: 'docked !important', // !important is necessary to override Mantine's z-index
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
18 changes: 14 additions & 4 deletions apps/web/src/components/workflow/preview/ErrorPrettyRender.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { css } from '@novu/novui/css';
import { Button, Text, Title } from '@novu/novui';
import { Center, Stack, VStack } from '@novu/novui/jsx';
import { IconErrorOutline, IconExpandLess, IconExpandMore } from '@novu/novui/icons';
import { useDisclosure } from '@mantine/hooks';
import { css } from '@novu/novui/css';
import { Text } from '@novu/novui';
import { hstack } from '@novu/novui/patterns';
import { Center, Stack } from '@novu/novui/jsx';
import { IconErrorOutline, IconExpandLess, IconExpandMore } from '@novu/novui/icons';

export function ErrorPrettyRender({ error: unparsedError }) {
const [isExpanded, { toggle }] = useDisclosure();
const error = 'response' in unparsedError ? unparsedError?.response?.data : unparsedError;
/*
* TODO: find a way to import ErrorCodeEnum from @novu/framework without transiently importing
* types that are not available in the browser, like `crypto`
*/
const isInvalidControlSyntax = error?.code === 'StepControlCompilationFailedError';

// If invalid syntax of var (e.g. missing closing bracket {{var {{var}), show preview as loading.
if (isInvalidControlSyntax) {
return null;
}

return (
<Stack
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
@@ -1,8 +1,8 @@
import { FC, useEffect, useMemo } from 'react';
import { FC, useMemo } from 'react';

import { Button, JsonSchemaForm, Tabs, Title } from '@novu/novui';
import { IconOutlineEditNote, IconOutlineTune, IconOutlineSave } from '@novu/novui/icons';
import { css, cx } from '@novu/novui/css';
import { css } from '@novu/novui/css';
import { Container, Flex } from '@novu/novui/jsx';
import { useDebouncedCallback } from '@novu/novui';

Expand All @@ -11,6 +11,9 @@ import { When } from '../../../../components/utils/When';
import { ControlsEmptyPanel } from './ControlsEmptyPanel';
import { useTelemetry } from '../../../../hooks/useNovuAPI';
import { PATHS } from '../../../../components/docs/docs.const';
import { getSuggestionVariables, subscriberVariables } from '../../../utils';
import { useFeatureFlag } from '../../../../hooks/useFeatureFlag';
import { FeatureFlagsKeysEnum } from '@novu/shared';

export type OnChangeType = 'step' | 'payload';

Expand Down Expand Up @@ -38,25 +41,35 @@ export const WorkflowStepEditorControlsPanel: FC<IWorkflowStepEditorControlsPane
}) => {
const track = useTelemetry();
const { Component, toggle, setPath } = useDocsModal();
const havePayloadProperties = useMemo(() => {
return (
Object.keys(
workflow?.payload?.schema?.properties ||
workflow?.options?.payloadSchema?.properties ||
workflow?.payloadSchema?.properties ||
{}
).length > 0
);

const [payloadProperties, havePayloadProperties] = useMemo(() => {
const payloadObject =
workflow?.payload?.schema?.properties ||
workflow?.options?.payloadSchema?.properties ||
workflow?.payloadSchema?.properties ||
{};

return [getSuggestionVariables(payloadObject, 'payload'), Object.keys(payloadObject).length > 0];
}, [workflow?.payload?.schema, workflow?.options?.payloadSchema, workflow?.payloadSchema]);

const haveControlProperties = useMemo(() => {
return Object.keys(step?.controls?.schema?.properties || step?.inputs?.schema?.properties || {}).length > 0;
const [haveControlProperties] = useMemo(() => {
const controlsObject = step?.controls?.schema?.properties || step?.inputs?.schema?.properties || {};

return [Object.keys(controlsObject).length > 0];
}, [step?.controls?.schema, step?.inputs?.schema]);

const handleOnChange = useDebouncedCallback(async (type: OnChangeType, data: any, id?: string) => {
onChange(type, data, id);
}, TYPING_DEBOUNCE_TIME_MS);

const isAutocompleteEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_CONTROLS_AUTOCOMPLETE_ENABLED);

// set variables to undefined when autocomplete flag is disabled to use plain text entry.
const variables = useMemo(
() => (isAutocompleteEnabled ? [...(subscriberVariables || []), ...(payloadProperties || [])] : undefined),
[payloadProperties, isAutocompleteEnabled]
);

return (
<>
<Tabs
Expand Down Expand Up @@ -94,6 +107,7 @@ export const WorkflowStepEditorControlsPanel: FC<IWorkflowStepEditorControlsPane
onChange={(data, id) => handleOnChange('step', data, id)}
schema={step?.controls?.schema || step?.inputs?.schema || {}}
formData={defaultControls || {}}
variables={variables}
/>
</When>
<When truthy={!haveControlProperties}>
Expand Down Expand Up @@ -143,7 +157,7 @@ export const WorkflowStepEditorControlsPanel: FC<IWorkflowStepEditorControlsPane
};

export const formContainerClassName = css({
h: '72vh',
h: '[72vh]',
overflowY: 'auto',
scrollbar: 'hidden',
});
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';
20 changes: 20 additions & 0 deletions apps/web/src/studio/utils/variables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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}`);
}
if (schemaItem?.type === 'array') {
// TODO: determine how we should handle dynamic (array-based controls)
return;
}

return `${namespace}.${name}`;
})
.filter((variable) => !!variable);
}

export const subscriberVariables = getSuggestionVariables(SystemVariablesWithTypes.subscriber, 'subscriber');
22 changes: 15 additions & 7 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 @@ -131,13 +130,22 @@
}
},
"dependencies": {
"@mantine/code-highlight": "^7.10.2",
"@mantine/core": "^7.10.0",
"@mantine/hooks": "^7.10.0",
"@rjsf/core": "^5.17.1",
"@rjsf/utils": "^5.17.1",
"@mantine/code-highlight": "^7.12.1",
"@mantine/core": "^7.12.1",
"@mantine/hooks": "^7.12.1",
"@mantine/tiptap": "^7.12.1",
"@rjsf/core": "^5.20.0",
"@rjsf/utils": "^5.20.0",
"@rjsf/validator-ajv8": "^5.17.1",
"@tanstack/react-table": "^8.17.3",
"@tiptap/extension-document": "^2.6.6",
"@tiptap/extension-history": "^2.6.6",
"@tiptap/extension-mention": "^2.6.6",
"@tiptap/extension-paragraph": "^2.6.6",
"@tiptap/extension-text": "^2.6.6",
"@tiptap/pm": "^2.6.6",
"@tiptap/react": "^2.6.6",
"@tiptap/suggestion": "^2.6.6",
"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';
37 changes: 36 additions & 1 deletion libs/novui/src/json-schema-components/JsonSchemaForm.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ export default {
argTypes: {},
} as Meta<typeof JsonSchemaForm>;

const VARIABLES = [
'ctrl.a',
'ctrl.b',
'ctrl.c',
'ctrl.d',
'ctrl.e',
'payload.var',
'payload.obj.var',
'fakeAutocomplete.foo',
'fakeAutocomplete.bar',
'fakeAutocomplete.fizz',
'fakeAutocomplete.buzz',
'fakeAutocomplete.croissants',
'fakeAutocomplete.olympics',
'fakeAutocomplete.aReallyLongStringThatShouldOverflowFromTheContainer',
];

const Template: StoryFn<typeof JsonSchemaForm> = ({ colorPalette, ...args }) => {
const onSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
Expand All @@ -27,7 +44,7 @@ const Template: StoryFn<typeof JsonSchemaForm> = ({ colorPalette, ...args }) =>
Save
</Button>
</HStack>
<JsonSchemaForm {...args} />
<JsonSchemaForm {...args} variables={VARIABLES} />
</form>
);
};
Expand Down Expand Up @@ -59,6 +76,7 @@ const schema: RJSFSchema = {
country: {
type: 'string',
title: 'Country',
default: `Hello {{${VARIABLES[0]}}}, my name is {{invalid}} yo`,
},
address: {
type: 'string',
Expand Down Expand Up @@ -204,3 +222,20 @@ export const ArrayDesigns = Template.bind({});
ArrayDesigns.args = {
schema: ARRAY_DESIGNS_SCHEMA,
};

const SIMPLE_AUTOCOMPLETE_SCHEMA: RJSFSchema = {
type: 'object',
title: 'Simple autocomplete',
properties: {
country: {
type: 'string',
title: 'Name',
default: `Hello {{${VARIABLES[0]}}}, {{ ${VARIABLES[1]} | upcase }} {{invalidRef}} {{badSyntax`,
},
},
};

export const SimpleAutocomplete = Template.bind({});
SimpleAutocomplete.args = {
schema: SIMPLE_AUTOCOMPLETE_SCHEMA,
};
Loading

0 comments on commit 5149f3d

Please sign in to comment.