-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
base: next
Are you sure you want to change the base?
Changes from all commits
ef07044
7edc595
0c565ee
ada855a
de8cdfc
4065e93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 /> | ||
|
@@ -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} /> | ||
|
||
|
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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, So, it's unclear if we rerun based on transactionID or Notification ID. My suggestion is to use the Having said that, I suggest changing the URL schema to There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit picky: return all the results from |
||
activity, | ||
isPending, | ||
error, | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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'; | ||
|
@@ -20,7 +21,7 @@ export const compactButtonVariants = tv({ | |
// focus | ||
'focus:outline-none', | ||
], | ||
icon: '', | ||
icon: ['transition-transform', '[&.loading]:animate-spin'], | ||
}, | ||
variants: { | ||
variant: { | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add's a nice loading state |
||
); | ||
}); | ||
|
There was a problem hiding this comment.
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