Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dashboard): re run activity item #7459

Open
wants to merge 6 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions apps/dashboard/src/components/activity/activity-job-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function ActivityJobItem({ job, isFirst, isLast }: ActivityJobItemProps)

<JobStatusIndicator status={job.status} />

<Card className="border-1 flex-1 border border-neutral-200 p-1 shadow-[0px_1px_2px_0px_rgba(10,13,20,0.03)]">
<Card className="border-1 min-w-0 flex-1 border border-neutral-200 p-1 shadow-[0px_1px_2px_0px_rgba(10,13,20,0.03)]">
<CardHeader
className="flex flex-row items-center justify-between bg-white p-2 px-1 hover:cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
Expand Down Expand Up @@ -67,10 +67,12 @@ export function ActivityJobItem({ job, isFirst, isLast }: ActivityJobItemProps)
</CardHeader>

{!isExpanded && (
<CardContent className="rounded-lg bg-neutral-50 p-2">
<div className="flex items-center justify-between">
<span className="text-foreground-400 max-w-[300px] truncate pr-2 text-xs">{getStatusMessage(job)}</span>
<Badge variant="lighter" color="gray" size="sm">
<CardContent className="overflow-hidden rounded-lg bg-neutral-50 p-2">
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 flex-1 overflow-hidden">
<span className="text-foreground-400 block truncate text-xs">{getStatusMessage(job)}</span>
</div>
<Badge variant="lighter" color="gray" size="sm" className="shrink-0 whitespace-nowrap">
Comment on lines +70 to +75
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed clipping of the date badge on long text

<TimeDisplayHoverCard date={new Date(job.updatedAt)}>
{format(new Date(job.updatedAt), 'MMM d yyyy, HH:mm:ss')}
</TimeDisplayHoverCard>
Expand Down
97 changes: 63 additions & 34 deletions apps/dashboard/src/components/activity/activity-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,74 @@
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { IActivityJob } from '@novu/shared';
import { motion } from 'motion/react';
import { RiPlayCircleLine, RiRouteFill } from 'react-icons/ri';
import { IActivityJob, JobStatusEnum } from '@novu/shared';
import { RiMemoriesFill, RiPlayCircleLine, RiRouteFill } from 'react-icons/ri';
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip';

import { ActivityJobItem } from './activity-job-item';
import { InlineToast } from '../primitives/inline-toast';
import { useFetchActivity } from '@/hooks/use-fetch-activity';
import { ActivityOverview } from './components/activity-overview';
import { useEnvironment } from '@/context/environment/hooks';
import { useTriggerWorkflow } from '@/hooks/use-trigger-workflow';
import { useTelemetry } from '../../hooks/use-telemetry';
import { TelemetryEvent } from '../../utils/telemetry';
import { cn } from '../../utils/ui';
import { CompactButton } from '../primitives/button-compact';
import { InlineToast } from '../primitives/inline-toast';
import { Skeleton } from '../primitives/skeleton';
import { QueryKeys } from '@/utils/query-keys';
import { useEnvironment } from '@/context/environment/hooks';
import { showErrorToast, showSuccessToast } from '../primitives/sonner-helpers';
import { ActivityJobItem } from './activity-job-item';
import { ActivityOverview } from './components/activity-overview';
import { useActivityByTransaction, useActivityPolling } from './hooks';

export interface ActivityPanelProps {
activityId?: string;
transactionId?: string;
onActivitySelect: (activityId: string) => void;
headerClassName?: string;
overviewHeaderClassName?: string;
}

export function ActivityPanel({
activityId,
transactionId: initialTransactionId,
onActivitySelect,
headerClassName,
overviewHeaderClassName,
}: ActivityPanelProps) {
const queryClient = useQueryClient();
const [shouldRefetch, setShouldRefetch] = useState(true);
const track = useTelemetry();
const { isLoadingTransaction, setTransactionId } = useActivityByTransaction({
transactionId: initialTransactionId,
onActivityFound: onActivitySelect,
});

const { activity, isPending, error } = useActivityPolling({
activityId,
});

const { currentEnvironment } = useEnvironment();
Comment on lines 26 to +43
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the logic of fetching and refetching by transaction id to the activity panel to allow initializing by either activityId or transaction id

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please elaborate on why do we need to do both? The transaction is identified by transactionID, so I think we can just use that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When vieweing an item from the activity feed, we want to view a specific activityId, transacitonId is used in cases of a re-trigger, and also from the test workflow sidebar. As a trigger can potentially generate multiple activity ids, when testing we assume it generates one and just take the first.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Let's continue here. My suggestion is to work with the notification list (activity feed) page as the main resource and the selected notification (details of a run).

If not mistaken, the transactionId for the rerun should be available in the selected notification resource.

const { activity, isPending, error } = useFetchActivity(
{ activityId },
{
refetchInterval: shouldRefetch ? 1000 : false,
}
);
const { triggerWorkflow, isPending: isRerunning } = useTriggerWorkflow();

useEffect(() => {
if (!activity) return;
const handleRerun = async () => {
if (!activity || !currentEnvironment) return;

const isPending = activity.jobs?.some(
(job) =>
job.status === JobStatusEnum.PENDING ||
job.status === JobStatusEnum.QUEUED ||
job.status === JobStatusEnum.RUNNING ||
job.status === JobStatusEnum.DELAYED
);
try {
track(TelemetryEvent.RE_RUN_WORKFLOW, {
name: activity.template?.name,
});

// Only stop refetching if we have an activity and it's not pending
setShouldRefetch(isPending || !activity?.jobs?.length);
const { data } = await triggerWorkflow({
name: activity.template?.triggers[0].identifier || '',
payload: activity.payload || {},
to: activity.subscriber?.subscriberId || '',
});

queryClient.invalidateQueries({
queryKey: [QueryKeys.fetchActivity, currentEnvironment?._id, activityId],
});
}, [activity, queryClient, currentEnvironment, activityId]);
showSuccessToast('Workflow triggered successfully', 'bottom-right');

if (isPending) {
if (data?.transactionId) {
setTransactionId(data.transactionId);
}
} catch (e: any) {
showErrorToast(e.message || 'Failed to trigger workflow');
}
};

if (isPending || isLoadingTransaction) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.1 }}>
<LoadingSkeleton />
Expand Down Expand Up @@ -95,6 +108,22 @@ export function ActivityPanel({
<span className="text-foreground-950 text-sm font-medium">
{activity.template?.name || 'Deleted workflow'}
</span>

{activity.template?.name && (
<Tooltip>
<TooltipTrigger asChild>
<CompactButton
icon={RiMemoriesFill}
size="md"
variant="ghost"
className="ml-auto"
onClick={handleRerun}
isLoading={isRerunning}
/>
</TooltipTrigger>
<TooltipContent>Rerun this workflow again with the same payload and recipients</TooltipContent>
</Tooltip>
)}
</div>
<ActivityOverview activity={activity} />

Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/components/activity/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './use-activity-by-transaction';
export * from './use-activity-polling';
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useFetchActivities } from '@/hooks/use-fetch-activities';
import { useEffect, useState } from 'react';

interface UseActivityByTransactionProps {
transactionId?: string;
onActivityFound: (activityId: string) => void;
}

export function useActivityByTransaction({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hook is needed to correct a misalignment in the dashboard's Activity Feed. The current notification item, ? activityItemFeed=..., is already present in the address bar.

So, it's unclear if we rerun based on transactionID or Notification ID.

Screenshot 2025-01-21 at 12 42 34

My suggestion is to use the notificationId for now. We shouldn't need the useState; we should get it from the URL. That would eliminate the need for this hook.

Having said that, I suggest changing the URL schema to
https://dashboard-v2.novu.co/env/:env_id/activity-feed/:notificationId to map with the API powering this screen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, but I do not entirely follow your suggestion. The re run uses the current notification entity, and performs a trigger using the same payload and recipient properties as the previous trigger.

useActivityByTransactionId is just a refactor I did that encapsulates the fetch activities given a specific transactionId, this happens in 2 cases:

  • We perform a trigger in the test workflow sidebar (Workflow editor), the API returns a transactionId, and we need to obtain the notification Id by searching the notification feed based on the transaction id and select the first activity item.
  • When we re-run an activity item, the API returns us a transactionId, which we again need to convert to a notificationId/activityId to display the full details.

transactionId: initialTransactionId,
onActivityFound,
}: UseActivityByTransactionProps) {
const [transactionId, setTransactionId] = useState<string | undefined>(initialTransactionId);

const { activities } = useFetchActivities(
{
filters: transactionId ? { transactionId } : undefined,
},
{
enabled: !!transactionId,
refetchInterval: transactionId ? 1000 : false,
}
);

useEffect(() => {
if (!activities?.length || !transactionId) return;

const newActivityId = activities[0]._id;
if (newActivityId) {
onActivityFound(newActivityId);
setTransactionId(undefined);
}
}, [activities, transactionId, onActivityFound]);

return {
isLoadingTransaction: transactionId && !activities?.[0]?._id,
setTransactionId,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { JobStatusEnum } from '@novu/shared';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';

import { useEnvironment } from '@/context/environment/hooks';
import { useFetchActivity } from '@/hooks/use-fetch-activity';
import { QueryKeys } from '@/utils/query-keys';

interface UseActivityPollingProps {
activityId?: string;
}

export function useActivityPolling({ activityId }: UseActivityPollingProps) {
const queryClient = useQueryClient();
const [shouldRefetch, setShouldRefetch] = useState(true);
const { currentEnvironment } = useEnvironment();

const { activity, isPending, error } = useFetchActivity(
{ activityId: activityId ?? '' },
{
refetchInterval: shouldRefetch ? 1000 : false,
}
);

useEffect(() => {
if (!activity) return;

const isPending = activity.jobs?.some(
(job) =>
job.status === JobStatusEnum.PENDING ||
job.status === JobStatusEnum.QUEUED ||
job.status === JobStatusEnum.RUNNING ||
job.status === JobStatusEnum.DELAYED
);

setShouldRefetch(isPending || !activity?.jobs?.length);

queryClient.invalidateQueries({
queryKey: [QueryKeys.fetchActivity, currentEnvironment?._id, activityId],
});
}, [activity, queryClient, currentEnvironment, activityId]);

return {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit picky: return all the results from useFetchActivity.

activity,
isPending,
error,
};
}
10 changes: 6 additions & 4 deletions apps/dashboard/src/components/primitives/button-compact.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PolymorphicComponentProps } from '@/utils/polymorphic';
import { recursiveCloneChildren } from '@/utils/recursive-clone-children';
import { tv, type VariantProps } from '@/utils/tv';
import { IconType } from 'react-icons';
import { RiLoader4Line } from 'react-icons/ri';

const COMPACT_BUTTON_ROOT_NAME = 'CompactButtonRoot';
const COMPACT_BUTTON_ICON_NAME = 'CompactButtonIcon';
Expand All @@ -20,7 +21,7 @@ export const compactButtonVariants = tv({
// focus
'focus:outline-none',
],
icon: '',
icon: ['transition-transform', '[&.loading]:animate-spin'],
},
variants: {
variant: {
Expand Down Expand Up @@ -135,11 +136,12 @@ const CompactButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof CompactButtonRoot> & {
icon: IconType;
isLoading?: boolean;
}
>(({ children, icon: Icon, ...rest }, forwardedRef) => {
>(({ children, icon: Icon, isLoading, disabled, ...rest }, forwardedRef) => {
return (
<CompactButtonRoot ref={forwardedRef} {...rest}>
<CompactButtonIcon as={Icon} />
<CompactButtonRoot ref={forwardedRef} disabled={isLoading || disabled} {...rest}>
<CompactButtonIcon as={isLoading ? RiLoader4Line : Icon} className={isLoading ? 'loading' : ''} />
</CompactButtonRoot>
Comment on lines +139 to 145
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add's a nice loading state

);
});
Expand Down
7 changes: 5 additions & 2 deletions apps/dashboard/src/components/primitives/sonner-helpers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ReactNode } from 'react';
import { ExternalToast, toast } from 'sonner';
import { Toast, ToastIcon, ToastProps } from './sonner';
import { ReactNode } from 'react';

export const showToast = ({
options,
Expand All @@ -18,7 +18,10 @@ export const showToast = ({
});
};

export const showSuccessToast = (message: string, position: 'bottom-center' | 'top-center' = 'bottom-center') => {
export const showSuccessToast = (
message: string,
position: 'bottom-center' | 'top-center' | 'bottom-right' = 'bottom-center'
) => {
showToast({
children: () => (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,57 +1,22 @@
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';

import { ActivityPanel } from '@/components/activity/activity-panel';
import { useFetchActivities } from '../../../hooks/use-fetch-activities';
import { WorkflowTriggerInboxIllustration } from '../../icons/workflow-trigger-inbox';

type TestWorkflowLogsSidebarProps = {
transactionId?: string;
};

export const TestWorkflowLogsSidebar = ({ transactionId }: TestWorkflowLogsSidebarProps) => {
const [parentActivityId, setParentActivityId] = useState<string | undefined>(undefined);
const [shouldRefetch, setShouldRefetch] = useState(true);
const { activities } = useFetchActivities(
{
filters: transactionId ? { transactionId } : undefined,
},
{
enabled: !!transactionId,
refetchInterval: shouldRefetch ? 1000 : false,
}
);
const activityId: string | undefined = parentActivityId ?? activities?.[0]?._id;

useEffect(() => {
if (activityId) {
setShouldRefetch(false);
}
}, [activityId]);

// Reset refetch when transaction ID changes
useEffect(() => {
if (!transactionId) {
return;
}

setShouldRefetch(true);
setParentActivityId(undefined);
}, [transactionId]);
const [activityId, setActivityId] = useState<string>();

return (
<aside className="flex h-full max-h-full flex-1 flex-col overflow-auto">
{transactionId && !activityId ? (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="size-8 animate-spin text-neutral-500" />
<div className="text-foreground-600 text-sm">Waiting for activity...</div>
</div>
</div>
) : activityId ? (
<aside className="flex h-full flex-col">
{transactionId ? (
<ActivityPanel
activityId={activityId}
onActivitySelect={setParentActivityId}
transactionId={transactionId}
onActivitySelect={setActivityId}
headerClassName="h-[49px]"
overviewHeaderClassName="border-t-0"
/>
Expand Down
8 changes: 4 additions & 4 deletions apps/dashboard/src/hooks/use-onboarding-steps.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { useFetchWorkflows } from './use-fetch-workflows';
import { useOrganization } from '@clerk/clerk-react';
import { ChannelTypeEnum, IIntegration } from '@novu/shared';
import { useFetchIntegrations } from './use-fetch-integrations';
import { useMemo } from 'react';
import { ONBOARDING_DEMO_WORKFLOW_ID } from '../config';
import { useFetchIntegrations } from './use-fetch-integrations';
import { useFetchWorkflows } from './use-fetch-workflows';

export enum StepIdEnum {
ACCOUNT_CREATION = 'account-creation',
Expand Down Expand Up @@ -53,7 +53,7 @@ function getProviderDescription(providerType: ChannelTypeEnum): string {

function isActiveIntegration(integration: IIntegration, providerType: ChannelTypeEnum): boolean {
const isMatchingChannel = integration.channel === providerType;
const isNotNovuProvider = !integration.providerId.startsWith('novu-');
const isNotNovuProvider = !integration.providerId?.startsWith('novu-');
const isConnected = providerType === ChannelTypeEnum.IN_APP ? !!integration.connected : true;

return isMatchingChannel && isNotNovuProvider && isConnected;
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/utils/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export enum TelemetryEvent {
CHANGELOG_ITEM_CLICKED = 'Changelog item clicked',
CHANGELOG_ITEM_DISMISSED = 'Changelog item dismissed',
SHARE_FEEDBACK_LINK_CLICKED = 'Share feedback link clicked',
RE_RUN_WORKFLOW = 'Re-run workflow',
VARIABLE_POPOVER_OPENED = 'Variable popover opened - [Variable Editor]',
VARIABLE_POPOVER_APPLIED = 'Variable popover applied - [Variable Editor]',
TEMPLATE_MODAL_OPENED = 'Template Modal Opened - [Template Store]',
Expand Down
Loading