Skip to content

Commit

Permalink
feat(api-service,dashboard): Step integration issues
Browse files Browse the repository at this point in the history
  • Loading branch information
desiprisg committed Feb 7, 2025
1 parent 0503fe7 commit 9c388f5
Show file tree
Hide file tree
Showing 12 changed files with 164 additions and 88 deletions.
3 changes: 2 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,8 @@
"querybuilder",
"liquified",
"autoload",
"novugo"
"novugo",
"titleize"
],
"flagWords": [],
"patterns": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import Ajv, { ErrorObject } from 'ajv';
import addFormats from 'ajv-formats';
import { AdditionalOperation, RulesLogic } from 'json-logic-js';
import { Injectable } from '@nestjs/common';
import { ControlValuesRepository } from '@novu/dal';
import { ControlValuesRepository, IntegrationRepository } from '@novu/dal';
import {
ContentIssue,
StepContentIssue,
JSONSchemaDto,
StepContentIssueEnum,
StepIssuesDto,
UserSessionData,
StepTypeEnum,
WorkflowOriginEnum,
ControlValuesLevelEnum,
StepIntegrationIssueEnum,
} from '@novu/shared';
import {
InstrumentUsecase,
Expand Down Expand Up @@ -43,7 +44,8 @@ export class BuildStepIssuesUsecase {
private buildAvailableVariableSchemaUsecase: BuildVariableSchemaUsecase,
private controlValuesRepository: ControlValuesRepository,
private tierRestrictionsValidateUsecase: TierRestrictionsValidateUsecase,
private logger: PinoLogger
private logger: PinoLogger,
private integrationsRepository: IntegrationRepository
) {}

@InstrumentUsecase()
Expand Down Expand Up @@ -90,8 +92,13 @@ export class BuildStepIssuesUsecase {
const skipLogicIssues = sanitizedControlValues?.skip
? this.validateSkipField(variableSchema, sanitizedControlValues.skip as RulesLogic<AdditionalOperation>)
: {};
const integrationIssues = await this.validateIntegration({
stepTypeDto,
environmentId: user.environmentId,
organizationId: user.organizationId,
});

return merge(schemaIssues, liquidIssues, customIssues, skipLogicIssues);
return merge(schemaIssues, liquidIssues, customIssues, skipLogicIssues, integrationIssues);
}

@Instrument()
Expand Down Expand Up @@ -177,7 +184,7 @@ export class BuildStepIssuesUsecase {

return acc;
},
{} as Record<string, ContentIssue[]>
{} as Record<string, StepContentIssue[]>
),
};

Expand Down Expand Up @@ -207,7 +214,7 @@ export class BuildStepIssuesUsecase {
return {};
}

