Skip to content

Commit

Permalink
Bulk replaying and canceling from the runs list (#1109)
Browse files Browse the repository at this point in the history
* WIP on multi-select

* WIP on simple checkbox

* CheckboxWIthLabel and Checkbox

* Multi-selection of runs across pages is working

* Fix for selection on seconds page

* Focus the run filter on page load

* Don’t focus the checkbox

* BulkActionBar now shows/hides and has buttons

* Some state to stop escape clearing the selection when the modals are open

* Delete unused formData util

* Improvements to the page

* Created the replay resource action. It doesn’t do anything useful yet.

* Database schema created for BulkActionGroup/BulkActionItem

* The BulkActionService is creating the right data, now we need to process it

* WIP on bulk processing

* Added failed state and made the sourceRun required

* Bulk replaying is working

* WIP on bulk action filtering

* Fixed bulk filters displaying

* Filtering by batch is working

* Some fixes for the bulk id filtering

* Style tweaks

* Load the extra info in parallel

* Bulk canceling working

* Get the most recent 20 bulk actions to display in the filter menu

* Even if the run isn’t cancelable add it to the final list

* Maximum of 250 runs can be bulk actioned

* Don’t let them select more than the maximum (250 currently)

* Separate each bulk item action into it’s own separate graphile job to increase resiliency

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
  • Loading branch information
matt-aitken and ericallam committed May 20, 2024
1 parent c9ebe7f commit a5cba37
Show file tree
Hide file tree
Showing 29 changed files with 1,351 additions and 115 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ApiAuthenticationMethodOAuth2, Integration, Scope } from "~/services/ex
import { cn } from "~/utils/cn";
import { CodeBlock } from "../code/CodeBlock";
import { Button } from "../primitives/Buttons";
import { Checkbox } from "../primitives/Checkbox";
import { CheckboxWithLabel } from "../primitives/Checkbox";
import { Fieldset } from "../primitives/Fieldset";
import { FormError } from "../primitives/FormError";
import { Header2, Header3 } from "../primitives/Headers";
Expand Down Expand Up @@ -123,7 +123,7 @@ export function ConnectToOAuthForm({
<Paragraph variant="small" className="mb-2">
To use your own OAuth app, check the option below and insert the details.
</Paragraph>
<Checkbox
<CheckboxWithLabel
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
Expand Down Expand Up @@ -200,7 +200,7 @@ export function ConnectToOAuthForm({
)}
{authMethod.scopes.map((s) => {
return (
<Checkbox
<CheckboxWithLabel
key={s.name}
id={s.name}
value={s.name}
Expand Down
6 changes: 3 additions & 3 deletions apps/webapp/app/components/integrations/UpdateOAuthForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ApiAuthenticationMethodOAuth2, Integration, Scope } from "~/services/ex
import { cn } from "~/utils/cn";
import { CodeBlock } from "../code/CodeBlock";
import { Button } from "../primitives/Buttons";
import { Checkbox } from "../primitives/Checkbox";
import { CheckboxWithLabel } from "../primitives/Checkbox";
import { Fieldset } from "../primitives/Fieldset";
import { FormError } from "../primitives/FormError";
import { Header2, Header3 } from "../primitives/Headers";
Expand Down Expand Up @@ -113,7 +113,7 @@ export function UpdateOAuthForm({
<Paragraph variant="small" className="mb-2">
To use your own OAuth app, check the option below and insert the details.
</Paragraph>
<Checkbox
<CheckboxWithLabel
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
Expand Down Expand Up @@ -189,7 +189,7 @@ export function UpdateOAuthForm({
)}
{authMethod.scopes.map((s) => {
return (
<Checkbox
<CheckboxWithLabel
key={s.name}
id={s.name}
value={s.name}
Expand Down
26 changes: 22 additions & 4 deletions apps/webapp/app/components/primitives/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from "react";
import { useEffect, useState } from "react";
import { forwardRef, useEffect, useState } from "react";
import { cn } from "~/utils/cn";
import { Badge } from "./Badge";
import { Paragraph } from "./Paragraph";
Expand Down Expand Up @@ -53,18 +53,18 @@ export type CheckboxProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"checked" | "onChange"
> & {
id: string;
id?: string;
name?: string;
value?: string;
variant?: keyof typeof variants;
label?: React.ReactNode;
label: React.ReactNode;
description?: string;
badges?: string[];
className?: string;
onChange?: (isChecked: boolean) => void;
};

export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProps>(
(
{
id,
Expand Down Expand Up @@ -172,3 +172,21 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
);
}
);

type SimpleCheckboxProps = Omit<React.ComponentProps<"input">, "type">;

export const Checkbox = forwardRef<HTMLInputElement, SimpleCheckboxProps>(
({ className, ...props }: SimpleCheckboxProps, ref) => {
return (
<input
type="checkbox"
className={cn(
props.readOnly || props.disabled ? "cursor-default" : "cursor-pointer",
"read-only:border-charcoal-650 disabled:border-charcoal-650 rounded-sm border border-charcoal-600 bg-transparent transition checked:!bg-indigo-500 read-only:!bg-charcoal-700 group-hover:bg-charcoal-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:!bg-charcoal-700"
)}
{...props}
ref={ref}
/>
);
}
);
146 changes: 146 additions & 0 deletions apps/webapp/app/components/primitives/SelectedItemsProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"use client";

import { createContext, useCallback, useContext, useReducer } from "react";

type SelectedItemsContext = {
selectedItems: Set<string>;
select: (items: string | string[]) => void;
deselect: (items: string | string[]) => void;
toggle: (items: string | string[]) => void;
deselectAll: () => void;
has: (item: string) => boolean;
hasAll: (items: string[]) => boolean;
};

const SelectedItemsContext = createContext<SelectedItemsContext>({} as SelectedItemsContext);

export function useSelectedItems(enabled = true) {
const context = useContext(SelectedItemsContext);
if (!context && enabled) {
throw new Error("useSelectedItems must be used within a SelectedItemsProvider");
}

return context;
}

export function SelectedItemsProvider({
initialSelectedItems,
maxSelectedItemCount,
children,
}: {
initialSelectedItems: string[];
maxSelectedItemCount?: number;
children: React.ReactNode | ((context: SelectedItemsContext) => React.ReactNode);
}) {
const [state, dispatch] = useReducer(selectedItemsReducer, {
items: new Set<string>(initialSelectedItems),
maxSelectedItemCount,
});

const select = useCallback((items: string | string[]) => {
dispatch({ type: "select", items: Array.isArray(items) ? items : [items] });
}, []);

const deselect = useCallback((items: string | string[]) => {
dispatch({ type: "deselect", items: Array.isArray(items) ? items : [items] });
}, []);

const toggle = useCallback((items: string | string[]) => {
dispatch({ type: "toggle", items: Array.isArray(items) ? items : [items] });
}, []);

const deselectAll = useCallback(() => {
dispatch({ type: "deselectAll" });
}, []);

const has = useCallback((item: string) => state.items.has(item), [state]);

const hasAll = useCallback(
(items: string[]) => items.every((item) => state.items.has(item)),
[state]
);

return (
<SelectedItemsContext.Provider
value={{ selectedItems: state.items, select, deselect, toggle, deselectAll, has, hasAll }}
>
{typeof children === "function"
? children({
selectedItems: state.items,
select,
deselect,
toggle,
deselectAll,
has,
hasAll,
})
: children}
</SelectedItemsContext.Provider>
);
}

type SelectItemsAction = {
type: "select";
items: string[];
};

type DeSelectItemsAction = {
type: "deselect";
items: string[];
};

type DeselectAllItemsAction = {
type: "deselectAll";
};

type ToggleItemsAction = {
type: "toggle";
items: string[];
};

type Action = SelectItemsAction | DeSelectItemsAction | ToggleItemsAction | DeselectAllItemsAction;

function selectedItemsReducer(
state: { items: Set<string>; maxSelectedItemCount?: number },
action: Action
) {
switch (action.type) {
case "select":
const items = new Set([...state.items, ...action.items]);
return { ...state, items: cappedSet(items, state.maxSelectedItemCount) };
case "deselect":
const newItems = new Set(state.items);
action.items.forEach((item) => {
newItems.delete(item);
});
return { ...state, items: cappedSet(newItems, state.maxSelectedItemCount) };
case "toggle":
let newSet = new Set(state.items);
action.items.forEach((item) => {
if (newSet.has(item)) {
newSet.delete(item);
} else {
newSet.add(item);
}
});
return { ...state, items: cappedSet(newSet, state.maxSelectedItemCount) };
case "deselectAll":
return { ...state, items: new Set<string>() };
default:
return state;
}
}

function cappedSet(set: Set<string>, max?: number) {
if (!max) {
return set;
}

if (set.size <= max) {
return set;
}

console.warn(`Selected items exceeded the maximum count of ${max}.`);

return new Set([...set].slice(0, max));
}
6 changes: 3 additions & 3 deletions apps/webapp/app/components/primitives/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
ref={ref}
scope="col"
className={cn(
"px-4 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-text-dimmed",
"px-3 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-text-dimmed",
alignmentClassName,
className
)}
Expand Down Expand Up @@ -155,7 +155,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
}

const flexClasses = cn(
"flex w-full whitespace-nowrap px-4 py-3 text-xs text-text-dimmed",
"flex w-full whitespace-nowrap px-3 py-3 text-xs text-text-dimmed",
alignment === "left"
? "justify-start text-left"
: alignment === "center"
Expand All @@ -170,7 +170,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
"text-xs text-charcoal-400",
to || onClick || hasAction
? "cursor-pointer group-hover/table-row:bg-charcoal-900"
: "px-4 py-3 align-middle",
: "px-3 py-3 align-middle",
!to && !onClick && alignmentClassName,
isSticky && stickyStyles,
className
Expand Down
73 changes: 73 additions & 0 deletions apps/webapp/app/components/runs/v3/BulkAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { ArrowPathIcon, NoSymbolIcon } from "@heroicons/react/20/solid";
import { BulkActionType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { cn } from "~/utils/cn";

export function BulkActionStatusCombo({
type,
className,
iconClassName,
}: {
type: BulkActionType;
className?: string;
iconClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<BulkActionIcon type={type} className={cn("h-4 w-4", iconClassName)} />
<BulkActionLabel type={type} />
</span>
);
}

export function BulkActionLabel({ type }: { type: BulkActionType }) {
return <span className={bulkActionClassName(type)}>{bulkActionTitle(type)}</span>;
}

export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) {
switch (type) {
case "REPLAY":
return <ArrowPathIcon className={cn(bulkActionClassName(type), className)} />;
case "CANCEL":
return <NoSymbolIcon className={cn(bulkActionClassName(type), className)} />;
default: {
assertNever(type);
}
}
}

export function bulkActionClassName(type: BulkActionType): string {
switch (type) {
case "REPLAY":
return "text-indigo-500";
case "CANCEL":
return "text-rose-500";
default: {
assertNever(type);
}
}
}

export function bulkActionTitle(type: BulkActionType): string {
switch (type) {
case "REPLAY":
return "Replay";
case "CANCEL":
return "Cancel";
default: {
assertNever(type);
}
}
}

export function bulkActionVerb(type: BulkActionType): string {
switch (type) {
case "REPLAY":
return "Replaying";
case "CANCEL":
return "Canceling";
default: {
assertNever(type);
}
}
}
Loading

0 comments on commit a5cba37

Please sign in to comment.