chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
BranchEnvironmentIconSmall,
|
||||
DeployedEnvironmentIconSmall,
|
||||
DevEnvironmentIconSmall,
|
||||
ProdEnvironmentIconSmall,
|
||||
} from "~/assets/icons/EnvironmentIcons";
|
||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Environment = Pick<RuntimeEnvironment, "type"> & { branchName?: string | null };
|
||||
|
||||
export function EnvironmentIcon({
|
||||
environment,
|
||||
className,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
}) {
|
||||
if (environment.branchName) {
|
||||
return (
|
||||
<BranchEnvironmentIconSmall
|
||||
className={cn(environmentTextClassName(environment), className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (environment.type) {
|
||||
case "DEVELOPMENT":
|
||||
return (
|
||||
<DevEnvironmentIconSmall className={cn(environmentTextClassName(environment), className)} />
|
||||
);
|
||||
case "PRODUCTION":
|
||||
return (
|
||||
<ProdEnvironmentIconSmall
|
||||
className={cn(environmentTextClassName(environment), className)}
|
||||
/>
|
||||
);
|
||||
case "STAGING":
|
||||
case "PREVIEW":
|
||||
return (
|
||||
<DeployedEnvironmentIconSmall
|
||||
className={cn(environmentTextClassName(environment), className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function EnvironmentCombo({
|
||||
environment,
|
||||
className,
|
||||
iconClassName,
|
||||
tooltipSideOffset,
|
||||
tooltipSide,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1.5 text-sm text-text-bright", className)}>
|
||||
<EnvironmentIcon
|
||||
environment={environment}
|
||||
className={cn("size-4.5 shrink-0", iconClassName)}
|
||||
/>
|
||||
<EnvironmentLabel
|
||||
environment={environment}
|
||||
tooltipSideOffset={tooltipSideOffset}
|
||||
tooltipSide={tooltipSide}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnvironmentLabel({
|
||||
environment,
|
||||
className,
|
||||
tooltipSideOffset = 34,
|
||||
tooltipSide = "right",
|
||||
disableTooltip = false,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
disableTooltip?: boolean;
|
||||
}) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const text = environment.branchName ? environment.branchName : environmentFullTitle(environment);
|
||||
|
||||
useEffect(() => {
|
||||
const checkTruncation = () => {
|
||||
if (spanRef.current) {
|
||||
const isTruncated = spanRef.current.scrollWidth > spanRef.current.clientWidth;
|
||||
setIsTruncated(isTruncated);
|
||||
}
|
||||
};
|
||||
|
||||
checkTruncation();
|
||||
// Add resize observer to recheck on window resize
|
||||
const resizeObserver = new ResizeObserver(checkTruncation);
|
||||
if (spanRef.current) {
|
||||
resizeObserver.observe(spanRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [text]);
|
||||
|
||||
const content = (
|
||||
<span
|
||||
ref={spanRef}
|
||||
className={cn("truncate text-left", environmentTextClassName(environment), className)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isTruncated && !disableTooltip) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
button={content}
|
||||
content={
|
||||
<span ref={spanRef} className={cn("text-left", environmentTextClassName(environment))}>
|
||||
{text}
|
||||
</span>
|
||||
}
|
||||
side={tooltipSide}
|
||||
variant="dark"
|
||||
sideOffset={tooltipSideOffset}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export function EnvironmentSlug({ environment }: { environment: Environment & { slug: string } }) {
|
||||
return <span className={environmentTextClassName(environment)}>{environment.slug}</span>;
|
||||
}
|
||||
|
||||
export function environmentTitle(environment: Environment, username?: string) {
|
||||
if (environment.branchName) {
|
||||
return environment.branchName;
|
||||
}
|
||||
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "Prod";
|
||||
case "STAGING":
|
||||
return "Staging";
|
||||
case "DEVELOPMENT":
|
||||
return username ? `Dev: ${username}` : "Dev: You";
|
||||
case "PREVIEW":
|
||||
return "Preview";
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentFullTitle(environment: Environment) {
|
||||
if (environment.branchName) {
|
||||
return environment.branchName;
|
||||
}
|
||||
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "Production";
|
||||
case "STAGING":
|
||||
return "Staging";
|
||||
case "DEVELOPMENT":
|
||||
return "Development";
|
||||
case "PREVIEW":
|
||||
return "Preview";
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentTextClassName(environment: { type: Environment["type"] }) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "text-prod";
|
||||
case "STAGING":
|
||||
return "text-staging";
|
||||
case "DEVELOPMENT":
|
||||
return "text-dev";
|
||||
case "PREVIEW":
|
||||
return "text-preview";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { generateTwoRandomWords } from "~/utils/randomWords";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import { Fieldset } from "../primitives/Fieldset";
|
||||
import { FormButtons } from "../primitives/FormButtons";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { CheckboxWithLabel } from "../primitives/Checkbox";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
type ModalProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
hasVercelIntegration: boolean;
|
||||
isDevelopment: boolean;
|
||||
};
|
||||
|
||||
type ModalContentProps = ModalProps & {
|
||||
randomWord: string;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
export function RegenerateApiKeyModal({
|
||||
id,
|
||||
title,
|
||||
hasVercelIntegration,
|
||||
isDevelopment,
|
||||
}: ModalProps) {
|
||||
const randomWord = generateTwoRandomWords();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="minimal/small" textAlignLeft LeadingIcon={ArrowPathIcon}>
|
||||
Regenerate…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{`Regenerate ${title} environment key`}</DialogHeader>
|
||||
<RegenerateApiKeyModalContent
|
||||
id={id}
|
||||
title={title}
|
||||
hasVercelIntegration={hasVercelIntegration}
|
||||
isDevelopment={isDevelopment}
|
||||
randomWord={randomWord}
|
||||
closeModal={() => setOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const RegenerateApiKeyModalContent = ({
|
||||
id,
|
||||
randomWord,
|
||||
title,
|
||||
hasVercelIntegration,
|
||||
isDevelopment,
|
||||
closeModal,
|
||||
}: ModalContentProps) => {
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
const fetcher = useFetcher();
|
||||
const isSubmitting = fetcher.state === "submitting";
|
||||
|
||||
// form submission completed
|
||||
if (fetcher.state === "loading") {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-y-4 pt-4">
|
||||
<Callout variant="warning">
|
||||
{`A new API key will be issued for the ${title} environment. The previous key stays valid
|
||||
for 24 hours so you can roll out the new key in your environment variables without downtime.
|
||||
After 24 hours, the previous key stops working.`}
|
||||
</Callout>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
action={`/resources/environments/${id}/regenerate-api-key`}
|
||||
className="mt-2 w-full"
|
||||
>
|
||||
<Fieldset className="w-full">
|
||||
<InputGroup className="max-w-full">
|
||||
<Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="select-all rounded-md border border-grid-bright bg-background-deep px-2 py-1 font-mono"
|
||||
>
|
||||
{randomWord}
|
||||
</Paragraph>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Confirmation text"
|
||||
fullWidth
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
{hasVercelIntegration && !isDevelopment && (
|
||||
<CheckboxWithLabel
|
||||
name="syncToVercel"
|
||||
variant="simple/small"
|
||||
label="Also update TRIGGER_SECRET_KEY in Vercel"
|
||||
defaultChecked={true}
|
||||
value="on"
|
||||
/>
|
||||
)}
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/medium"}
|
||||
LeadingIcon={isSubmitting ? Spinner : undefined}
|
||||
disabled={confirmationText !== randomWord}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<Button variant={"tertiary/medium"} type="button" onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user