const result: Record<string, ContentIssue[]> = {};
const result: Record<string, StepContentIssue[]> = {};
for (const restrictionsError of restrictionsErrors) {
result[restrictionsError.controlKey] = [
{
Expand Down Expand Up @@ -304,4 +311,36 @@ export class BuildStepIssuesUsecase {

return issues.controls?.skip.length ? issues : {};
}

@Instrument()
private async validateIntegration(args: {
stepTypeDto: StepTypeEnum;
environmentId: string;
organizationId: string;
}): Promise<StepIssuesDto> {
const issues: StepIssuesDto = {};

const validIntegrationForStep = await this.integrationsRepository.findOne({
_environmentId: args.environmentId,
_organizationId: args.organizationId,
active: true,
channel: args.stepTypeDto,
});

if (validIntegrationForStep) {
return issues;
}

issues.integration = {
[args.stepTypeDto]: [
{
issueType: StepIntegrationIssueEnum.MISSING_INTEGRATION,
message: 'Missing active integration provider',
variableName: args.stepTypeDto,
},
],
};

return issues;
}
}
1 change: 0 additions & 1 deletion apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1018,7 +1018,6 @@ describe('Workflow Controller E2E API Testing #novu-v2', () => {
expect(step.slug, stringify(step)).to.be.ok;
expect(step.name, stringify(step)).to.be.equal(stepInRequest.name);
expect(step.type, stringify(step)).to.be.equal(stepInRequest.type);
expect(Object.keys(step.issues?.body || {}).length, stringify(step)).to.be.eq(0);
}
}

Expand Down
44 changes: 17 additions & 27 deletions apps/dashboard/src/components/workflow-editor/step-utils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,3 @@
import { flatten } from 'flat';
import type {
ContentIssue,
StepCreateDto,
StepIssuesDto,
StepUpdateDto,
UpdateWorkflowDto,
WorkflowResponseDto,
} from '@novu/shared';
import { StepTypeEnum } from '@novu/shared';
import {
DEFAULT_CONTROL_DELAY_AMOUNT,
DEFAULT_CONTROL_DELAY_TYPE,
Expand All @@ -18,28 +8,28 @@ import {
DEFAULT_CONTROL_DIGEST_UNIT,
STEP_TYPE_LABELS,
} from '@/utils/constants';
import type {
StepContentIssue,
StepCreateDto,
StepIssuesDto,
StepUpdateDto,
UpdateWorkflowDto,
WorkflowResponseDto,
} from '@novu/shared';
import { StepTypeEnum } from '@novu/shared';
import { flatten } from 'flat';

export const getFirstBodyErrorMessage = (issues?: StepIssuesDto) => {
const stepIssuesArray = Object.entries({ ...issues?.body });
if (stepIssuesArray.length > 0) {
const firstIssue = stepIssuesArray[0];
const errorMessage = firstIssue[1]?.message;
return errorMessage;
}
};

export const getFirstControlsErrorMessage = (issues?: StepIssuesDto) => {
const controlsIssuesArray = Object.entries({ ...issues?.controls });
if (controlsIssuesArray.length > 0) {
const firstIssue = controlsIssuesArray[0];
export const getFirstErrorMessage = (issues: StepIssuesDto, type: 'controls' | 'integration') => {
const issuesArray = Object.entries({ ...issues?.[type] });
if (issuesArray.length > 0) {
const firstIssue = issuesArray[0];
const contentIssues = firstIssue?.[1];
const errorMessage = contentIssues?.[0]?.message;
return errorMessage;
return contentIssues?.[0];
}
};

export const flattenIssues = (controlIssues?: Record<string, ContentIssue[]>): Record<string, string> => {
const controlIssuesFlat: Record<string, ContentIssue[]> = flatten({ ...controlIssues }, { safe: true });
export const flattenIssues = (controlIssues?: Record<string, StepContentIssue[]>): Record<string, string> => {
const controlIssuesFlat: Record<string, StepContentIssue[]> = flatten({ ...controlIssues }, { safe: true });

return Object.entries(controlIssuesFlat).reduce((acc, [key, value]) => {
const errorMessage = value.length > 0 ? value[0].message : undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,12 @@ import { SidebarContent, SidebarFooter, SidebarHeader } from '@/components/side-
import TruncatedText from '@/components/truncated-text';
import { stepSchema } from '@/components/workflow-editor/schema';
import { getStepDefaultValues } from '@/components/workflow-editor/step-default-values';
import {
flattenIssues,
getFirstBodyErrorMessage,
getFirstControlsErrorMessage,
updateStepInWorkflow,
} from '@/components/workflow-editor/step-utils';
import { flattenIssues, getFirstErrorMessage, updateStepInWorkflow } from '@/components/workflow-editor/step-utils';
import { ConfigureChatStepPreview } from '@/components/workflow-editor/steps/chat/configure-chat-step-preview';
import { ConfigureStepTemplateIssueCta } from '@/components/workflow-editor/steps/configure-step-template-issue-cta';
import {
ConfigureStepTemplateIssueCta,
ConfigureStepTemplateIssuesContainer,
} from '@/components/workflow-editor/steps/configure-step-template-issue-cta';
import { DelayControlValues } from '@/components/workflow-editor/steps/delay/delay-control-values';
import { DigestControlValues } from '@/components/workflow-editor/steps/digest/digest-control-values';
import { ConfigureEmailStepPreview } from '@/components/workflow-editor/steps/email/configure-email-step-preview';
Expand Down Expand Up @@ -159,9 +157,12 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => {
},
});

const firstError = useMemo(
() =>
step.issues ? getFirstBodyErrorMessage(step.issues) || getFirstControlsErrorMessage(step.issues) : undefined,
const firstControlsError = useMemo(
() => (step.issues ? getFirstErrorMessage(step.issues, 'controls') : undefined),
[step]
);
const firstIntegrationError = useMemo(
() => (step.issues ? getFirstErrorMessage(step.issues, 'integration') : undefined),
[step]
);

Expand All @@ -173,15 +174,15 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => {
Object.values(currentErrors).forEach((controlValues) => {
Object.keys(controlValues).forEach((key) => {
if (!stepIssues[`${key}`]) {
// @ts-expect-error
// @ts-expect-error - dynamic key
form.clearErrors(`controlValues.${key}`);
}
});
});

// Set new errors from stepIssues
Object.entries(stepIssues).forEach(([key, value]) => {
// @ts-expect-error
// @ts-expect-error - dynamic key
form.setError(`controlValues.${key}`, { message: value });
});
}, [form, step]);
Expand Down Expand Up @@ -298,9 +299,16 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => {
</SidebarContent>
<Separator />

{firstError ? (
{firstControlsError || firstIntegrationError ? (
<>
<ConfigureStepTemplateIssueCta step={step} issue={firstError} />
<ConfigureStepTemplateIssuesContainer>
{firstControlsError && (
<ConfigureStepTemplateIssueCta step={step} issue={firstControlsError} type="error" />
)}
{firstIntegrationError && (
<ConfigureStepTemplateIssueCta step={step} issue={firstIntegrationError} type="info" />
)}
</ConfigureStepTemplateIssuesContainer>
<Separator />
</>
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { Button } from '@/components/primitives/button';
import { SidebarContent } from '@/components/side-navigation/sidebar';
import TruncatedText from '@/components/truncated-text';
import { StepResponseDto } from '@novu/shared';
import { titleize } from '@/utils/titleize';
import { cn } from '@/utils/ui';
import { StepContentIssue, StepIntegrationIssue, StepResponseDto } from '@novu/shared';
import { PropsWithChildren } from 'react';
import { RiArrowRightUpLine } from 'react-icons/ri';
import { Link } from 'react-router-dom';
import { ExternalLink } from '../../shared/external-link';

type ConfigureStepTemplateIssueCtaProps = {
step: StepResponseDto;
issue: string;
};
export const ConfigureStepTemplateIssueCta = (props: ConfigureStepTemplateIssueCtaProps) => {
const { step, issue } = props;
export const ConfigureStepTemplateIssuesContainer = (props: PropsWithChildren) => {
const { children } = props;

return (
<SidebarContent>
<SidebarContent className="gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">Action required</span>
<ExternalLink
Expand All @@ -26,19 +25,48 @@ export const ConfigureStepTemplateIssueCta = (props: ConfigureStepTemplateIssueC
<span>Help?</span>
</ExternalLink>
</div>
<Link to={'./edit'} relative="path" state={{ stepType: step.type }}>
<Button
size="sm"
variant="secondary"
mode="outline"
className="flex w-full justify-start gap-1.5 text-xs font-medium"
type="button"
>
<span className="bg-destructive h-4 min-w-1 rounded-full" />
<TruncatedText>{issue}</TruncatedText>
<RiArrowRightUpLine className="text-destructive ml-auto h-4 w-4" />
</Button>
</Link>
{children}
</SidebarContent>
);
};

type ConfigureStepTemplateIssueCtaProps = {
step: StepResponseDto;
issue: StepContentIssue | StepIntegrationIssue;
type: 'error' | 'info';
};

export const ConfigureStepTemplateIssueCta = (props: ConfigureStepTemplateIssueCtaProps) => {
const { step, issue, type } = props;
const isError = type === 'error';

const linkTo = isError ? './edit' : '/integrations';

const truncatedTextContent = isError
? `Invalid variable: ${issue.variableName}`
: `${titleize(issue.variableName?.replace('_', ' ') || '')} provider not connected`;

return (
<Link to={linkTo} relative="path" state={{ stepType: step.type }}>
<Button
size="sm"
variant="secondary"
mode="outline"
className="flex h-full w-full justify-start gap-3 py-2 text-xs"
type="button"
>
<span className={cn(`h-full min-w-1 rounded-full`, { 'bg-destructive': isError, 'bg-bg-sub': !isError })} />
<div className="flex flex-col items-start gap-0.5">
<TruncatedText className="font-medium">{truncatedTextContent}</TruncatedText>
<p className="text-text-soft text-wrap text-start">{issue.message}</p>
</div>
<RiArrowRightUpLine
className={cn(`mb-auto ml-auto size-4 shrink-0`, {
'text-destructive': isError,
'text-text-sub': !isError,
})}
/>
</Button>
</Link>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import '@xyflow/react/dist/style.css';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';

import { getFirstErrorMessage } from '@/components/workflow-editor/step-utils';
import { useWorkflow } from '@/components/workflow-editor/workflow-provider';
import { useEnvironment } from '@/context/environment/hooks';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
Expand All @@ -36,7 +37,6 @@ import {
SmsNode,
TriggerNode,
} from './nodes';
import { getFirstBodyErrorMessage, getFirstControlsErrorMessage } from './step-utils';
import { WorkflowChecklist } from './workflow-checklist';

const nodeTypes = {
Expand Down Expand Up @@ -112,7 +112,9 @@ const mapStepToNode = ({
}): Node<NodeData, keyof typeof nodeTypes> => {
const content = mapStepToNodeContent(step, workflowOrigin);

const error = getFirstBodyErrorMessage(step.issues) || getFirstControlsErrorMessage(step.issues);
const error = step.issues
? getFirstErrorMessage(step.issues, 'controls') || getFirstErrorMessage(step.issues, 'integration')
: undefined;

return {
id: crypto.randomUUID(),
Expand All @@ -122,7 +124,7 @@ const mapStepToNode = ({
content,
addStepIndex,
stepSlug: step.slug,
error,
error: error?.message,
controlValues: step.controls.values,
readOnly,
},
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/src/utils/titleize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const titleize = (str: string) => {
return str.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
};
Loading

0 comments on commit 9c388f5

Please sign in to comment.