679 lines
24 KiB
TypeScript
679 lines
24 KiB
TypeScript
import { getFormProps, useForm, type FieldMetadata, type FormMetadata } from "@conform-to/react";
|
|
import { parseWithZod } from "@conform-to/zod";
|
|
import {
|
|
LockClosedIcon,
|
|
LockOpenIcon,
|
|
NoSymbolIcon,
|
|
PlusIcon,
|
|
XMarkIcon,
|
|
} from "@heroicons/react/20/solid";
|
|
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
|
|
import { json } from "@remix-run/server-runtime";
|
|
import dotenv from "dotenv";
|
|
import { useCallback, useState } from "react";
|
|
import { redirect } from "remix-typedjson";
|
|
import invariant from "tiny-invariant";
|
|
import { z } from "zod";
|
|
import { EnvironmentLabel, environmentFullTitle } from "~/components/environments/EnvironmentLabel";
|
|
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
|
import { CheckboxWithLabel } 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 { Paragraph } from "~/components/primitives/Paragraph";
|
|
import { Select, SelectItem } from "~/components/primitives/Select";
|
|
import { Switch } from "~/components/primitives/Switch";
|
|
import { TextLink } from "~/components/primitives/TextLink";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "~/components/primitives/Tooltip";
|
|
import { prisma } from "~/db.server";
|
|
import { useEnvironment } from "~/hooks/useEnvironment";
|
|
import { useList } from "~/hooks/useList";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import { useProject } from "~/hooks/useProject";
|
|
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
|
|
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
|
import {
|
|
environmentVariablesRouteId,
|
|
type loader as environmentVariablesLoader,
|
|
} from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route";
|
|
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
|
|
import { cn } from "~/utils/cn";
|
|
import {
|
|
EnvironmentParamSchema,
|
|
v3BillingPath,
|
|
v3EnvironmentVariablesPath,
|
|
} from "~/utils/pathBuilder";
|
|
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
|
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
|
|
|
|
const Variable = z.object({
|
|
key: EnvironmentVariableKey,
|
|
value: z.string().nonempty("Value is required"),
|
|
});
|
|
|
|
type Variable = z.infer<typeof Variable>;
|
|
|
|
const schema = z.object({
|
|
override: z.preprocess((i) => {
|
|
if (i === "true") return true;
|
|
if (i === "false") return false;
|
|
return;
|
|
}, z.boolean()),
|
|
isSecret: z.preprocess((i) => {
|
|
if (i === "true") return true;
|
|
return false;
|
|
}, 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 = dashboardAction(
|
|
{
|
|
params: EnvironmentParamSchema,
|
|
context: async (params) => {
|
|
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
|
return organizationId ? { organizationId } : {};
|
|
},
|
|
// Per-environment write:envvars is enforced in the handler — the target
|
|
// environments come from the submission, not the route params.
|
|
},
|
|
async ({ request, params, user, ability }) => {
|
|
const userId = user.id;
|
|
const { organizationSlug, projectParam, envParam } = params;
|
|
|
|
if (request.method.toUpperCase() !== "POST") {
|
|
throw new Response("Method Not Allowed", { status: 405 });
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const submission = parseWithZod(formData, { schema });
|
|
|
|
if (submission.status !== "success") {
|
|
return json(submission.reply());
|
|
}
|
|
|
|
// Enforce env-tier write:envvars for every targeted environment, so a role
|
|
// that can't write a deployed tier can't create vars there via a direct
|
|
// POST (the disabled checkboxes are not the boundary).
|
|
const targetEnvironments = await prisma.runtimeEnvironment.findMany({
|
|
where: { id: { in: submission.value.environmentIds } },
|
|
select: { type: true },
|
|
});
|
|
const hasDeniedEnvironment = targetEnvironments.some(
|
|
(env) => !ability.can("write", { type: "envvars", envType: env.type })
|
|
);
|
|
if (hasDeniedEnvironment) {
|
|
return json(
|
|
submission.reply({
|
|
fieldErrors: {
|
|
environmentIds: [
|
|
"You don't have permission to manage environment variables in one of the selected environments.",
|
|
],
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
const project = await prisma.project.findUnique({
|
|
where: {
|
|
slug: params.projectParam,
|
|
organization: {
|
|
members: {
|
|
some: {
|
|
userId,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
if (!project) {
|
|
return json(submission.reply({ formErrors: ["Project not found"] }));
|
|
}
|
|
|
|
const repository = new EnvironmentVariablesRepository(prisma);
|
|
const result = await repository.create(project.id, {
|
|
...submission.value,
|
|
lastUpdatedBy: {
|
|
type: "user",
|
|
userId,
|
|
},
|
|
});
|
|
|
|
if (!result.success) {
|
|
const fieldErrors: Record<string, string[]> = {};
|
|
if (result.variableErrors) {
|
|
for (const { key, error } of result.variableErrors) {
|
|
const index = submission.value.variables.findIndex((v) => v.key === key);
|
|
|
|
if (index !== -1) {
|
|
fieldErrors[`variables[${index}].key`] = [error];
|
|
}
|
|
}
|
|
} else {
|
|
fieldErrors.variables = [result.error];
|
|
}
|
|
|
|
return json(submission.reply({ fieldErrors }));
|
|
}
|
|
|
|
return redirect(
|
|
v3EnvironmentVariablesPath(
|
|
{ slug: organizationSlug },
|
|
{ slug: projectParam },
|
|
{ slug: envParam }
|
|
)
|
|
);
|
|
}
|
|
);
|
|
|
|
export default function Page() {
|
|
const [isOpen, _setIsOpen] = useState(true);
|
|
const parentData = useTypedMatchesData<typeof environmentVariablesLoader>({
|
|
id: environmentVariablesRouteId,
|
|
});
|
|
invariant(
|
|
parentData,
|
|
"Environment variables page loader data must be defined when rendering the create dialog"
|
|
);
|
|
const { environments, hasStaging, writableEnvironmentIds } = parentData;
|
|
// Creating a variable is a write, so gate the targets on write access.
|
|
const writableEnvironmentIdSet = new Set(writableEnvironmentIds);
|
|
const lastSubmission = useActionData();
|
|
const navigation = useNavigation();
|
|
const navigate = useNavigate();
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const environment = useEnvironment();
|
|
const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState<Set<string>>(new Set());
|
|
const [selectedBranchId, setSelectedBranchId] = useState<string | undefined>(undefined);
|
|
|
|
// TODO for no we only support branch-specific env vars for Preview environments
|
|
// Mostly to keep the UX for setting consistent env-vars across Dev/Staging/Prod easier
|
|
const previewBranches = environments.filter(
|
|
(env) => env.type === "PREVIEW" && env.parentEnvironmentId !== null
|
|
);
|
|
const nonBranchEnvironments = environments.filter((env) => env.parentEnvironmentId === null);
|
|
const selectedEnvironments = environments.filter((env) => selectedEnvironmentIds.has(env.id));
|
|
const previewIsSelected = selectedEnvironments.some((env) => env.type === "PREVIEW");
|
|
|
|
const isLoading = navigation.state !== "idle" && navigation.formMethod === "post";
|
|
|
|
const [form, fields] = useForm<z.infer<typeof schema>>({
|
|
id: "create-environment-variables",
|
|
// TODO: type this
|
|
lastResult: lastSubmission as any,
|
|
onValidate({ formData }) {
|
|
return parseWithZod(formData, { schema });
|
|
},
|
|
shouldRevalidate: "onSubmit",
|
|
defaultValue: {
|
|
variables: [{ key: "", value: "" }],
|
|
},
|
|
});
|
|
const { environmentIds, variables } = fields;
|
|
|
|
const handleEnvironmentChange = (
|
|
environmentId: string,
|
|
isChecked: boolean,
|
|
environmentType?: string
|
|
) => {
|
|
setSelectedEnvironmentIds((prev) => {
|
|
const newSet = new Set(prev);
|
|
|
|
if (isChecked) {
|
|
if (environmentType === "PREVIEW") {
|
|
// If PREVIEW is checked, clear all other selections including branches
|
|
newSet.clear();
|
|
|
|
newSet.add(environmentId);
|
|
} else {
|
|
// If a non-PREVIEW environment is checked, remove PREVIEW if it's selected
|
|
const previewEnv = environments.find((env) => env.type === "PREVIEW");
|
|
if (previewEnv) {
|
|
newSet.delete(previewEnv.id);
|
|
}
|
|
newSet.add(environmentId);
|
|
setSelectedBranchId(undefined);
|
|
}
|
|
} else {
|
|
newSet.delete(environmentId);
|
|
}
|
|
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
const handleBranchChange = (branchId: string) => {
|
|
if (branchId === "all") {
|
|
setSelectedBranchId(undefined);
|
|
} else {
|
|
setSelectedBranchId(branchId);
|
|
}
|
|
};
|
|
|
|
const [revealAll, setRevealAll] = useState(true);
|
|
|
|
return (
|
|
<Dialog
|
|
open={isOpen}
|
|
onOpenChange={(o) => {
|
|
if (!o) {
|
|
navigate(v3EnvironmentVariablesPath(organization, project, environment));
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="p-0 pt-2.5 md:max-w-2xl lg:max-w-3xl">
|
|
<DialogHeader className="px-4">New environment variables</DialogHeader>
|
|
<Form method="post" {...getFormProps(form)}>
|
|
<Fieldset className="max-h-[70vh] overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
|
<InputGroup fullWidth>
|
|
<Label>Environments</Label>
|
|
{selectedBranchId ? (
|
|
<input type="hidden" name="environmentIds" value={selectedBranchId} />
|
|
) : (
|
|
Array.from(selectedEnvironmentIds).map((id) => (
|
|
<input key={id} type="hidden" name="environmentIds" value={id} />
|
|
))
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
{nonBranchEnvironments.map((environment) =>
|
|
writableEnvironmentIdSet.has(environment.id) ? (
|
|
<CheckboxWithLabel
|
|
key={environment.id}
|
|
id={environment.id}
|
|
value={environment.id}
|
|
defaultChecked={selectedEnvironmentIds.has(environment.id)}
|
|
onChange={(isChecked) =>
|
|
handleEnvironmentChange(environment.id, isChecked, environment.type)
|
|
}
|
|
label={<EnvironmentLabel environment={environment} className="text-sm" />}
|
|
variant="button"
|
|
/>
|
|
) : (
|
|
<TooltipProvider key={environment.id}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div>
|
|
<CheckboxWithLabel
|
|
id={environment.id}
|
|
value={environment.id}
|
|
disabled
|
|
defaultChecked={false}
|
|
label={
|
|
<EnvironmentLabel environment={environment} className="text-sm" />
|
|
}
|
|
variant="button"
|
|
/>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="flex items-center gap-2">
|
|
<NoSymbolIcon className="size-4 text-text-dimmed" />
|
|
With your current role, you can't manage{" "}
|
|
{environmentFullTitle(environment)} environment variables.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)
|
|
)}
|
|
{!hasStaging && (
|
|
<>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<TextLink
|
|
to={v3BillingPath(organization)}
|
|
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-border-bright py-2.5 pl-3 pr-4 transition hover:border-border-brightest hover:bg-background-dimmed"
|
|
>
|
|
<LockClosedIcon className="size-4 text-text-faint" />
|
|
<EnvironmentLabel
|
|
environment={{ type: "STAGING" }}
|
|
className="text-sm"
|
|
/>
|
|
</TextLink>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="flex items-center gap-2">
|
|
<LockOpenIcon className="size-4 text-indigo-500" />
|
|
Upgrade your plan to add a Staging environment.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<TextLink
|
|
to={v3BillingPath(organization)}
|
|
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-border-bright py-2.5 pl-3 pr-4 transition hover:border-border-brightest hover:bg-background-dimmed"
|
|
>
|
|
<LockClosedIcon className="size-4 text-text-faint" />
|
|
<EnvironmentLabel
|
|
environment={{ type: "PREVIEW" }}
|
|
className="text-sm"
|
|
/>
|
|
</TextLink>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="flex items-center gap-2">
|
|
<LockOpenIcon className="size-4 text-indigo-500" />
|
|
Upgrade your plan to add Preview branches.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</>
|
|
)}
|
|
</div>
|
|
<FormError id={environmentIds.errorId}>{environmentIds.errors}</FormError>
|
|
<Hint>
|
|
Dev environment variables specified here will be overridden by ones in your .env
|
|
file when running locally.
|
|
</Hint>
|
|
</InputGroup>
|
|
|
|
{previewIsSelected && (
|
|
<InputGroup fullWidth>
|
|
<Label>Select branch</Label>
|
|
<div className="flex items-center gap-1">
|
|
<Select
|
|
variant="tertiary/medium"
|
|
value={selectedBranchId ?? "all"}
|
|
setValue={handleBranchChange}
|
|
placeholder="All branches"
|
|
items={[{ id: "all", branchName: "All branches" }, ...previewBranches]}
|
|
className="w-fit min-w-52"
|
|
filter={{
|
|
keys: [
|
|
(item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "",
|
|
],
|
|
}}
|
|
text={(val) =>
|
|
val ? previewBranches.find((b) => b.id === val)?.branchName : null
|
|
}
|
|
dropdownIcon
|
|
>
|
|
{(matches) =>
|
|
matches?.map((env) => (
|
|
<SelectItem key={env.id} value={env.id}>
|
|
{env.branchName}
|
|
</SelectItem>
|
|
))
|
|
}
|
|
</Select>
|
|
{selectedBranchId !== "all" && selectedBranchId !== undefined && (
|
|
<Button
|
|
variant="minimal/medium"
|
|
type="button"
|
|
onClick={() => setSelectedBranchId(undefined)}
|
|
LeadingIcon={XMarkIcon}
|
|
/>
|
|
)}
|
|
</div>
|
|
<Hint>Select a branch to override variables in the Preview environment.</Hint>
|
|
</InputGroup>
|
|
)}
|
|
|
|
<InputGroup className="w-auto">
|
|
<Switch
|
|
name="isSecret"
|
|
variant="medium"
|
|
defaultChecked={false}
|
|
value="true"
|
|
className="-ml-2 inline-flex w-fit"
|
|
label={<span className="text-text-bright">Secret value</span>}
|
|
/>
|
|
<Hint className="-mt-1">
|
|
If enabled, you and your team will not be able to view the values after creation.
|
|
</Hint>
|
|
</InputGroup>
|
|
<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}
|
|
form={form}
|
|
variablesFields={variables}
|
|
/>
|
|
<FormError id={variables.errorId}>{variables.errors}</FormError>
|
|
</InputGroup>
|
|
|
|
<FormError>{form.errors}</FormError>
|
|
</Fieldset>
|
|
<FormButtons
|
|
className="px-4 pb-4"
|
|
confirmButton={
|
|
<div className="flex flex-row-reverse items-center gap-2">
|
|
<Button
|
|
type="submit"
|
|
variant="primary/medium"
|
|
disabled={isLoading}
|
|
name="override"
|
|
value="false"
|
|
>
|
|
{isLoading ? "Saving" : "Save"}
|
|
</Button>
|
|
<Button
|
|
variant="secondary/medium"
|
|
disabled={isLoading}
|
|
name="override"
|
|
value="true"
|
|
>
|
|
{isLoading ? "Overriding" : "Override"}
|
|
</Button>
|
|
</div>
|
|
}
|
|
cancelButton={
|
|
<LinkButton
|
|
to={v3EnvironmentVariablesPath(organization, project, environment)}
|
|
variant="tertiary/medium"
|
|
>
|
|
Cancel
|
|
</LinkButton>
|
|
}
|
|
/>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function FieldLayout({ children }: { children: React.ReactNode }) {
|
|
return <div className="grid w-full grid-cols-[1fr_1fr] gap-1.5">{children}</div>;
|
|
}
|
|
|
|
function VariableFields({
|
|
revealValues,
|
|
formId,
|
|
variablesFields,
|
|
form,
|
|
}: {
|
|
revealValues: boolean;
|
|
formId?: string;
|
|
variablesFields: FieldMetadata<Variable[]>;
|
|
form: FormMetadata<any>;
|
|
}) {
|
|
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");
|
|
if (!text) return;
|
|
|
|
const variables = dotenv.parse(text);
|
|
const keyValuePairs = Object.entries(variables).map(([key, value]) => ({ key, value }));
|
|
|
|
//do the default paste
|
|
if (keyValuePairs.length === 0) return;
|
|
|
|
//prevent default pasting
|
|
e.preventDefault();
|
|
|
|
const [firstPair, ...rest] = keyValuePairs;
|
|
update(index, firstPair);
|
|
|
|
for (const _pair of rest) {
|
|
form.insert({ name: variablesFields.name });
|
|
}
|
|
insertAfter(index, rest);
|
|
}, []);
|
|
|
|
const fields = variablesFields.getFieldList();
|
|
|
|
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={() => {
|
|
form.remove({ name: variablesFields.name, index });
|
|
remove(index);
|
|
}}
|
|
showDeleteButton={items.length > 1}
|
|
showValue={revealValues}
|
|
config={field}
|
|
/>
|
|
);
|
|
})}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<Paragraph variant="extra-small">
|
|
Tip: Paste all your .env values at once into this form to populate it.
|
|
</Paragraph>
|
|
<Button
|
|
variant="tertiary/medium"
|
|
className="w-fit"
|
|
type="button"
|
|
onClick={() => {
|
|
form.insert({ name: variablesFields.name });
|
|
append([{ key: "", value: "" }]);
|
|
}}
|
|
LeadingIcon={PlusIcon}
|
|
>
|
|
Add another
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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: FieldMetadata<Variable>;
|
|
}) {
|
|
const fields = config.getFieldset();
|
|
const baseFieldName = `variables[${index}]`;
|
|
|
|
return (
|
|
<fieldset>
|
|
<FieldLayout>
|
|
<div className="space-y-2">
|
|
<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}
|
|
/>
|
|
<FormError id={fields.key.errorId}>{fields.key.errors}</FormError>
|
|
</div>
|
|
<div className={cn("flex items-start gap-1")}>
|
|
<div className="grow space-y-2">
|
|
<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 })}
|
|
/>
|
|
<FormError id={fields.value.errorId}>{fields.value.errors}</FormError>
|
|
</div>
|
|
{showDeleteButton && (
|
|
<Button
|
|
variant="minimal/medium"
|
|
type="button"
|
|
onClick={() => onDelete()}
|
|
LeadingIcon={XMarkIcon}
|
|
/>
|
|
)}
|
|
</div>
|
|
</FieldLayout>
|
|
</fieldset>
|
|
);
|
|
}
|