Merge branch 'main' into v3/self-hosting
This commit is contained in:
@@ -4,13 +4,15 @@ on:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: 1
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
short_sha: ${{ steps.get_commit.outputs.sha_short }}
|
||||
steps:
|
||||
- name: Setup Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
export function Hint({ children }: { children: React.ReactNode }) {
|
||||
return <Paragraph variant="extra-small">{children}</Paragraph>;
|
||||
export function Hint({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<Paragraph variant="extra-small" className={className}>
|
||||
{children}
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,7 +195,12 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
{environment ? (
|
||||
<>
|
||||
{" "}
|
||||
in <EnvironmentLabel environment={environment} size="large" />
|
||||
in{" "}
|
||||
<EnvironmentLabel
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
size="large"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Paragraph>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Reducer, useReducer } from "react";
|
||||
|
||||
export type ListState<T> = {
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type AppendAction<T> = {
|
||||
type: "append";
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type UpdateAction<T> = {
|
||||
type: "update";
|
||||
index: number;
|
||||
item: T;
|
||||
};
|
||||
|
||||
type DeleteAction<T> = {
|
||||
type: "delete";
|
||||
index: number;
|
||||
};
|
||||
|
||||
type InsertAfter<T> = {
|
||||
type: "insertAfter";
|
||||
index: number;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type Action<T> = AppendAction<T> | UpdateAction<T> | DeleteAction<T> | InsertAfter<T>;
|
||||
|
||||
function reducer<T>(state: ListState<T>, action: Action<T>): ListState<T> {
|
||||
switch (action.type) {
|
||||
case "append":
|
||||
return { items: [...state.items, ...action.items] };
|
||||
case "update":
|
||||
return {
|
||||
items: state.items.map((v, i) => (i === action.index ? action.item : v)),
|
||||
};
|
||||
case "delete":
|
||||
return { items: state.items.filter((_, i) => i !== action.index) };
|
||||
case "insertAfter":
|
||||
return {
|
||||
items: [
|
||||
...state.items.slice(0, action.index + 1),
|
||||
...action.items,
|
||||
...state.items.slice(action.index + 1),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type HookReturn<T> = {
|
||||
items: T[];
|
||||
append: (items: T[]) => void;
|
||||
update: (index: number, item: T) => void;
|
||||
delete: (index: number) => void;
|
||||
insertAfter: (index: number, items: T[]) => void;
|
||||
};
|
||||
|
||||
export function useList<T>(initialItems: T[]): HookReturn<T> {
|
||||
const [state, dispatch] = useReducer<Reducer<ListState<T>, Action<T>>>(reducer, {
|
||||
items: initialItems,
|
||||
});
|
||||
|
||||
return {
|
||||
items: state.items,
|
||||
append: (items: T[]) => dispatch({ type: "append", items }),
|
||||
update: (index: number, item: T) => dispatch({ type: "update", index, item }),
|
||||
delete: (index: number) => dispatch({ type: "delete", index }),
|
||||
insertAfter: (index: number, items: T[]) => dispatch({ type: "insertAfter", index, items }),
|
||||
};
|
||||
}
|
||||
@@ -63,17 +63,18 @@ export class TestPresenter {
|
||||
const searchParams = createSearchParams(url, TestSearchParams);
|
||||
|
||||
//no environmentId
|
||||
if (!searchParams.success || !searchParams.params.get("environment")) {
|
||||
if (!searchParams.success) {
|
||||
return {
|
||||
hasSelectedEnvironment: false as const,
|
||||
environments,
|
||||
};
|
||||
}
|
||||
|
||||
//default to dev environment
|
||||
const environment = searchParams.params.get("environment") ?? "dev";
|
||||
|
||||
//is the environmentId valid?
|
||||
const matchingEnvironment = project.environments.find(
|
||||
(env) => env.slug === searchParams.params.get("environment")
|
||||
);
|
||||
const matchingEnvironment = project.environments.find((env) => env.slug === environment);
|
||||
if (!matchingEnvironment) {
|
||||
return {
|
||||
hasSelectedEnvironment: false as const,
|
||||
|
||||
@@ -171,17 +171,23 @@ export class TestTaskPresenter {
|
||||
return {
|
||||
triggerSource: "SCHEDULED",
|
||||
task: taskWithEnvironment,
|
||||
runs: await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
runs: (
|
||||
await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: await getScheduleTaskRunPayload(r),
|
||||
};
|
||||
})
|
||||
),
|
||||
const payload = await getScheduleTaskRunPayload(r);
|
||||
|
||||
if (payload.success) {
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: payload.data,
|
||||
};
|
||||
}
|
||||
})
|
||||
)
|
||||
).filter(Boolean),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -189,6 +195,6 @@ export class TestTaskPresenter {
|
||||
|
||||
async function getScheduleTaskRunPayload(run: RawRun) {
|
||||
const payload = await parsePacket({ data: run.payload, dataType: run.payloadType });
|
||||
const parsed = ScheduledTaskPayload.parse(payload);
|
||||
const parsed = ScheduledTaskPayload.safeParse(payload);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
+287
-98
@@ -1,35 +1,43 @@
|
||||
import { Submission, conform, useForm } from "@conform-to/react";
|
||||
import {
|
||||
FieldConfig,
|
||||
list,
|
||||
requestIntent,
|
||||
useFieldList,
|
||||
useFieldset,
|
||||
useForm,
|
||||
} from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, useLocation, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { RefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import {
|
||||
environmentTextClassName,
|
||||
environmentTitle,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useList } from "~/hooks/useList";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3NewEnvironmentVariablesPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3EnvironmentVariablesPath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import { CreateEnvironmentVariable } from "~/v3/environmentVariables/repository";
|
||||
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -47,7 +55,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
environments,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
@@ -55,9 +62,39 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
}
|
||||
};
|
||||
|
||||
const Variable = z.object({
|
||||
key: EnvironmentVariableKey,
|
||||
value: z.string().nonempty("Value is required"),
|
||||
});
|
||||
|
||||
type Variable = z.infer<typeof Variable>;
|
||||
|
||||
const schema = z.object({
|
||||
action: z.enum(["create", "create-more"]),
|
||||
...CreateEnvironmentVariable.shape,
|
||||
overwrite: z.preprocess((i) => {
|
||||
if (i === "true") return true;
|
||||
if (i === "false") return false;
|
||||
return;
|
||||
}, z.boolean()),
|
||||
environmentIds: z.preprocess((i) => {
|
||||
if (typeof i === "string") return [i];
|
||||
|
||||
if (Array.isArray(i)) {
|
||||
const ids = i.filter((v) => typeof v === "string" && v !== "");
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
return;
|
||||
}, z.array(z.string(), { required_error: "At least one environment is required" })),
|
||||
variables: z.preprocess((i) => {
|
||||
if (!Array.isArray(i)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return i;
|
||||
}, Variable.array().nonempty("At least one variable is required")),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
@@ -92,22 +129,22 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const result = await repository.create(project.id, userId, submission.value);
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.key = result.error;
|
||||
if (result.variableErrors) {
|
||||
for (const { key, error } of result.variableErrors) {
|
||||
const index = submission.value.variables.findIndex((v) => v.key === key);
|
||||
|
||||
if (index !== -1) {
|
||||
submission.error[`variables[${index}].key`] = error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
submission.error.variables = result.error;
|
||||
}
|
||||
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "create":
|
||||
return redirect(
|
||||
v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam })
|
||||
);
|
||||
case "create-more":
|
||||
return redirectWithSuccessMessage(
|
||||
v3NewEnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Created ${submission.value.key} environment variable`
|
||||
);
|
||||
}
|
||||
return redirect(v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }));
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
@@ -118,15 +155,11 @@ export default function Page() {
|
||||
const navigate = useNavigate();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const keyFieldRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "create";
|
||||
const isLoading = navigation.state !== "idle" && navigation.formMethod === "post";
|
||||
|
||||
const [form, { key }] = useForm({
|
||||
id: "create-environment-variable",
|
||||
const [form, { environmentIds, variables }] = useForm({
|
||||
id: "create-environment-variables",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
@@ -141,14 +174,6 @@ export default function Page() {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (navigation.state !== "idle") return;
|
||||
if (lastSubmission !== undefined) return;
|
||||
|
||||
form.ref.current?.reset();
|
||||
keyFieldRef.current?.focus();
|
||||
}, [navigation.state, lastSubmission]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
@@ -159,60 +184,63 @@ export default function Page() {
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>New environment variable</DialogHeader>
|
||||
<Form method="post" {...form.props}>
|
||||
<DialogHeader>New environment variables</DialogHeader>
|
||||
<Form
|
||||
method="post"
|
||||
{...form.props}
|
||||
className="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<Fieldset className="mt-2">
|
||||
<InputGroup fullWidth>
|
||||
<Label>Key</Label>
|
||||
<Input
|
||||
{...conform.input(key)}
|
||||
placeholder="e.g. CLIENT_KEY"
|
||||
autoFocus
|
||||
ref={keyFieldRef}
|
||||
/>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Values</Label>
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Reveal values"
|
||||
checked={revealAll}
|
||||
onCheckedChange={(e) => setRevealAll(e.valueOf())}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-2">
|
||||
{environments.map((environment, index) => {
|
||||
return (
|
||||
<Fragment key={environment.id}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={`values[${index}].environmentId`}
|
||||
value={environment.id}
|
||||
/>
|
||||
<label
|
||||
className="flex items-center justify-end"
|
||||
htmlFor={`values[${index}].value`}
|
||||
<Label>Environments</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{environments.map((environment) => (
|
||||
<Checkbox
|
||||
key={environment.id}
|
||||
id={environment.id}
|
||||
value={environment.id}
|
||||
name="environmentIds"
|
||||
type="radio"
|
||||
label={
|
||||
<span
|
||||
className={cn("text-xs uppercase", environmentTextClassName(environment))}
|
||||
>
|
||||
<EnvironmentLabel environment={environment} className="h-5 px-2" />
|
||||
</label>
|
||||
<Input
|
||||
type={revealAll ? "text" : "password"}
|
||||
name={`values[${index}].value`}
|
||||
placeholder="Not set"
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{environmentTitle(environment)}
|
||||
</span>
|
||||
}
|
||||
variant="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormError id={environmentIds.errorId}>{environmentIds.error}</FormError>
|
||||
<Hint>
|
||||
Dev environment variables specified here will be overridden by ones in your .env
|
||||
file when running locally.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<Hint>Tip: Paste your .env into this form to populate it:</Hint>
|
||||
<InputGroup fullWidth>
|
||||
<FieldLayout>
|
||||
<Label>Keys</Label>
|
||||
<div className="flex justify-between gap-1">
|
||||
<Label>Values</Label>
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Reveal"
|
||||
checked={revealAll}
|
||||
onCheckedChange={(e) => setRevealAll(e.valueOf())}
|
||||
/>
|
||||
</div>
|
||||
</FieldLayout>
|
||||
<VariableFields
|
||||
revealValues={revealAll}
|
||||
formId={form.id}
|
||||
formRef={form.ref}
|
||||
variablesFields={variables}
|
||||
/>
|
||||
<FormError id={variables.errorId}>{variables.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<Callout variant="info" className="inline-flex">
|
||||
Dev environment variables specified here will be overridden by ones in your{" "}
|
||||
<InlineCode variant="extra-small">.env</InlineCode> file when running locally.
|
||||
</Callout>
|
||||
|
||||
<FormError id={key.errorId}>{key.error}</FormError>
|
||||
<FormError>{form.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
@@ -221,18 +249,18 @@ export default function Page() {
|
||||
type="submit"
|
||||
variant="primary/small"
|
||||
disabled={isLoading}
|
||||
name="action"
|
||||
value="create-more"
|
||||
name="overwrite"
|
||||
value="false"
|
||||
>
|
||||
{isLoading ? "Saving" : "Save and add another"}
|
||||
{isLoading ? "Saving" : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
disabled={isLoading}
|
||||
name="action"
|
||||
value="create"
|
||||
name="overwrite"
|
||||
value="true"
|
||||
>
|
||||
{isLoading ? "Saving" : "Save"}
|
||||
{isLoading ? "Overwriting" : "Overwrite"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -251,3 +279,164 @@ export default function Page() {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid w-full grid-cols-[1fr_1fr_2rem] gap-2">{children}</div>;
|
||||
}
|
||||
|
||||
function VariableFields({
|
||||
revealValues,
|
||||
formId,
|
||||
variablesFields,
|
||||
formRef,
|
||||
}: {
|
||||
revealValues: boolean;
|
||||
formId?: string;
|
||||
variablesFields: FieldConfig<any>;
|
||||
formRef: RefObject<HTMLFormElement>;
|
||||
}) {
|
||||
const {
|
||||
items,
|
||||
append,
|
||||
update,
|
||||
delete: remove,
|
||||
insertAfter,
|
||||
} = useList<Variable>([{ key: "", value: "" }]);
|
||||
|
||||
const handlePaste = useCallback((index: number, e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const clipboardData = e.clipboardData;
|
||||
if (!clipboardData) return;
|
||||
|
||||
let text = clipboardData.getData("text");
|
||||
//replace carriage returns
|
||||
text = text.replace(/\r/g, "");
|
||||
const lines = text.split("\n");
|
||||
|
||||
const keyValuePairs = lines.flatMap((line) => {
|
||||
if (line.trim().startsWith("#")) return [];
|
||||
|
||||
const split = line.split("=");
|
||||
if (split.length === 2) {
|
||||
return [{ key: split[0], value: split[1] }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
if (keyValuePairs.length === 0) return;
|
||||
|
||||
//prevent default pasting
|
||||
e.preventDefault();
|
||||
|
||||
const [firstPair, ...rest] = keyValuePairs;
|
||||
update(index, firstPair);
|
||||
|
||||
for (const pair of rest) {
|
||||
requestIntent(formRef.current ?? undefined, list.append(variablesFields.name));
|
||||
}
|
||||
insertAfter(index, rest);
|
||||
}, []);
|
||||
|
||||
const fields = useFieldList(formRef, variablesFields);
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.map((field, index) => {
|
||||
const item = items[index];
|
||||
|
||||
return (
|
||||
<VariableField
|
||||
formId={formId}
|
||||
key={index}
|
||||
index={index}
|
||||
value={item}
|
||||
onChange={(value) => update(index, value)}
|
||||
onPaste={(e) => handlePaste(index, e)}
|
||||
onDelete={() => {
|
||||
requestIntent(
|
||||
formRef.current ?? undefined,
|
||||
list.remove(variablesFields.name, { index })
|
||||
);
|
||||
remove(index);
|
||||
}}
|
||||
showDeleteButton={items.length > 1}
|
||||
showValue={revealValues}
|
||||
config={field}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
requestIntent(formRef.current ?? undefined, list.append(variablesFields.name));
|
||||
append([{ key: "", value: "" }]);
|
||||
}}
|
||||
LeadingIcon={PlusIcon}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableField({
|
||||
formId,
|
||||
index,
|
||||
value,
|
||||
onChange,
|
||||
onPaste,
|
||||
onDelete,
|
||||
showDeleteButton,
|
||||
showValue,
|
||||
config,
|
||||
}: {
|
||||
formId?: string;
|
||||
index: number;
|
||||
value: Variable;
|
||||
onChange: (value: Variable) => void;
|
||||
onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => void;
|
||||
onDelete: () => void;
|
||||
showDeleteButton: boolean;
|
||||
showValue: boolean;
|
||||
config: FieldConfig<Variable>;
|
||||
}) {
|
||||
const ref = useRef<HTMLFieldSetElement>(null);
|
||||
const fields = useFieldset(ref, config);
|
||||
const baseFieldName = `variables[${index}]`;
|
||||
|
||||
return (
|
||||
<fieldset ref={ref}>
|
||||
<FieldLayout>
|
||||
<Input
|
||||
id={`${formId}-${baseFieldName}.key`}
|
||||
name={`${baseFieldName}.key`}
|
||||
placeholder="e.g. CLIENT_KEY"
|
||||
value={value.key}
|
||||
onChange={(e) => onChange({ ...value, key: e.currentTarget.value })}
|
||||
autoFocus={index === 0}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
<Input
|
||||
id={`${formId}-${baseFieldName}.value`}
|
||||
name={`${baseFieldName}.value`}
|
||||
type={showValue ? "text" : "password"}
|
||||
placeholder="Not set"
|
||||
value={value.value}
|
||||
onChange={(e) => onChange({ ...value, value: e.currentTarget.value })}
|
||||
/>
|
||||
{showDeleteButton && (
|
||||
<Button
|
||||
variant="minimal/medium"
|
||||
type="button"
|
||||
onClick={() => onDelete()}
|
||||
LeadingIcon={XMarkIcon}
|
||||
/>
|
||||
)}
|
||||
</FieldLayout>
|
||||
<div className="space-y-2">
|
||||
<FormError id={fields.key.errorId}>{fields.key.error}</FormError>
|
||||
<FormError id={fields.value.errorId}>{fields.value.error}</FormError>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -187,8 +187,9 @@ export default function Page() {
|
||||
to={v3NewEnvironmentVariablesPath(organization, project)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New environment variable
|
||||
Add new
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Table>
|
||||
|
||||
+57
-4
@@ -9,6 +9,7 @@ import {
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
@@ -32,6 +34,7 @@ import { useLinkStatus } from "~/hooks/useLinkStatus";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTextFilter } from "~/hooks/useTextFilter";
|
||||
import {
|
||||
SelectedEnvironment,
|
||||
TaskListItem,
|
||||
@@ -67,7 +70,7 @@ export default function Page() {
|
||||
|
||||
//get optimistic location for the segment control
|
||||
const optimisticLocation = useOptimisticLocation();
|
||||
const environment = new URLSearchParams(optimisticLocation.search).get("environment");
|
||||
const environment = new URLSearchParams(optimisticLocation.search).get("environment") ?? "dev";
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
@@ -150,8 +153,50 @@ function TaskSelector({
|
||||
tasks: TaskListItem[];
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
const { filterText, setFilterText, filteredItems } = useTextFilter<TaskListItem>({
|
||||
items: tasks,
|
||||
filter: (task, text) => {
|
||||
if (task.taskIdentifier.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.exportName.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.filePath.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.id.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.friendlyId.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-charcoal-800 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="px-2 pb-2">
|
||||
<Input
|
||||
placeholder="Search tasks"
|
||||
variant="medium"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
autoFocus
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -163,9 +208,17 @@ function TaskSelector({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((t) => (
|
||||
<TaskRow key={t.friendlyId} task={t} environmentSlug={environmentSlug} />
|
||||
))}
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((t) => (
|
||||
<TaskRow key={t.friendlyId} task={t} environmentSlug={environmentSlug} />
|
||||
))
|
||||
) : (
|
||||
<TableBlankRow colSpan={3}>
|
||||
<Paragraph spacing variant="small">
|
||||
No tasks match "{filterText}"
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Prisma, PrismaClient } from "@trigger.dev/database";
|
||||
import { Prisma, PrismaClient, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { environmentTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { EnvironmentVariable, ProjectEnvironmentVariable, Repository, Result } from "./repository";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
CreateResult,
|
||||
EnvironmentVariable,
|
||||
ProjectEnvironmentVariable,
|
||||
Repository,
|
||||
Result,
|
||||
} from "./repository";
|
||||
|
||||
function secretKeyProjectPrefix(projectId: string) {
|
||||
return `environmentvariable:${projectId}:`;
|
||||
@@ -35,8 +42,15 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
async create(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
options: { key: string; values: { value: string; environmentId: string }[] }
|
||||
): Promise<Result> {
|
||||
options: {
|
||||
overwrite: boolean;
|
||||
environmentIds: string[];
|
||||
variables: {
|
||||
key: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
): Promise<CreateResult> {
|
||||
const project = await this.prismaClient.project.findUnique({
|
||||
where: {
|
||||
id: projectId,
|
||||
@@ -55,6 +69,18 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
environmentVariables: {
|
||||
select: {
|
||||
key: true,
|
||||
values: {
|
||||
select: {
|
||||
environment: {
|
||||
select: { id: true, type: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,58 +88,109 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
return { success: false as const, error: "Project not found" };
|
||||
}
|
||||
|
||||
if (options.values.every((v) => !project.environments.some((e) => e.id === v.environmentId))) {
|
||||
if (options.environmentIds.every((v) => !project.environments.some((e) => e.id === v))) {
|
||||
return { success: false as const, error: `Environment not found` };
|
||||
}
|
||||
|
||||
//get rid of empty strings
|
||||
const values = options.values.filter((v) => v.value.trim() !== "");
|
||||
|
||||
//get rid of empty variables
|
||||
const values = options.variables.filter((v) => v.key.trim() !== "" && v.value.trim() !== "");
|
||||
if (values.length === 0) {
|
||||
return { success: false as const, error: `You must set at least one value` };
|
||||
}
|
||||
|
||||
//check if any of them exist in an environment we're setting
|
||||
if (!options.overwrite) {
|
||||
const existingVariableKeys: { key: string; environments: RuntimeEnvironmentType[] }[] = [];
|
||||
for (const variable of values) {
|
||||
const existingVariable = project.environmentVariables.find((v) => v.key === variable.key);
|
||||
if (
|
||||
existingVariable &&
|
||||
existingVariable.values.some((v) => options.environmentIds.includes(v.environment.id))
|
||||
) {
|
||||
existingVariableKeys.push({
|
||||
key: variable.key,
|
||||
environments: existingVariable.values
|
||||
.filter((v) => options.environmentIds.includes(v.environment.id))
|
||||
.map((v) => v.environment.type),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (existingVariableKeys.length > 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: `Some of the variables are already set for these environments`,
|
||||
variableErrors: existingVariableKeys.map((val) => ({
|
||||
key: val.key,
|
||||
error: `Variable already set in ${val.environments
|
||||
.map((e) => environmentTitle({ type: e }))
|
||||
.join(", ")}.`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $transaction(this.prismaClient, async (tx) => {
|
||||
const environmentVariable = await tx.environmentVariable.create({
|
||||
data: {
|
||||
key: options.key,
|
||||
friendlyId: generateFriendlyId("envvar"),
|
||||
project: {
|
||||
connect: {
|
||||
id: projectId,
|
||||
for (const variable of values) {
|
||||
const environmentVariable = await tx.environmentVariable.upsert({
|
||||
where: {
|
||||
projectId_key: {
|
||||
key: variable.key,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const secretStore = getSecretStore("DATABASE", {
|
||||
prismaClient: tx,
|
||||
});
|
||||
|
||||
//create the secret values and references
|
||||
for (const value of values) {
|
||||
const key = secretKey(projectId, value.environmentId, options.key);
|
||||
|
||||
//create the secret reference
|
||||
const secretReference = await tx.secretReference.create({
|
||||
data: {
|
||||
key,
|
||||
provider: "DATABASE",
|
||||
create: {
|
||||
key: variable.key,
|
||||
friendlyId: generateFriendlyId("envvar"),
|
||||
project: {
|
||||
connect: {
|
||||
id: projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const variableValue = await tx.environmentVariableValue.create({
|
||||
data: {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId: value.environmentId,
|
||||
valueReferenceId: secretReference.id,
|
||||
},
|
||||
const secretStore = getSecretStore("DATABASE", {
|
||||
prismaClient: tx,
|
||||
});
|
||||
|
||||
await secretStore.setSecret<{ secret: string }>(key, {
|
||||
secret: value.value,
|
||||
});
|
||||
//set the secret values and references
|
||||
for (const environmentId of options.environmentIds) {
|
||||
const key = secretKey(projectId, environmentId, variable.key);
|
||||
|
||||
//create the secret reference
|
||||
const secretReference = await tx.secretReference.upsert({
|
||||
where: {
|
||||
key,
|
||||
},
|
||||
create: {
|
||||
key,
|
||||
provider: "DATABASE",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const variableValue = await tx.environmentVariableValue.upsert({
|
||||
where: {
|
||||
variableId_environmentId: {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId: environmentId,
|
||||
valueReferenceId: secretReference.id,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await secretStore.setSecret<{ secret: string }>(key, {
|
||||
secret: variable.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -126,7 +203,7 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
if (error.code === "P2002") {
|
||||
return {
|
||||
success: false as const,
|
||||
error: `There's already an environment variable called ${options.key}.`,
|
||||
error: `There was already an existing field`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvironmentVariable = z
|
||||
export const EnvironmentVariableKey = z
|
||||
.string()
|
||||
.nonempty("Environment variable key is required")
|
||||
.regex(/^\w+$/, "Environment variables can only contain alphanumeric characters and underscores");
|
||||
.nonempty("Key is required")
|
||||
.regex(/^\w+$/, "Keys can only use alphanumeric characters and underscores");
|
||||
|
||||
export const CreateEnvironmentVariable = z.object({
|
||||
key: EnvironmentVariable,
|
||||
values: z.array(
|
||||
z.object({
|
||||
environmentId: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
),
|
||||
export const CreateEnvironmentVariables = z.object({
|
||||
environmentIds: z.array(z.string()),
|
||||
variables: z.array(z.object({ key: EnvironmentVariableKey, value: z.string() })),
|
||||
});
|
||||
|
||||
export type CreateEnvironmentVariable = z.infer<typeof CreateEnvironmentVariable>;
|
||||
export type CreateEnvironmentVariables = z.infer<typeof CreateEnvironmentVariables>;
|
||||
|
||||
export type CreateResult =
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
variableErrors?: { key: string; error: string }[];
|
||||
};
|
||||
|
||||
export const EditEnvironmentVariable = z.object({
|
||||
id: z.string(),
|
||||
@@ -60,7 +65,11 @@ export type EnvironmentVariable = {
|
||||
};
|
||||
|
||||
export interface Repository {
|
||||
create(projectId: string, userId: string, options: CreateEnvironmentVariable): Promise<Result>;
|
||||
create(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
options: CreateEnvironmentVariables
|
||||
): Promise<CreateResult>;
|
||||
edit(projectId: string, userId: string, options: EditEnvironmentVariable): Promise<Result>;
|
||||
getProject(projectId: string, userId: string): Promise<ProjectEnvironmentVariable[]>;
|
||||
getEnvironment(
|
||||
|
||||
@@ -76,6 +76,15 @@ export class TriggerTaskService extends BaseService {
|
||||
immediate: true,
|
||||
},
|
||||
async (event, traceContext) => {
|
||||
const runFriendlyId = generateFriendlyId("run");
|
||||
|
||||
const payloadPacket = await this.#handlePayloadPacket(
|
||||
body.payload,
|
||||
body.options?.payloadType ?? "application/json",
|
||||
runFriendlyId,
|
||||
environment
|
||||
);
|
||||
|
||||
const lockId = taskIdentifierToLockId(taskId);
|
||||
|
||||
const run = await $transaction(this._prisma, async (tx) => {
|
||||
@@ -105,15 +114,6 @@ export class TriggerTaskService extends BaseService {
|
||||
event.setAttribute("queueName", queueName);
|
||||
span.setAttribute("queueName", queueName);
|
||||
|
||||
const runFriendlyId = generateFriendlyId("run");
|
||||
|
||||
const payloadPacket = await this.#handlePayloadPacket(
|
||||
body.payload,
|
||||
body.options?.payloadType ?? "application/json",
|
||||
runFriendlyId,
|
||||
environment
|
||||
);
|
||||
|
||||
const taskRun = await tx.taskRun.create({
|
||||
data: {
|
||||
status: "PENDING",
|
||||
|
||||
@@ -7,8 +7,10 @@ This simple GitHub action file will deploy you Trigger.dev tasks when new code i
|
||||
|
||||
<Warning>The deploy step will fail if any version mismatches are detected. Please see the [version pinning](/v3/github-actions#version-pinning) section for more details.</Warning>
|
||||
|
||||
```yaml .github/workflows/release-trigger.yml
|
||||
name: Deploy to Trigger.dev
|
||||
<CodeGroup>
|
||||
|
||||
```yaml .github/workflows/release-trigger-prod.yml
|
||||
name: Deploy to Trigger.dev (prod)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -39,6 +41,37 @@ jobs:
|
||||
npx trigger.dev@beta deploy
|
||||
```
|
||||
|
||||
|
||||
```yaml .github/workflows/release-trigger-staging.yml
|
||||
name: Deploy to Trigger.dev (staging)
|
||||
|
||||
# Requires manually calling the workflow from a branch / commit to deploy to staging
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: 🚀 Deploy Trigger.dev
|
||||
env:
|
||||
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
|
||||
run: |
|
||||
npx trigger.dev@beta deploy --env staging
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
If you already have a GitHub action file, you can just add the final step "🚀 Deploy Trigger.dev" to your existing file.
|
||||
|
||||
You need to add the `TRIGGER_ACCESS_TOKEN` secret to your repository. You can create a new access token by going to your profile page and then clicking on the "Personal Access Tokens" tab.
|
||||
|
||||
Reference in New Issue
Block a user