Files
tesseract/web/src/workspaces/workspace-port-info-tab.tsx

220 lines
5.2 KiB
TypeScript

import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { superstructResolver } from "@hookform/resolvers/superstruct";
import { Check, Trash2, X } from "lucide-react";
import { useContext, useEffect, useId } from "react";
import { useForm } from "react-hook-form";
import { type Infer, number, object, pattern, size, string } from "superstruct";
import { create } from "zustand";
import { useAddWorkspacePort, useDeleteWorkspacePort } from "./api";
import { WorkspaceTableRowContext } from "./workspace-table";
interface PortInfoTabStore {
isAddingPort: boolean;
setIsAddingPort: (isAddingPort: boolean) => void;
}
const useStore = create<PortInfoTabStore>()((set) => ({
isAddingPort: false,
setIsAddingPort: (isAddingPort) => set({ isAddingPort }),
}));
function PortInfoTab() {
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Subdomain</TableHead>
<TableHead>Port</TableHead>
</TableRow>
</TableHeader>
<PortInfoTableBody />
</Table>
<AddPortButton />
</>
);
}
function PortInfoTableBody() {
const workspace = useContext(WorkspaceTableRowContext);
const ports = workspace.ports ?? [];
return (
<TableBody>
{ports.map(({ port, subdomain }) => (
<TableRow key={subdomain}>
<TableCell className="py-0">{subdomain}</TableCell>
<TableCell className="py-0">{port}</TableCell>
<TableCell className="p-0 text-right">
<DeletePortMappingButton subdomain={subdomain} />
</TableCell>
</TableRow>
))}
<NewPortMappingRow />
</TableBody>
);
}
function AddPortButton() {
const isAddingPort = useStore((state) => state.isAddingPort);
const setIsAddingPort = useStore((state) => state.setIsAddingPort);
if (isAddingPort) {
return null;
}
return (
<Button
variant="secondary"
size="sm"
className="mt-4"
onClick={() => setIsAddingPort(true)}
>
Add port
</Button>
);
}
const NewPortMappingForm = object({
subdomain: pattern(string(), /^[\w-]+$/),
port: size(number(), 1, 65536),
});
function NewPortMappingRow() {
const { addWorkspacePort, status } = useAddWorkspacePort();
const workspace = useContext(WorkspaceTableRowContext);
const isAddingPort = useStore((state) => state.isAddingPort);
const setIsAddingPort = useStore((state) => state.setIsAddingPort);
const formId = useId();
const form = useForm({
resolver: superstructResolver(NewPortMappingForm),
disabled: status.type === "loading",
defaultValues: {
subdomain: "",
port: 3000,
},
});
if (!isAddingPort) {
return null;
}
async function submitForm(values: Infer<typeof NewPortMappingForm>) {
await addWorkspacePort(workspace.name, [
{
subdomain: values.subdomain,
port: values.port,
},
]);
setIsAddingPort(false);
}
return (
<TableRow>
<TableCell>
<Form {...form}>
<form onSubmit={form.handleSubmit(submitForm)} id={formId}>
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="text" placeholder="subdomain" {...field} />
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
</TableCell>
<TableCell>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<Input
className="[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
style={{ MozAppearance: "textfield" }}
type="number"
min={0}
max={65535}
placeholder="8080"
form={formId}
{...field}
onChange={(value) =>
field.onChange(value.currentTarget.valueAsNumber)
}
/>
)}
/>
</TableCell>
<TableCell className="px-0">
<Button
form={formId}
disabled={status.type === "loading"}
variant="ghost"
size="icon"
onClick={() => setIsAddingPort(false)}
>
<X />
</Button>
<Button
form={formId}
disabled={status.type === "loading"}
variant="ghost"
size="icon"
>
{status.type === "loading" ? <LoadingSpinner /> : <Check />}
</Button>
</TableCell>
</TableRow>
);
}
function DeletePortMappingButton({ subdomain }: { subdomain: string }) {
const { deleteWorkspacePort, status } = useDeleteWorkspacePort();
const { toast } = useToast();
const workspace = useContext(WorkspaceTableRowContext);
const isDeleting = status.type === "loading";
useEffect(() => {
if (status.type === "error") {
toast({
variant: "destructive",
title: "Failed to delete port.",
description: "Unexpected error.",
});
}
}, [status.type, toast]);
async function _deleteWorkspacePort() {
await deleteWorkspacePort(workspace.name, subdomain);
}
return (
<Button
disabled={isDeleting}
variant="ghost"
size="icon"
onClick={_deleteWorkspacePort}
>
{isDeleting ? <LoadingSpinner /> : <Trash2 />}
</Button>
);
}
export { PortInfoTab };