Skip to content
Merged
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"ajv-formats": "^2.1.1",
"axios": "^1.9.0",
"bcrypt": "^5.0.0",
"body-parser": "^2.2.0",
"body-parser": "^2.2.1",
"bull": "^4.2.1",
"class-transformer": "0.5.1",
"class-validator": "0.14.1",
Expand Down
8 changes: 2 additions & 6 deletions apps/api/src/app/events/e2e/bridge-trigger.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,7 @@ type Context = { name: string; isStateful: boolean };
const contexts: Context[] = [{ name: 'stateful', isStateful: true }];

contexts.forEach((context: Context) => {
/**
* For some reason, the bridge trigger is very flaky in setting up the test server,
* It's not clear why, but it's causing the tests to fail.
*/
describe.skip('Self-Hosted Bridge Trigger #novu-v2', async () => {
describe('Self-Hosted Bridge Trigger #novu-v2', async () => {
let session: UserSession;
let bridgeServer: TestBridgeServer;
const messageRepository = new MessageRepository();
Expand Down Expand Up @@ -1678,7 +1674,7 @@ contexts.forEach((context: Context) => {
transactionId: transactionIdNoSkip,
type: StepTypeEnum.DELAY,
});
expect(delayJobNoSkip?.status).to.equal(JobStatusEnum.COMPLETED, 'Scenario 1: Delay job should be COMPLETED');
expect(delayJobNoSkip?.status).to.equal(JobStatusEnum.SKIPPED, 'Scenario 1: Delay job should be SKIPPED');

const failedExecDetailsNoSkip = await executionDetailsRepository.find({
_environmentId: session.environment._id,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/app/workflows-v2/dtos/get-list-query-params.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { WorkflowResponseDto } from '@novu/application-generic';
import { WorkflowStatusEnum } from '@novu/shared';
import { Transform } from 'class-transformer';
import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator';
import { LimitOffsetPaginationQueryDto } from '../../shared/dtos/limit-offset-pagination.dto';

Expand All @@ -25,6 +26,7 @@ export class GetListQueryParamsDto extends LimitOffsetPaginationQueryDto(Workflo
required: false,
})
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : Array.isArray(value) ? value : [value]))
@IsArray()
@IsString({ each: true })
tags?: string[];
Expand All @@ -37,6 +39,7 @@ export class GetListQueryParamsDto extends LimitOffsetPaginationQueryDto(Workflo
required: false,
})
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : Array.isArray(value) ? value : [value]))
@IsArray()
@IsEnum(WorkflowStatusEnum, { each: true })
status?: WorkflowStatusEnum[];
Expand Down
36 changes: 36 additions & 0 deletions apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,42 @@ describe('Workflow Controller E2E API Testing #novu-v2', () => {
return { workflowV2Id, workflowId, name: listWorkflowResponse.workflows[0].name };
}

it('should filter workflows by a single tag', async () => {
await createWorkflow(apiClient, buildWorkflow({ name: 'Tagged Workflow 1', tags: ['ai'] }));
await createWorkflow(apiClient, buildWorkflow({ name: 'Tagged Workflow 2', tags: ['ai', 'ml'] }));
await createWorkflow(apiClient, buildWorkflow({ name: 'Untagged Workflow', tags: ['other'] }));

const res = await apiClient.workflows.list({ tags: ['ai'] });
expect(res.result.totalCount).to.equal(2);
expect(res.result.workflows).to.have.lengthOf(2);
const names = res.result.workflows.map((w) => w.name);
expect(names).to.include('Tagged Workflow 1');
expect(names).to.include('Tagged Workflow 2');
});

it('should filter workflows by multiple tags', async () => {
await createWorkflow(apiClient, buildWorkflow({ name: 'AI Workflow', tags: ['ai'] }));
await createWorkflow(apiClient, buildWorkflow({ name: 'ML Workflow', tags: ['ml'] }));
await createWorkflow(apiClient, buildWorkflow({ name: 'Both Tags Workflow', tags: ['ai', 'ml'] }));
await createWorkflow(apiClient, buildWorkflow({ name: 'No Match Workflow', tags: ['other'] }));

const res = await apiClient.workflows.list({ tags: ['ai', 'ml'] });
expect(res.result.totalCount).to.equal(3);
expect(res.result.workflows).to.have.lengthOf(3);
const names = res.result.workflows.map((w) => w.name);
expect(names).to.include('AI Workflow');
expect(names).to.include('ML Workflow');
expect(names).to.include('Both Tags Workflow');
});

it('should return empty results when filtering by non-existent tag', async () => {
await createWorkflow(apiClient, buildWorkflow({ name: 'Some Workflow', tags: ['existing'] }));

const res = await apiClient.workflows.list({ tags: ['non-existent'] });
expect(res.result.totalCount).to.equal(0);
expect(res.result.workflows).to.have.lengthOf(0);
});

it('old list endpoint should not retrieve the new workflow', async () => {
const { workflowV2Id, name } = await getV2WorkflowIdAndExternalId('Test Workflow');
const [, , workflowV0Created] = await Promise.all([
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@
"@novu/dal": "workspace:*",
"@novu/ee-auth": "workspace:*",
"@novu/testing": "workspace:*",
"@playwright/test": "^1.46.1",
"@playwright/test": "^1.55.1",
"@sentry/vite-plugin": "^2.22.6",
"@tiptap/core": "^2.11.5",
"@types/json-schema": "^7.0.15",
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/components/workflow-editor/base-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ const nodeBadgeVariants = cva(

export interface NodeIconProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof nodeBadgeVariants> {}

export const NodeIcon = ({ children, variant }: NodeIconProps) => {
return <span className={nodeBadgeVariants({ variant })}>{children}</span>;
export const NodeIcon = ({ children, variant, className }: NodeIconProps) => {
return <span className={cn(nodeBadgeVariants({ variant }), className)}>{children}</span>;
};

export const NodeName = ({ children }: { children: ReactNode }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ResourceOriginEnum, StepResponseDto, WorkflowResponseDto } from '@novu/shared';
import React from 'react';
import { FaCode } from 'react-icons/fa6';
import { RiArrowLeftSLine } from 'react-icons/ri';
import { RiArrowLeftSLine, RiExpandUpDownLine } from 'react-icons/ri';
import { useLocation, useNavigate, useParams } from 'react-router-dom';

import { RouteFill } from '@/components/icons';
import { STEP_TYPE_TO_ICON } from '@/components/icons/utils';
import { Badge } from '@/components/primitives/badge';
import {
Breadcrumb,
Expand All @@ -15,15 +16,35 @@ import {
BreadcrumbSeparator,
} from '@/components/primitives/breadcrumb';
import { CompactButton } from '@/components/primitives/button-compact';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/primitives/dropdown-menu';
import TruncatedText from '@/components/truncated-text';
import { useEnvironment } from '@/context/environment/hooks';
import { useFetchWorkflow } from '@/hooks/use-fetch-workflow';
import { STEP_TYPE_LABELS } from '@/utils/constants';
import type { ProviderColorToken } from '@/utils/color';
import { STEP_TYPE_TO_COLOR } from '@/utils/color';
import { STEP_TYPE_LABELS, TEMPLATE_CONFIGURABLE_STEP_TYPES } from '@/utils/constants';
import { buildRoute, ROUTES } from '@/utils/routes';
import { cn } from '@/utils/ui';
import { SavingStatusIndicator } from './saving-status-indicator';
import { getStepTypeIcon } from './steps/utils/preview-context.utils';
import { useWorkflow } from './workflow-provider';

const COLOR_TOKEN_TO_TEXT: Record<ProviderColorToken, string> = {
neutral: 'text-neutral-400',
stable: 'text-stable/30',
information: 'text-information/30',
feature: 'text-feature/30',
destructive: 'text-destructive/30',
verified: 'text-verified/30',
alert: 'text-alert/30',
highlighted: 'text-highlighted/30',
warning: 'text-warning/30',
};

type BreadcrumbData = {
label: string;
href: string;
Expand Down Expand Up @@ -144,16 +165,71 @@ function WorkflowBreadcrumbContent({
}

function StepBreadcrumb({ step }: { step: StepResponseDto }) {
const Icon = getStepTypeIcon(step.type);
const { isUpdatePatchPending, lastSaveError } = useWorkflow();
const Icon = STEP_TYPE_TO_ICON[step.type];
const { isUpdatePatchPending, lastSaveError, workflow } = useWorkflow();
const navigate = useNavigate();
const { currentEnvironment } = useEnvironment();
const { workflowSlug = '' } = useParams<{ workflowSlug: string }>();
const steps = workflow?.steps ?? [];
const hasMultipleSteps = steps.length > 1;

function handleStepSwitch(targetStep: StepResponseDto) {
if (!workflow || !currentEnvironment?.slug) return;
if (targetStep.slug === step.slug) return;

const basePath =
buildRoute(ROUTES.EDIT_WORKFLOW, {
environmentSlug: currentEnvironment.slug,
workflowSlug,
}) + `/steps/${targetStep.slug}`;

const isTemplateConfigurable = TEMPLATE_CONFIGURABLE_STEP_TYPES.includes(targetStep.type);
const finalPath = isTemplateConfigurable ? `${basePath}/editor` : basePath;

navigate(finalPath);
}

return (
<BreadcrumbItem>
<BreadcrumbPage className="flex items-center gap-1">
<Icon className="size-3.5" />
<div className="flex max-w-[32ch]">
<TruncatedText>{step.name || STEP_TYPE_LABELS[step.type]}</TruncatedText>
</div>
{hasMultipleSteps ? (
<DropdownMenu>
<DropdownMenuTrigger className="flex cursor-pointer items-center gap-1 rounded-md border border-transparent px-1 py-[1px] hover:border-neutral-alpha-200 hover:bg-neutral-50">
<Icon className="text-foreground-950 size-3" />
<span className="text-foreground-950 max-w-[32ch] truncate text-sm font-medium">
{step.name || STEP_TYPE_LABELS[step.type]}
</span>
<RiExpandUpDownLine className="text-foreground-400 size-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[144px]">
{steps.map((s) => {
const StepIcon = STEP_TYPE_TO_ICON[s.type];
const isCurrentStep = s.slug === step.slug;

return (
<DropdownMenuItem
key={s._id}
onSelect={() => handleStepSwitch(s)}
className={cn(
'flex cursor-pointer items-center gap-1 px-1 py-1 text-xs',
isCurrentStep && 'bg-neutral-alpha-50'
)}
>
<StepIcon className={cn('size-4 shrink-0', COLOR_TOKEN_TO_TEXT[STEP_TYPE_TO_COLOR[s.type]])} />
<span className="truncate">{s.name || STEP_TYPE_LABELS[s.type]}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
<Icon className="text-foreground-950 size-4" />
<div className="flex max-w-[32ch]">
<TruncatedText>{step.name || STEP_TYPE_LABELS[step.type]}</TruncatedText>
</div>
</>
)}
<SavingStatusIndicator isSaving={isUpdatePatchPending} hasError={!!lastSaveError} />
</BreadcrumbPage>
</BreadcrumbItem>
Expand Down
4 changes: 4 additions & 0 deletions apps/dashboard/src/components/workflow-editor/node-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export const createNode = ({
controlValues,
isPending,
type,
stepResolverHash,
}: {
x: number;
y: number;
Expand All @@ -150,6 +151,7 @@ export const createNode = ({
controlValues: Record<string, unknown>;
isPending?: boolean;
type: StepTypeEnum;
stepResolverHash?: string;
}): Node<NodeData, keyof typeof nodeTypes> => {
return {
// the random id is used to identify the node and to be able to re-render the nodes and edges
Expand All @@ -163,6 +165,7 @@ export const createNode = ({
error,
controlValues,
isPending,
stepResolverHash,
},
type,
};
Expand Down Expand Up @@ -195,6 +198,7 @@ export const mapStepToNode = ({
error: error?.message ?? '',
controlValues: step.controls.values,
type: step.type,
stepResolverHash: step.stepResolverHash,
});
};

Expand Down
Loading
Loading