Skip to content
This repository was archived by the owner on Jan 31, 2025. It is now read-only.

Commit 7876ab8

Browse files
committed
feat: add a way to modify tags on builds
1 parent 71c173f commit 7876ab8

File tree

11 files changed

+475
-102
lines changed

11 files changed

+475
-102
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import * as EditBuildTagsForm from "@/domains/project/forms/build-tags-form";
2+
import type { DialogContentProps } from "@/hooks/use-dialog";
3+
import {
4+
Button,
5+
DialogFooter,
6+
DialogHeader,
7+
DialogTitle,
8+
} from "@rivet-gg/components";
9+
import { useSuspenseQueries } from "@tanstack/react-query";
10+
import { useState } from "react";
11+
import {
12+
projectBuildQueryOptions,
13+
projectBuildsQueryOptions,
14+
useUpdateBuildTagsMutation,
15+
} from "../../queries";
16+
17+
interface ContentProps extends DialogContentProps {
18+
projectId: string;
19+
environmentId: string;
20+
buildId: string;
21+
}
22+
23+
export default function EditBuildTagsDialogContent(props: ContentProps) {
24+
if (!props.buildId) {
25+
return null;
26+
}
27+
28+
return <Content {...props} />;
29+
}
30+
31+
function Content({ buildId, projectId, environmentId, onClose }: ContentProps) {
32+
const [{ data }, { data: builds }] = useSuspenseQueries({
33+
queries: [
34+
projectBuildQueryOptions({ buildId, projectId, environmentId }),
35+
projectBuildsQueryOptions({ projectId, environmentId }),
36+
],
37+
});
38+
const { mutateAsync } = useUpdateBuildTagsMutation();
39+
40+
const [tagKeys, setTagKeys] = useState(() =>
41+
Array.from(
42+
new Set(builds?.flatMap((build) => Object.keys(build.tags))).values(),
43+
).map((key) => ({
44+
label: key,
45+
value: key,
46+
})),
47+
);
48+
49+
const [tagValues, setTagValues] = useState(() =>
50+
Array.from(
51+
new Set(builds?.flatMap((build) => Object.values(build.tags))).values(),
52+
).map((key) => ({ label: key, value: key })),
53+
);
54+
55+
return (
56+
<EditBuildTagsForm.Form
57+
defaultValues={{
58+
tags: Object.entries(data.tags).map(([key, value]) => ({
59+
key,
60+
value,
61+
})),
62+
}}
63+
onSubmit={async (values) => {
64+
const tags = Object.fromEntries(
65+
values.tags.map(({ key, value }) => [key, value]),
66+
);
67+
68+
await mutateAsync({
69+
projectId,
70+
environmentId,
71+
buildId,
72+
tags,
73+
});
74+
onClose?.();
75+
}}
76+
>
77+
<DialogHeader>
78+
<DialogTitle>Edit Build Tags</DialogTitle>
79+
</DialogHeader>
80+
81+
<EditBuildTagsForm.Tags
82+
keys={tagKeys}
83+
values={tagValues}
84+
onCreateKeyOption={(option) =>
85+
setTagKeys((opts) => [...opts, { label: option, value: option }])
86+
}
87+
onCreateValueOption={(option) =>
88+
setTagValues((opts) => [...opts, { label: option, value: option }])
89+
}
90+
/>
91+
92+
<DialogFooter>
93+
<EditBuildTagsForm.Submit>Save</EditBuildTagsForm.Submit>
94+
<Button type="button" variant="secondary" onClick={onClose}>
95+
Close
96+
</Button>
97+
</DialogFooter>
98+
</EditBuildTagsForm.Form>
99+
);
100+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {
2+
Button,
3+
DropdownMenu,
4+
DropdownMenuContent,
5+
DropdownMenuItem,
6+
DropdownMenuLabel,
7+
DropdownMenuTrigger,
8+
} from "@rivet-gg/components";
9+
import { Icon, faEllipsisH } from "@rivet-gg/icons";
10+
import { useNavigate } from "@tanstack/react-router";
11+
12+
interface ProjectBuildsTableActionsProps {
13+
buildId: string;
14+
}
15+
16+
export function ProjectBuildsTableActions({
17+
buildId,
18+
}: ProjectBuildsTableActionsProps) {
19+
const navigate = useNavigate();
20+
return (
21+
<DropdownMenu>
22+
<DropdownMenuTrigger asChild>
23+
<Button aria-haspopup="true" size="icon" variant="ghost">
24+
<Icon className="size-4" icon={faEllipsisH} />
25+
<span className="sr-only">Toggle menu</span>
26+
</Button>
27+
</DropdownMenuTrigger>
28+
<DropdownMenuContent align="end">
29+
<DropdownMenuLabel>Actions</DropdownMenuLabel>
30+
<DropdownMenuItem
31+
onSelect={() => {
32+
navigate({ to: ".", search: { modal: "edit-tags", buildId } });
33+
}}
34+
>
35+
Edit tags
36+
</DropdownMenuItem>
37+
</DropdownMenuContent>
38+
</DropdownMenu>
39+
);
40+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {
2+
Button,
3+
Combobox,
4+
FormControl,
5+
FormFieldContext,
6+
FormItem,
7+
FormLabel,
8+
FormMessage,
9+
type Option,
10+
Text,
11+
createSchemaForm,
12+
} from "@rivet-gg/components";
13+
import { Icon, faTrash } from "@rivet-gg/icons";
14+
import {
15+
type UseFormReturn,
16+
useFieldArray,
17+
useFormContext,
18+
} from "react-hook-form";
19+
import z from "zod";
20+
21+
export const formSchema = z.object({
22+
tags: z
23+
.array(
24+
z.object({
25+
key: z.string().min(1),
26+
value: z.string(),
27+
}),
28+
)
29+
.superRefine((tags, ctx) => {
30+
const tagsSet = new Set<string>();
31+
for (const [idx, tag] of [...tags].reverse().entries()) {
32+
if (tagsSet.has(tag.key)) {
33+
ctx.addIssue({
34+
code: z.ZodIssueCode.custom,
35+
path: [idx, "key"],
36+
message: "Tag keys must be unique",
37+
});
38+
}
39+
tagsSet.add(tag.key);
40+
}
41+
}),
42+
});
43+
44+
export type FormValues = z.infer<typeof formSchema>;
45+
export type SubmitHandler = (
46+
values: FormValues,
47+
form: UseFormReturn<FormValues>,
48+
) => Promise<void>;
49+
50+
const { Form, Submit } = createSchemaForm(formSchema);
51+
export { Form, Submit };
52+
53+
export const Tags = ({
54+
onCreateKeyOption,
55+
onCreateValueOption,
56+
keys,
57+
values,
58+
}: {
59+
onCreateKeyOption: (option: string) => void;
60+
onCreateValueOption: (option: string) => void;
61+
keys: Option[];
62+
values: Option[];
63+
}) => {
64+
const { control, setValue, watch } = useFormContext<FormValues>();
65+
const { fields, append, remove } = useFieldArray({
66+
name: "tags",
67+
control,
68+
});
69+
70+
return (
71+
<>
72+
{fields.length === 0 ? <Text mb="2">There's no tags added.</Text> : null}
73+
{fields.map((field, index) => (
74+
<div
75+
key={field.id}
76+
className="grid grid-cols-[1fr,1fr,auto] grid-rows-[repeat(3,auto)] items-start mt-2 gap-2"
77+
>
78+
<FormFieldContext.Provider value={{ name: `tags.${index}.key` }}>
79+
<FormItem
80+
flex="1"
81+
className="grid grid-cols-subgrid grid-rows-subgrid row-span-full"
82+
>
83+
<FormLabel>Key</FormLabel>
84+
<FormControl>
85+
<Combobox
86+
placeholder="Choose a key"
87+
options={keys}
88+
className="w-full"
89+
value={watch(`tags.${index}.key`)}
90+
onValueChange={(value) => {
91+
setValue(`tags.${index}.key`, value, {
92+
shouldDirty: true,
93+
shouldTouch: true,
94+
shouldValidate: true,
95+
});
96+
}}
97+
allowCreate
98+
onCreateOption={onCreateKeyOption}
99+
/>
100+
</FormControl>
101+
<FormMessage />
102+
</FormItem>
103+
</FormFieldContext.Provider>
104+
105+
<FormFieldContext.Provider value={{ name: `tags.${index}.value` }}>
106+
<FormItem
107+
flex="1"
108+
className="grid grid-cols-subgrid grid-rows-subgrid row-span-full"
109+
>
110+
<FormLabel>Value</FormLabel>
111+
<FormControl>
112+
<Combobox
113+
placeholder="Choose a value"
114+
options={values}
115+
className="w-full"
116+
value={watch(`tags.${index}.value`)}
117+
onValueChange={(value) => {
118+
setValue(`tags.${index}.value`, value, {
119+
shouldDirty: true,
120+
shouldTouch: true,
121+
shouldValidate: true,
122+
});
123+
}}
124+
allowCreate
125+
onCreateOption={onCreateValueOption}
126+
/>
127+
</FormControl>
128+
<FormMessage />
129+
</FormItem>
130+
</FormFieldContext.Provider>
131+
<Button
132+
size="icon"
133+
className="self-end row-start-2"
134+
variant="secondary"
135+
type="button"
136+
onClick={() => remove(index)}
137+
>
138+
<Icon icon={faTrash} />
139+
</Button>
140+
</div>
141+
))}
142+
<Button
143+
className="justify-self-start"
144+
variant="secondary"
145+
type="button"
146+
onClick={() => append({ value: "", key: "" })}
147+
>
148+
Add a tag
149+
</Button>
150+
</>
151+
);
152+
};

apps/hub/src/domains/project/queries/dynamic-servers/mutations.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { queryClient, rivetClient } from "@/queries/global";
22
import type { Rivet } from "@rivet-gg/api";
33
import { useMutation } from "@tanstack/react-query";
44
import {
5+
projectBuildQueryOptions,
6+
projectBuildsQueryOptions,
57
projectServersQueryOptions,
68
serverQueryOptions,
79
} from "./query-options";
@@ -72,3 +74,37 @@ export function useCreateDynamicServerMutation({
7274
},
7375
});
7476
}
77+
78+
export function useUpdateBuildTagsMutation({
79+
onSuccess,
80+
}: { onSuccess?: () => void } = {}) {
81+
return useMutation({
82+
mutationFn: ({
83+
projectId,
84+
environmentId,
85+
buildId,
86+
...request
87+
}: {
88+
projectId: string;
89+
environmentId: string;
90+
buildId: string;
91+
} & Rivet.servers.PatchBuildTagsRequest) =>
92+
rivetClient.servers.builds.patchTags(
93+
projectId,
94+
environmentId,
95+
buildId,
96+
request,
97+
),
98+
onSuccess: async (_, { projectId, environmentId, buildId }) => {
99+
await Promise.allSettled([
100+
queryClient.invalidateQueries(
101+
projectBuildsQueryOptions({ projectId, environmentId }),
102+
),
103+
queryClient.invalidateQueries(
104+
projectBuildQueryOptions({ buildId, projectId, environmentId }),
105+
),
106+
]);
107+
onSuccess?.();
108+
},
109+
});
110+
}

apps/hub/src/domains/project/queries/dynamic-servers/query-options.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,41 @@ export const projectBuildsQueryOptions = ({
218218
});
219219
};
220220

