From ec35a01736b8c1fef4dddc1cf0368c1b37c84970 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Thu, 17 Oct 2024 16:48:04 +0200 Subject: [PATCH] feat(framework, api, web, application-generic): Add `name` and `description` to Framework workflow options (#6708) --- .../app/bridge/usecases/sync/sync.usecase.ts | 66 ++++-- .../api/src/app/events/e2e/bridge-sync.e2e.ts | 197 +++++++++++++++++- .../LocalStudioSidebarContent.tsx | 5 +- .../LocalStudioSidebarToggleButton.tsx | 8 +- .../editor_v2/useWorkflowDetailPageForm.ts | 2 + .../workflows/node-view/WorkflowNodes.tsx | 11 +- .../node-view/WorkflowsDetailPage.tsx | 6 +- .../StudioWorkflowSettingsSidePanel.tsx | 3 + .../WorkflowDetailFormContextProvider.tsx | 2 +- .../WorkflowGeneralSettingsForm.tsx | 112 ++++++++-- .../WorkflowSettingsSidePanelContent.tsx | 5 +- .../components/workflows/preferences/types.ts | 2 + .../workflows/table/WorkflowsTable.tsx | 46 ---- .../workflows/table/WorkflowsTable.types.ts | 3 - .../table/WorkflowsTableCellRenderers.tsx | 63 ------ .../components/workflows/table/index.ts | 1 - .../test-workflow/WorkflowsTestPage.tsx | 6 +- apps/web/src/studio/hooks/useBridgeAPI.ts | 5 +- .../src/studio/pages/StudioStepEditorPage.tsx | 2 +- apps/web/src/studio/types.ts | 22 -- .../update-workflow.usecase.ts | 2 +- libs/design-system/src/select/Select.tsx | 12 +- libs/design-system/src/select/Select.types.ts | 2 +- packages/framework/src/resources/index.ts | 2 +- .../framework/src/resources/workflow/index.ts | 181 +--------------- .../resources/workflow/workflow.resource.ts | 182 ++++++++++++++++ .../src/resources/workflow/workflow.test.ts | 36 +++- .../framework/src/types/discover.types.ts | 2 + .../framework/src/types/workflow.types.ts | 18 ++ 29 files changed, 623 insertions(+), 381 deletions(-) delete mode 100644 apps/web/src/studio/components/workflows/table/WorkflowsTable.tsx delete mode 100644 apps/web/src/studio/components/workflows/table/WorkflowsTable.types.ts delete mode 100644 apps/web/src/studio/components/workflows/table/WorkflowsTableCellRenderers.tsx delete mode 100644 apps/web/src/studio/components/workflows/table/index.ts create mode 100644 packages/framework/src/resources/workflow/workflow.resource.ts diff --git a/apps/api/src/app/bridge/usecases/sync/sync.usecase.ts b/apps/api/src/app/bridge/usecases/sync/sync.usecase.ts index b5556063423..2d93d73b2c5 100644 --- a/apps/api/src/app/bridge/usecases/sync/sync.usecase.ts +++ b/apps/api/src/app/bridge/usecases/sync/sync.usecase.ts @@ -84,7 +84,7 @@ export class Sync { return persistedWorkflowsInBridge; } - private async updateBridgeUrl(command: SyncCommand) { + private async updateBridgeUrl(command: SyncCommand): Promise { await this.environmentRepository.update( { _id: command.environmentId }, { @@ -100,7 +100,10 @@ export class Sync { ); } - private async disposeOldWorkflows(command: SyncCommand, createdWorkflows: NotificationTemplateEntity[]) { + private async disposeOldWorkflows( + command: SyncCommand, + createdWorkflows: NotificationTemplateEntity[] + ): Promise { const persistedWorkflowIdsInBridge = createdWorkflows.map((i) => i._id); const workflowsToDelete = await this.findAllWorkflowsWithOtherIds(command, persistedWorkflowIdsInBridge); @@ -119,7 +122,10 @@ export class Sync { ); } - private async findAllWorkflowsWithOtherIds(command: SyncCommand, persistedWorkflowIdsInBridge: string[]) { + private async findAllWorkflowsWithOtherIds( + command: SyncCommand, + persistedWorkflowIdsInBridge: string[] + ): Promise { return await this.notificationTemplateRepository.find({ _environmentId: command.environmentId, type: { @@ -132,7 +138,10 @@ export class Sync { }); } - private async createWorkflows(command: SyncCommand, workflowsFromBridge: DiscoverWorkflowOutput[]) { + private async createWorkflows( + command: SyncCommand, + workflowsFromBridge: DiscoverWorkflowOutput[] + ): Promise { return Promise.all( workflowsFromBridge.map(async (workflow) => { const workflowExist = await this.notificationTemplateRepository.findByTriggerIdentifier( @@ -183,7 +192,12 @@ export class Sync { ); } - private async createWorkflow(notificationGroupId: string, isWorkflowActive, command: SyncCommand, workflow) { + private async createWorkflow( + notificationGroupId: string, + isWorkflowActive: boolean, + command: SyncCommand, + workflow: DiscoverWorkflowOutput + ): Promise { return await this.createWorkflowUsecase.execute( CreateWorkflowCommand.create({ origin: WorkflowOriginEnum.EXTERNAL, @@ -193,7 +207,8 @@ export class Sync { environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, - name: workflow.workflowId, + name: this.getWorkflowName(workflow), + triggerIdentifier: workflow.workflowId, __source: WorkflowCreationSourceEnum.BRIDGE, steps: this.mapSteps(workflow.steps), /** @deprecated */ @@ -209,23 +224,28 @@ export class Sync { /** @deprecated */ (workflow.options?.payloadSchema as Record), active: isWorkflowActive, - description: this.castToAnyNotSupportedParam(workflow.options).description, + description: this.getWorkflowDescription(workflow), data: this.castToAnyNotSupportedParam(workflow).options?.data, - tags: workflow.tags || [], + tags: this.getWorkflowTags(workflow), critical: this.castToAnyNotSupportedParam(workflow.options)?.critical ?? false, preferenceSettings: this.castToAnyNotSupportedParam(workflow.options)?.preferenceSettings, }) ); } - private async updateWorkflow(workflowExist, command: SyncCommand, workflow) { + private async updateWorkflow( + workflowExist: NotificationTemplateEntity, + command: SyncCommand, + workflow: DiscoverWorkflowOutput + ): Promise { return await this.updateWorkflowUsecase.execute( UpdateWorkflowCommand.create({ id: workflowExist._id, environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, - name: workflow.workflowId, + name: this.getWorkflowName(workflow), + workflowId: workflow.workflowId, steps: this.mapSteps(workflow.steps, workflowExist), inputs: { schema: workflow.controls?.schema || workflow.inputs.schema, @@ -238,9 +258,9 @@ export class Sync { (workflow.payload?.schema as Record) || (workflow.options?.payloadSchema as Record), type: WorkflowTypeEnum.BRIDGE, - description: this.castToAnyNotSupportedParam(workflow.options).description, + description: this.getWorkflowDescription(workflow), data: this.castToAnyNotSupportedParam(workflow.options)?.data, - tags: workflow.tags, + tags: this.getWorkflowTags(workflow), active: this.castToAnyNotSupportedParam(workflow.options)?.active ?? true, critical: this.castToAnyNotSupportedParam(workflow.options)?.critical ?? false, preferenceSettings: this.castToAnyNotSupportedParam(workflow.options)?.preferenceSettings, @@ -248,7 +268,10 @@ export class Sync { ); } - private mapSteps(commandWorkflowSteps: DiscoverStepOutput[], workflow?: NotificationTemplateEntity | undefined) { + private mapSteps( + commandWorkflowSteps: DiscoverStepOutput[], + workflow?: NotificationTemplateEntity | undefined + ): NotificationStep[] { const steps: NotificationStep[] = commandWorkflowSteps.map((step) => { const foundStep = workflow?.steps?.find((workflowStep) => workflowStep.stepId === step.stepId); @@ -275,7 +298,10 @@ export class Sync { return steps; } - private async getNotificationGroup(notificationGroupIdCommand: string | undefined, environmentId: string) { + private async getNotificationGroup( + notificationGroupIdCommand: string | undefined, + environmentId: string + ): Promise { let notificationGroupId = notificationGroupIdCommand; if (!notificationGroupId) { @@ -293,6 +319,18 @@ export class Sync { return notificationGroupId; } + private getWorkflowName(workflow: DiscoverWorkflowOutput): string { + return workflow.name || workflow.workflowId; + } + + private getWorkflowDescription(workflow: DiscoverWorkflowOutput): string { + return workflow.description || ''; + } + + private getWorkflowTags(workflow: DiscoverWorkflowOutput): string[] { + return workflow.tags || []; + } + private castToAnyNotSupportedParam(param: any): any { return param as any; } diff --git a/apps/api/src/app/events/e2e/bridge-sync.e2e.ts b/apps/api/src/app/events/e2e/bridge-sync.e2e.ts index bd81c734af5..8a6df9d0692 100644 --- a/apps/api/src/app/events/e2e/bridge-sync.e2e.ts +++ b/apps/api/src/app/events/e2e/bridge-sync.e2e.ts @@ -18,7 +18,7 @@ describe('Bridge Sync - /bridge/sync (POST)', async () => { showButton: { type: 'boolean', default: true }, }, }, - }; + } as const; let bridgeServer: BridgeServer; beforeEach(async () => { @@ -164,7 +164,7 @@ describe('Bridge Sync - /bridge/sync (POST)', async () => { }; }, { - controlSchema: inputPostPayload.schema as any, + controlSchema: inputPostPayload.schema, } ); }, @@ -364,4 +364,197 @@ describe('Bridge Sync - /bridge/sync (POST)', async () => { expect(response.status).to.equal(200); }); + + it('should create a workflow with a name', async () => { + const workflowId = 'hello-world-description'; + const newWorkflow = workflow( + workflowId, + async ({ step }) => { + await step.email('send-email', () => ({ + subject: 'Welcome!', + body: 'Hello there', + })); + }, + { + name: 'My Workflow', + } + ); + await bridgeServer.start({ workflows: [newWorkflow] }); + + const result = await session.testAgent.post(`/v1/bridge/sync`).send({ + bridgeUrl: bridgeServer.serverPath, + }); + + const workflows = await workflowsRepository.find({ + _environmentId: session.environment._id, + _id: result.body.data[0]._id, + }); + expect(workflows.length).to.equal(1); + + const workflowData = workflows[0]; + expect(workflowData.name).to.equal('My Workflow'); + }); + + it('should create a workflow with a name that defaults to the workflowId', async () => { + const workflowId = 'hello-world-description'; + const newWorkflow = workflow(workflowId, async ({ step }) => { + await step.email('send-email', () => ({ + subject: 'Welcome!', + body: 'Hello there', + })); + }); + await bridgeServer.start({ workflows: [newWorkflow] }); + + const result = await session.testAgent.post(`/v1/bridge/sync`).send({ + bridgeUrl: bridgeServer.serverPath, + }); + + const workflows = await workflowsRepository.find({ + _environmentId: session.environment._id, + _id: result.body.data[0]._id, + }); + expect(workflows.length).to.equal(1); + + const workflowData = workflows[0]; + expect(workflowData.name).to.equal(workflowId); + }); + + it('should preserve the original workflow resource when syncing a workflow that has added a name', async () => { + const workflowId = 'hello-world-description'; + const newWorkflow = workflow(workflowId, async ({ step }) => { + await step.email('send-email', () => ({ + subject: 'Welcome!', + body: 'Hello there', + })); + }); + await bridgeServer.start({ workflows: [newWorkflow] }); + + const result = await session.testAgent.post(`/v1/bridge/sync`).send({ + bridgeUrl: bridgeServer.serverPath, + }); + const workflowDbId = result.body.data[0]._id; + + const workflows = await workflowsRepository.find({ + _environmentId: session.environment._id, + _id: workflowDbId, + }); + expect(workflows.length).to.equal(1); + + const workflowData = workflows[0]; + expect(workflowData.name).to.equal(workflowId); + + await bridgeServer.stop(); + + bridgeServer = new BridgeServer(); + const newWorkflowWithName = workflow( + workflowId, + async ({ step }) => { + await step.email('send-email', () => ({ + subject: 'Welcome!', + body: 'Hello there', + })); + }, + { + name: 'My Workflow', + } + ); + await bridgeServer.start({ workflows: [newWorkflowWithName] }); + + await session.testAgent.post(`/v1/bridge/sync`).send({ + bridgeUrl: bridgeServer.serverPath, + }); + + const workflowsWithName = await workflowsRepository.find({ + _environmentId: session.environment._id, + _id: workflowDbId, + }); + expect(workflowsWithName.length).to.equal(1); + + const workflowDataWithName = workflowsWithName[0]; + expect(workflowDataWithName.name).to.equal('My Workflow'); + }); + + it('should create a workflow with a description', async () => { + const workflowId = 'hello-world-description'; + const newWorkflow = workflow( + workflowId, + async ({ step }) => { + await step.email('send-email', () => ({ + subject: 'Welcome!', + body: 'Hello there', + })); + }, + { + description: 'This is a description', + } + ); + await bridgeServer.start({ workflows: [newWorkflow] }); + + const result = await session.testAgent.post(`/v1/bridge/sync`).send({ + bridgeUrl: bridgeServer.serverPath, + }); + + const workflows = await workflowsRepository.find({ + _environmentId: session.environment._id, + _id: result.body.data[0]._id, + }); + expect(workflows.length).to.equal(1); + + const workflowData = workflows[0]; + expect(workflowData.description).to.equal('This is a description'); + }); + + it('should unset the workflow description after the description is removed', async () => { + const workflowId = 'hello-world-description'; + const newWorkflow = workflow( + workflowId, + async ({ step }) => { + await step.email('send-email', () => ({ + subject: 'Welcome!', + body: 'Hello there', + })); + }, + { + description: 'This is a description', + } + ); + await bridgeServer.start({ workflows: [newWorkflow] }); + + const result = await session.testAgent.post(`/v1/bridge/sync`).send({ + bridgeUrl: bridgeServer.serverPath, + }); + const workflowDbId = result.body.data[0]._id; + const workflows = await workflowsRepository.find({ + _environmentId: session.environment._id, + _id: workflowDbId, + }); + expect(workflows.length).to.equal(1); + + const workflowData = workflows[0]; + expect(workflowData.description).to.equal('This is a description'); + + await bridgeServer.stop(); + + bridgeServer = new BridgeServer(); + const newWorkflowWithName = workflow(workflowId, async ({ step }) => { + await step.email('send-email', () => ({ + subject: 'Welcome!', + body: 'Hello there', + })); + }); + await bridgeServer.start({ workflows: [newWorkflowWithName] }); + + await session.testAgent.post(`/v1/bridge/sync`).send({ + bridgeUrl: bridgeServer.serverPath, + }); + + const workflowsWithDescription = await workflowsRepository.find({ + _environmentId: session.environment._id, + _id: workflowDbId, + }); + expect(workflowsWithDescription.length).to.equal(1); + + const workflowDataWithName = workflowsWithDescription[0]; + expect(workflowDataWithName.description).to.equal(''); + }); }); diff --git a/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarContent.tsx b/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarContent.tsx index 1e5a449a048..2b763c1c1b2 100644 --- a/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarContent.tsx +++ b/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarContent.tsx @@ -5,7 +5,7 @@ import { FC } from 'react'; import { token } from '@novu/novui/tokens'; import { css, cx } from '@novu/novui/css'; import { WithLoadingSkeleton } from '@novu/novui'; -import { IBridgeWorkflow } from '../../../../studio/types'; +import type { DiscoverWorkflowOutput } from '@novu/framework'; import { NavMenu } from '../../../nav/NavMenu'; import { NavMenuSection } from '../../../nav/NavMenuSection'; import { LocalStudioSidebarOrganizationDisplay } from './LocalStudioSidebarOrganizationDisplay'; @@ -14,10 +14,9 @@ import { useStudioState } from '../../../../studio/StudioStateProvider'; import { NavMenuButtonInner, rawButtonBaseStyles } from '../../../nav/NavMenuButton/NavMenuButton.shared'; import { useDocsModal } from '../../../docs/useDocsModal'; import { PATHS } from '../../../docs/docs.const'; -import { ROUTES } from '../../../../constants/routes'; type LocalStudioSidebarContentProps = { - workflows: IBridgeWorkflow[]; + workflows: DiscoverWorkflowOutput[]; isLoading?: boolean; }; diff --git a/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarToggleButton.tsx b/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarToggleButton.tsx index 939c429af8d..11dec10c286 100644 --- a/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarToggleButton.tsx +++ b/apps/web/src/components/layout/components/LocalStudioSidebar/LocalStudioSidebarToggleButton.tsx @@ -1,8 +1,8 @@ import { css } from '@novu/novui/css'; import { IconBolt } from '@novu/novui/icons'; import { FC } from 'react'; +import type { DiscoverWorkflowOutput } from '@novu/framework'; import { WORKFLOW_NODE_STEP_ICON_DICTIONARY } from '../../../../studio/components/workflows/node-view/WorkflowNodes'; -import { IBridgeWorkflow } from '../../../../studio/types'; import { getStudioWorkflowLink, getStudioWorkflowStepLink, @@ -11,16 +11,16 @@ import { import { NavMenuLinkButton, NavMenuToggleButton } from '../../../nav/NavMenuButton'; type LocalStudioSidebarToggleButtonProps = { - workflow: IBridgeWorkflow; + workflow: DiscoverWorkflowOutput; }; const linkButtonClassName = css({ padding: '75', _before: { display: 'none' } }); export const LocalStudioSidebarToggleButton: FC = ({ workflow }) => { - const { workflowId, steps } = workflow; + const { workflowId, name, steps } = workflow; return ( - + void; + steps: Pick[] | null; + onStepClick: (step: Pick) => void; onTriggerClick: () => void; } -export const WORKFLOW_NODE_STEP_ICON_DICTIONARY: Record = { +export const WORKFLOW_NODE_STEP_ICON_DICTIONARY: Record<`${ChannelStepEnum | ActionStepEnum}`, IconType> = { email: IconOutlineEmail, in_app: IconOutlineNotifications, sms: IconOutlineSms, @@ -54,7 +53,7 @@ export function WorkflowNodes({ steps, onStepClick, onTriggerClick }: WorkflowNo return ( } + icon={} title={step.stepId} onClick={handleStepClick} /> diff --git a/apps/web/src/studio/components/workflows/node-view/WorkflowsDetailPage.tsx b/apps/web/src/studio/components/workflows/node-view/WorkflowsDetailPage.tsx index ec5dc841fd9..26956b6f016 100644 --- a/apps/web/src/studio/components/workflows/node-view/WorkflowsDetailPage.tsx +++ b/apps/web/src/studio/components/workflows/node-view/WorkflowsDetailPage.tsx @@ -6,6 +6,7 @@ import { HStack, Stack } from '@novu/novui/jsx'; import { token } from '@novu/novui/tokens'; import { FeatureFlagsKeysEnum } from '@novu/shared'; import { useEffect, useState } from 'react'; +import type { DiscoverWorkflowOutput } from '@novu/framework'; import { useFeatureFlag } from '../../../../hooks/useFeatureFlag'; import { useTelemetry } from '../../../../hooks/useNovuAPI'; import { useWorkflow } from '../../../hooks/useBridgeAPI'; @@ -41,7 +42,10 @@ const BaseWorkflowsDetailPage = () => { return ; } - const title = workflow?.workflowId; + // After loading has completed, we can safely cast the workflow to DiscoverWorkflowOutput + const fetchedWorkflow = workflow as DiscoverWorkflowOutput; + + const title = fetchedWorkflow?.name || fetchedWorkflow.workflowId; return ( { if (workflow) { setValue('general.workflowId', workflow.workflowId); + setValue('general.name', workflow.name || workflow.workflowId); + setValue('general.description', workflow.description || ''); + setValue('general.tags', workflow.tags || []); setValue('preferences', buildWorkflowPreferences(workflow.preferences)); } }, [setValue, workflow]); diff --git a/apps/web/src/studio/components/workflows/preferences/WorkflowDetailFormContextProvider.tsx b/apps/web/src/studio/components/workflows/preferences/WorkflowDetailFormContextProvider.tsx index b64d0372eb9..c4683b27364 100644 --- a/apps/web/src/studio/components/workflows/preferences/WorkflowDetailFormContextProvider.tsx +++ b/apps/web/src/studio/components/workflows/preferences/WorkflowDetailFormContextProvider.tsx @@ -1,4 +1,4 @@ -import { buildWorkflowPreferences, WorkflowPreferences } from '@novu/shared'; +import { WorkflowPreferences } from '@novu/shared'; import { FC, PropsWithChildren } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { WorkflowGeneralSettings } from './types'; diff --git a/apps/web/src/studio/components/workflows/preferences/WorkflowGeneralSettingsForm.tsx b/apps/web/src/studio/components/workflows/preferences/WorkflowGeneralSettingsForm.tsx index 69840d548da..e7b2c0593dc 100644 --- a/apps/web/src/studio/components/workflows/preferences/WorkflowGeneralSettingsForm.tsx +++ b/apps/web/src/studio/components/workflows/preferences/WorkflowGeneralSettingsForm.tsx @@ -1,21 +1,38 @@ import { useClipboard } from '@mantine/hooks'; -import { IconButton, Input } from '@novu/novui'; +import { IconButton, Input, Textarea } from '@novu/novui'; import { IconCheck, IconCopyAll } from '@novu/novui/icons'; -import { Stack } from '@novu/novui/jsx'; +import { Select } from '@novu/design-system'; +import { Stack, Box, styled } from '@novu/novui/jsx'; import { FC } from 'react'; import { Controller, FieldPath, useFormContext } from 'react-hook-form'; +import { token } from '@novu/novui/tokens'; import { WorkflowDetailFormContext } from './WorkflowDetailFormContextProvider'; export type WorkflowGeneralSettingsFieldName = Extract< FieldPath, - 'general.workflowId' | 'general.name' + 'general.workflowId' | 'general.name' | 'general.description' | 'general.tags' >; export type WorkflowGeneralSettingsProps = { - checkShouldDisableField?: (fieldName: WorkflowGeneralSettingsFieldName, fieldValue: string) => boolean; + checkShouldDisableField?: (fieldName: WorkflowGeneralSettingsFieldName) => boolean; checkShouldHideField?: (fieldName: WorkflowGeneralSettingsFieldName) => boolean; }; +const InboxSnippet = () => ( + + {``} + +); + export const WorkflowGeneralSettingsForm: FC = ({ checkShouldDisableField, checkShouldHideField, @@ -29,45 +46,96 @@ export const WorkflowGeneralSettingsForm: FC = ({ return ( + {!checkShouldHideField?.('general.workflowId') && ( + { + return ( + + A unique, lowercase identifier, using only - (dash) separators + + } + rightSection={ copy(field.value)} />} + error={errors?.general?.workflowId?.message} + value={field.value || ''} + disabled={checkShouldDisableField?.(field.name)} + /> + ); + }} + /> + )} {!checkShouldHideField?.('general.name') && ( { return ( + A human-friendly name for the workflow, displayed in the Dashboard and the + + } value={field.value || ''} - disabled={checkShouldDisableField?.(field.name, field.value)} + disabled={checkShouldDisableField?.(field.name)} error={errors?.general?.name?.message} /> ); }} /> )} - {!checkShouldHideField?.('general.workflowId') && ( + {!checkShouldHideField?.('general.description') && ( { return ( - copy(field.value)} />} - error={errors?.general?.workflowId?.message} + label="Description" + description="A brief description of the workflow's purpose for team members" + placeholder="Add a description..." value={field.value || ''} - disabled={checkShouldDisableField?.(field.name, field.value)} + disabled={checkShouldDisableField?.(field.name)} + error={errors?.general?.description?.message} + /> + ); + }} + /> + )} + {!checkShouldHideField?.('general.tags') && ( + { + return ( +