Files
Matt Aitken a5cba375ae Bulk replaying and canceling from the runs list (#1109)
* 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>
2024-05-20 17:17:33 +01:00

239 lines
9.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { useFetcher, useLocation, useNavigation } from "@remix-run/react";
import type { ConnectionType } from "@trigger.dev/database";
import { useState } from "react";
import simplur from "simplur";
import { useFeatures } from "~/hooks/useFeatures";
import { useTextFilter } from "~/hooks/useTextFilter";
import { ApiAuthenticationMethodOAuth2, Integration, Scope } from "~/services/externalApis/types";
import { cn } from "~/utils/cn";
import { CodeBlock } from "../code/CodeBlock";
import { Button } from "../primitives/Buttons";
import { CheckboxWithLabel } from "../primitives/Checkbox";
import { Fieldset } from "../primitives/Fieldset";
import { FormError } from "../primitives/FormError";
import { Header2, Header3 } from "../primitives/Headers";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
import { Paragraph } from "../primitives/Paragraph";
import { Client } from "~/presenters/IntegrationsPresenter.server";
import { schema } from "~/routes/resources.connection.$organizationId.oauth2.$integrationId";
export type Status = "loading" | "idle";
export function UpdateOAuthForm({
existingIntegration,
integration,
authMethod,
authMethodKey,
organizationId,
clientType,
callbackUrl,
}: {
existingIntegration: Client;
integration: Integration;
authMethod: ApiAuthenticationMethodOAuth2;
authMethodKey: string;
organizationId: string;
clientType: ConnectionType;
callbackUrl: string;
}) {
const transition = useNavigation();
const fetcher = useFetcher();
const { isManagedCloud } = useFeatures();
const [form, { title, scopes, hasCustomClient, customClientId, customClientSecret }] = useForm({
// TODO: type this
lastSubmission: fetcher.data as any,
onValidate({ formData }) {
return parse(formData, {
schema,
});
},
});
const location = useLocation();
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(
new Set(authMethod.scopes.filter((s) => s.defaultChecked).map((s) => s.name))
);
const requiresCustomOAuthApp = clientType === "EXTERNAL" || !isManagedCloud;
const [useMyOAuthApp, setUseMyOAuthApp] = useState(requiresCustomOAuthApp);
const { filterText, setFilterText, filteredItems } = useTextFilter<Scope>({
items: authMethod.scopes,
filter: (scope, text) => {
if (scope.name.toLowerCase().includes(text.toLowerCase())) return true;
if (scope.description && scope.description.toLowerCase().includes(text.toLowerCase()))
return true;
return false;
},
});
return (
<fetcher.Form
method="put"
action={`/resources/connection/${organizationId}/oauth2/${existingIntegration.id}`}
{...form.props}
className="flex h-full max-h-full flex-grow flex-col"
>
<Fieldset>
<input type="hidden" name="id" value={existingIntegration.id} />
<input type="hidden" name="integrationIdentifier" value={integration.identifier} />
<input type="hidden" name="integrationAuthMethod" value={authMethodKey} />
<input type="hidden" name="redirectTo" value={location.pathname} />
<InputGroup>
<FormError>{form.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label variant="large">ID</Label>
<Paragraph variant="small" className="mb-2">
{existingIntegration.slug}
</Paragraph>
</InputGroup>
<InputGroup fullWidth>
<Label variant="large">Name</Label>
<Input
type="text"
fullWidth
{...conform.input(title)}
defaultValue={existingIntegration.title ?? undefined}
placeholder={`e.g. Personal ${integration.name}`}
/>
<FormError>{title.error}</FormError>
</InputGroup>
<input type="hidden" name="clientType" value={clientType} />
<div>
<Header2>Use my OAuth App</Header2>
<Paragraph variant="small" className="mb-2">
To use your own OAuth app, check the option below and insert the details.
</Paragraph>
<CheckboxWithLabel
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
readOnly={requiresCustomOAuthApp}
onChange={(checked) => setUseMyOAuthApp(checked)}
{...conform.input(hasCustomClient, { type: "checkbox" })}
defaultChecked={requiresCustomOAuthApp}
/>
{useMyOAuthApp && (
<div className="ml-6 mt-2">
<Paragraph variant="small" className="mb-2">
Set the callback url to <CodeBlock code={callbackUrl} showLineNumbers={false} />
</Paragraph>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<InputGroup fullWidth>
<Label variant="small">Client ID</Label>
<Input fullWidth {...conform.input(customClientId, { type: "text" })} />
</InputGroup>
<InputGroup fullWidth>
<Label variant="small">Client secret</Label>
<Input
fullWidth
{...conform.input(customClientSecret, {
type: "password",
})}
/>
</InputGroup>
</div>
<FormError>{customClientId.error}</FormError>
</div>
</div>
)}
</div>
{authMethod.scopes.length > 0 && (
<div>
<Header2>Scopes</Header2>
<Paragraph variant="small" className="mb-4">
Select the scopes you want to grant to {integration.name} in order for it to access
your data. Note: If you try and perform an action in a Job that requires a scope you
havent granted, that task will fail.
</Paragraph>
{/* <Header3 className="mb-2">
Select from popular scope collections
</Header3>
<fieldset>
<Checkbox
id="allScopes"
label="Select all"
variant="button/small"
/>
</fieldset> */}
<div className="mb-2 mt-4 flex items-center justify-between">
<Header3>Select {integration.name} scopes</Header3>
<Paragraph variant="small" className="text-charcoal-500">
{simplur`${selectedScopes.size} scope[|s] selected`}
</Paragraph>
</div>
<Input
placeholder="Search scopes"
className="mb-2"
variant="medium"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
<div className="mb-28 flex flex-col gap-y-0.5 overflow-hidden rounded-md">
{filteredItems.length === 0 && (
<Paragraph variant="small" className="p-4">
No scopes match {filterText}. Try a different search query.
</Paragraph>
)}
{authMethod.scopes.map((s) => {
return (
<CheckboxWithLabel
key={s.name}
id={s.name}
value={s.name}
name="scopes"
label={s.name}
defaultChecked={s.defaultChecked ?? false}
badges={s.annotations?.map((a) => a.label)}
description={s.description}
variant="description"
className={cn(filteredItems.find((f) => f.name === s.name) ? "" : "hidden")}
onChange={(isChecked) => {
if (isChecked) {
setSelectedScopes((selected) => {
selected.add(s.name);
return new Set(selected);
});
} else {
setSelectedScopes((selected) => {
selected.delete(s.name);
return new Set(selected);
});
}
}}
/>
);
})}
</div>
</div>
)}
</Fieldset>
<div className="absolute bottom-0 left-0 flex w-full items-center justify-end gap-x-4 rounded-b-md border-t border-charcoal-800 bg-background-dimmed p-4">
<FormError>{scopes.error}</FormError>
<Button
type="submit"
className="flex gap-2"
disabled={transition.state !== "idle"}
variant="primary/medium"
LeadingIcon={integration.identifier}
>
{`Connect to ${integration.name}`}
</Button>
</div>
</fetcher.Form>
);
}