Skip to content

Commit

Permalink
fix: Target selector runbook input (#126)
Browse files Browse the repository at this point in the history
  • Loading branch information
adityachoudhari26 authored Oct 10, 2024
1 parent 3065898 commit f8372a7
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 61 deletions.
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"use client";

import type { TargetCondition } from "@ctrlplane/validators/targets";
import type {
BooleanVariableConfigType,
ChoiceVariableConfigType,
NumberVariableConfigType,
RunbookVariableConfigType,
StringVariableConfigType,
TargetVariableConfigType,
VariableConfigType,
} from "@ctrlplane/validators/variables";
import { IconX } from "@tabler/icons-react";
import _ from "lodash";

import { Button } from "@ctrlplane/ui/button";
import { FormControl, FormItem, FormLabel } from "@ctrlplane/ui/form";
Expand All @@ -21,6 +23,13 @@ import {
SelectValue,
} from "@ctrlplane/ui/select";
import { Textarea } from "@ctrlplane/ui/textarea";
import {
defaultCondition,
isEmptyCondition,
} from "@ctrlplane/validators/targets";

import { TargetConditionBadge } from "~/app/[workspaceSlug]/_components/target-condition/TargetConditionBadge";
import { TargetConditionDialog } from "~/app/[workspaceSlug]/_components/target-condition/TargetConditionDialog";

export const ConfigTypeSelector: React.FC<{
value: string | undefined;
Expand All @@ -39,6 +48,24 @@ export const ConfigTypeSelector: React.FC<{
</Select>
);

export const RunbookConfigTypeSelector: React.FC<{
value: string | undefined;
onChange: (type: string) => void;
}> = ({ value, onChange }) => (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">String</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Boolean</SelectItem>
<SelectItem value="choice">Choice</SelectItem>
<SelectItem value="target">Target</SelectItem>
</SelectContent>
</Select>
);

type ConfigFieldsFC<T extends VariableConfigType> = React.FC<{
config: T;
updateConfig: (updates: Partial<T>) => void;
Expand Down Expand Up @@ -227,3 +254,30 @@ export const ChoiceConfigFields: ConfigFieldsFC<ChoiceVariableConfigType> = ({
</>
);
};

type RunbookConfigFieldsFC<T extends RunbookVariableConfigType> = React.FC<{
config: T;
updateConfig: (updates: Partial<T>) => void;
}>;

export const TargetConfigFields: RunbookConfigFieldsFC<
TargetVariableConfigType
> = ({ config, updateConfig }) => {
const onFilterChange = (condition: TargetCondition | undefined) => {
const cond = condition ?? defaultCondition;
if (isEmptyCondition(cond)) updateConfig({ ...config, filter: undefined });
if (!isEmptyCondition(cond)) updateConfig({ ...config, filter: cond });
};

return (
<>
{config.filter && <TargetConditionBadge condition={config.filter} />}
<TargetConditionDialog
condition={config.filter ?? defaultCondition}
onChange={onFilterChange}
>
<Button variant="outline">Edit Filter</Button>
</TargetConditionDialog>
</>
);
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import type { TargetCondition } from "@ctrlplane/validators/targets";
import type {
ChoiceVariableConfigType,
StringVariableConfigType,
TargetVariableConfigType,
} from "@ctrlplane/validators/variables";
import { useState } from "react";
import { useParams } from "next/navigation";
import { IconLoader2, IconSelector } from "@tabler/icons-react";

import { Button } from "@ctrlplane/ui/button";
import {
Command,
CommandInput,
CommandItem,
CommandList,
} from "@ctrlplane/ui/command";
import { Input } from "@ctrlplane/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@ctrlplane/ui/popover";
import {
Select,
SelectContent,
Expand All @@ -12,6 +25,12 @@ import {
SelectValue,
} from "@ctrlplane/ui/select";
import { Textarea } from "@ctrlplane/ui/textarea";
import {
TargetFilterType,
TargetOperator,
} from "@ctrlplane/validators/targets";

import { api } from "~/trpc/react";

export const VariableStringInput: React.FC<
StringVariableConfigType & {
Expand Down Expand Up @@ -85,3 +104,116 @@ export const VariableBooleanInput: React.FC<{
</SelectContent>
</Select>
);

export const VariableTargetInput: React.FC<
TargetVariableConfigType & {
value: string;
onChange: (v: string) => void;
}
> = ({ value, filter, onChange }) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");

const { workspaceSlug, systemSlug } = useParams<{
workspaceSlug: string;
systemSlug: string;
}>();
const systemQ = api.system.bySlug.useQuery({ workspaceSlug, systemSlug });
const system = systemQ.data;

const envsQ = api.environment.bySystemId.useQuery(system?.id ?? "", {
enabled: system != null,
});
const envs = envsQ.data ?? [];
const envConditions = envs
.filter((e) => e.targetFilter != null)
.map((e) => e.targetFilter!);

const tFilterConditions: TargetCondition[] = [
{
type: TargetFilterType.Comparison,
operator: TargetOperator.Or,
conditions: envConditions,
},
];
if (filter != null) tFilterConditions.push(filter);
const tFilter: TargetCondition = {
type: TargetFilterType.Comparison,
operator: TargetOperator.And,
conditions: tFilterConditions,
};
const allTargetsQ = api.target.byWorkspaceId.list.useQuery(
{ workspaceId: system?.workspaceId ?? "", filter: tFilter },
{ enabled: system != null, placeholderData: (prev) => prev },
);
const allTargets = allTargetsQ.data?.items ?? [];
const selectedTarget = allTargets.find((t) => t.id === value);

const tFilterConditionsWithSearch = tFilterConditions.concat([
{
type: TargetFilterType.Name,
operator: TargetOperator.Like,
value: `%${search}%`,
},
]);
const tFilterWithSearch: TargetCondition = {
type: TargetFilterType.Comparison,
operator: TargetOperator.And,
conditions: tFilterConditionsWithSearch,
};
const targetsQ = api.target.byWorkspaceId.list.useQuery(
{ workspaceId: system?.workspaceId ?? "", filter: tFilterWithSearch },
{ enabled: system != null, placeholderData: (prev) => prev },
);
const targets = targetsQ.data?.items ?? [];

const isLoading = allTargetsQ.isLoading || targetsQ.isLoading;

return (
<div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full items-center justify-start gap-2 px-2"
>
<IconSelector className="h-4 w-4" />
<span className="overflow-hidden text-ellipsis">
{selectedTarget?.name ?? value}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[462px] p-0">
<Command shouldFilter={false}>
<div className="relative">
<CommandInput value={search} onValueChange={setSearch} />
{isLoading && (
<IconLoader2 className="absolute right-2 top-3 h-4 w-4 animate-spin" />
)}
</div>
<CommandList>
{targets.map((t) => (
<CommandItem
key={t.id}
value={t.id}
onSelect={() => {
onChange(t.id);
setOpen(false);
}}
className="cursor-pointer overflow-ellipsis"
>
{t.name}
</CommandItem>
))}
{targets.length === 0 && !isLoading && (
<CommandItem disabled>No targets found</CommandItem>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export const RunbookRow: React.FC<{
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<TriggerRunbookDialog runbook={runbook}>
<TriggerRunbookDialog
runbook={runbook}
onSuccess={() => setOpen(false)}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Trigger Runbook
</DropdownMenuItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,38 @@ import {
} from "@ctrlplane/ui/dialog";
import { Input } from "@ctrlplane/ui/input";
import { Label } from "@ctrlplane/ui/label";
import { Switch } from "@ctrlplane/ui/switch";

import {
VariableBooleanInput,
VariableChoiceSelect,
VariableStringInput,
VariableTargetInput,
} from "~/app/[workspaceSlug]/systems/[systemSlug]/_components/variables/VariableInputs";
import { api } from "~/trpc/react";

export type TriggerRunbookDialogProps = {
runbook: Runbook & { variables: RunbookVariable[] };
onSuccess: () => void;
children: ReactNode;
};

export const TriggerRunbookDialog: React.FC<TriggerRunbookDialogProps> = ({
runbook,
onSuccess,
children,
}) => {
const [open, setOpen] = useState(false);
const trigger = api.runbook.trigger.useMutation();
const [variables, setVariables] = useState<Record<string, any>>({});
const router = useRouter();

const handleTriggerRunbook = async () => {
await trigger.mutateAsync({ runbookId: runbook.id, variables });
router.refresh();
setOpen(false);
};
const handleTriggerRunbook = () =>
trigger
.mutateAsync({ runbookId: runbook.id, variables })
.then(() => setVariables({}))
.then(() => router.refresh())
.then(() => onSuccess())
.then(() => setOpen(false));

const getValue = (k: string) => variables[k];
const onChange = (k: string) => (v: string) =>
Expand All @@ -61,54 +66,57 @@ export const TriggerRunbookDialog: React.FC<TriggerRunbookDialogProps> = ({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{runbook.variables.map((v) =>
v.config?.type !== "boolean" ? (
<div key={v.id} className="space-y-2">
<Label>{v.name}</Label>
{v.config?.type === "string" && (
<VariableStringInput
{...v.config}
value={getValue(v.key) ?? ""}
onChange={onChange(v.key)}
/>
)}
{runbook.variables.map((v) => (
<div key={v.id} className="space-y-2">
<Label>{v.name}</Label>
{v.config?.type === "string" && (
<VariableStringInput
{...v.config}
value={getValue(v.key) ?? ""}
onChange={onChange(v.key)}
/>
)}

{v.config?.type === "number" && (
<Input
type="number"
value={getValue(v.key) ?? ""}
onChange={(e) => onChange(v.key)(e.target.value)}
/>
)}

{v.config?.type === "number" && (
<Input
type="number"
value={getValue(v.key) ?? ""}
onChange={(e) => onChange(v.key)(e.target.value)}
/>
)}
{v.config?.type === "choice" && (
<VariableChoiceSelect
{...v.config}
value={getValue(v.key) ?? ""}
onSelect={onChange(v.key)}
/>
)}

{v.config?.type === "choice" && (
<VariableChoiceSelect
{...v.config}
value={getValue(v.key) ?? ""}
onSelect={onChange(v.key)}
/>
)}
{v.config?.type === "boolean" && (
<VariableBooleanInput
{...v.config}
value={getValue(v.key) ?? false}
onChange={(val) => onChange(v.key)(val.toString())}
/>
)}

{v.description !== "" && (
<div className="text-xs text-muted-foreground">
{v.description}
</div>
)}
</div>
) : (
<div key={v.id} className="flex items-center gap-4">
<Label>{v.name}</Label>
<Switch
checked={getValue(v.key) === "true"}
onCheckedChange={(checked) =>
onChange(v.key)(checked.toString())
}
{v.config?.type === "target" && (
<VariableTargetInput
{...v.config}
value={getValue(v.key) ?? ""}
onChange={onChange(v.key)}
/>
</div>
),
)}
)}

{v.description !== "" && (
<div className="text-xs text-muted-foreground">
{v.description}
</div>
)}
</div>
))}
</div>
<pre>{JSON.stringify(variables, null, 2)}</pre>
<DialogFooter>
<Button variant="secondary">Cancel</Button>
<Button onClick={handleTriggerRunbook}>Trigger</Button>
Expand Down
Loading

0 comments on commit f8372a7

Please sign in to comment.