221+
export const projectBuildQueryOptions = ({
222+
projectId,
223+
environmentId,
224+
buildId,
225+
}: {
226+
projectId: string;
227+
environmentId: string;
228+
buildId: string;
229+
}) => {
230+
return queryOptions({
231+
queryKey: [
232+
"project",
233+
projectId,
234+
"environment",
235+
environmentId,
236+
"build",
237+
buildId,
238+
],
239+
queryFn: ({
240+
signal: abortSignal,
241+
queryKey: [_, projectId, __, environmentId, ___, buildId],
242+
}) =>
243+
rivetClient.servers.builds.getBuild(
244+
projectId,
245+
environmentId,
246+
buildId,
247+
{},
248+
{
249+
abortSignal,
250+
},
251+
),
252+
select: (data) => data.build,
253+
});
254+
};
255+
221256
export const buildQueryOptions = ({
222257
projectId,
223258
environmentId,

apps/hub/src/hooks/use-dialog.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,7 @@ useDialog.CreateDynamicServer = createDialogHook(
275275
import("@/domains/project/components/dialogs/create-dynamic-server-dialog"),
276276
{ size: "lg" },
277277
);
278+
279+
useDialog.EditBuildTags = createDialogHook(
280+
import("@/domains/project/components/dialogs/edit-build-tags-dialog"),
281+
);

0 commit comments

Comments
 (0)