Skip to content

Commit 88c8a12

Browse files
authored
Add assignee logic to issue create-edit-component (#437)
1 parent 455449f commit 88c8a12

4 files changed

Lines changed: 302 additions & 53 deletions

File tree

apps/blade/src/app/_components/issues/create-edit-dialog.tsx

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
190190
}: Partial<ISSUE.IssueSubmitValues> = initialValues ?? {};
191191
const resolvedEventData = initial.eventData;
192192
const resolvedRoles = initial.teamVisibilityIds ?? defaults.roles;
193+
const resolvedAssigneeIds = initial.assigneeIds ?? defaults.assigneeIds;
193194
if (initial.isEvent) {
194195
return {
195196
...defaults,
@@ -200,6 +201,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
200201
links: initial.links ?? defaults.links,
201202
date: normalizeTaskDueDate(initial.date ?? defaults.date),
202203
roles: resolvedRoles,
204+
assigneeIds: resolvedAssigneeIds,
203205
};
204206
}
205207
return {
@@ -211,6 +213,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
211213
date: normalizeTaskDueDate(initial.date ?? defaults.date),
212214
links: initial.links ?? defaults.links,
213215
roles: resolvedRoles,
216+
assigneeIds: resolvedAssigneeIds,
214217
};
215218
}, [initialValues]);
216219
const [formValues, setFormValues] = useState<ISSUE.IssueEditNode>(
@@ -262,6 +265,16 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
262265

263266
const baseId = useId();
264267
const effectiveTeam = formValues.team || (rolesData?.[0]?.id ?? "");
268+
const usersOnTeamQuery = api.issues.getUsersOnTeam.useQuery(
269+
{ teamId: effectiveTeam },
270+
{ enabled: isOpen && !!effectiveTeam },
271+
);
272+
const assigneesForTeam = useMemo<ISSUE.IssueAssigneeOption[]>(
273+
() => usersOnTeamQuery.data ?? [],
274+
[usersOnTeamQuery.data],
275+
);
276+
const isAssigneesLoading = usersOnTeamQuery.isLoading;
277+
const assigneesError = usersOnTeamQuery.error;
265278

266279
const isFormValid = issueFormSchema.safeParse({
267280
...formValues,
@@ -285,6 +298,17 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
285298
),
286299
[formValues.eventData?.roles, roleIdSet],
287300
);
301+
const assigneeIdSet = useMemo(
302+
() => new Set(assigneesForTeam.map((user) => user.id)),
303+
[assigneesForTeam],
304+
);
305+
const safeAssigneeIds = useMemo(
306+
() =>
307+
usersOnTeamQuery.isSuccess
308+
? formValues.assigneeIds.filter((userId) => assigneeIdSet.has(userId))
309+
: formValues.assigneeIds,
310+
[assigneeIdSet, formValues.assigneeIds, usersOnTeamQuery.isSuccess],
311+
);
288312

289313
// Helper for event form
290314
const updateEventData = <K extends keyof ISSUE.EventFormValues>(
@@ -410,7 +434,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
410434
team: effectiveTeam,
411435
teamVisibilityIds:
412436
safeVisibilityIds.length > 0 ? safeVisibilityIds : undefined,
413-
assigneeIds: formValues.assigneeIds,
437+
assigneeIds: safeAssigneeIds,
414438
};
415439

416440
try {
@@ -481,7 +505,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
481505
: normalizeTaskDueDate(formValues.date),
482506
teamVisibilityIds:
483507
safeVisibilityIds.length > 0 ? safeVisibilityIds : undefined,
484-
assigneeIds: formValues.assigneeIds,
508+
assigneeIds: safeAssigneeIds,
485509
});
486510

487511
await utils.issues.invalidate();
@@ -522,7 +546,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
522546
eventData: formValues.eventData,
523547
teamVisibilityIds:
524548
safeVisibilityIds.length > 0 ? safeVisibilityIds : undefined,
525-
assigneeIds: formValues.assigneeIds,
549+
assigneeIds: safeAssigneeIds,
526550
});
527551
}
528552
};
@@ -663,13 +687,91 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
663687
<TeamSelect
664688
className={cn(baseField, "col-span-3")}
665689
value={effectiveTeam}
666-
onValueChange={(v) => updateForm("team", v)}
690+
onValueChange={(v) => {
691+
setFormValues((previous) => ({
692+
...previous,
693+
team: v,
694+
assigneeIds: [],
695+
}));
696+
}}
667697
roles={rolesData ?? []}
668698
isLoading={isRolesLoading}
669699
error={rolesError}
670700
/>
671701
</div>
672702

703+
<div className="grid grid-cols-4 items-start gap-4">
704+
<Label className="mt-1 text-right text-sm">Assignees</Label>
705+
<div className="col-span-3 mt-1 grid grid-cols-2 gap-x-2 gap-y-3">
706+
{isAssigneesLoading && (
707+
<p className="col-span-2 text-sm text-muted-foreground">
708+
Loading team members...
709+
</p>
710+
)}
711+
712+
{!!assigneesError && (
713+
<p className="col-span-2 text-sm text-destructive">
714+
Failed to load team members
715+
</p>
716+
)}
717+
718+
{!isAssigneesLoading &&
719+
!assigneesError &&
720+
assigneesForTeam.length === 0 && (
721+
<p className="col-span-2 text-sm text-muted-foreground">
722+
No team members found for this team
723+
</p>
724+
)}
725+
726+
{!isAssigneesLoading &&
727+
!assigneesError &&
728+
assigneesForTeam.map((user) => {
729+
const assigneeCheckboxId = `${baseId}-assignee-${user.id}`;
730+
return (
731+
<div
732+
key={user.id}
733+
className="flex flex-row items-start space-x-3 space-y-0"
734+
>
735+
<Checkbox
736+
id={assigneeCheckboxId}
737+
checked={formValues.assigneeIds.includes(user.id)}
738+
onCheckedChange={(checked) => {
739+
const selectedIds = formValues.assigneeIds;
740+
updateForm(
741+
"assigneeIds",
742+
checked
743+
? Array.from(
744+
new Set([...selectedIds, user.id]),
745+
)
746+
: selectedIds.filter(
747+
(id) => id !== user.id,
748+
),
749+
);
750+
}}
751+
/>
752+
<div className="flex flex-col">
753+
<Label
754+
htmlFor={assigneeCheckboxId}
755+
className="cursor-pointer font-normal"
756+
>
757+
{user.name}
758+
</Label>
759+
{user.email && (
760+
<span className="text-xs text-muted-foreground">
761+
{user.email}
762+
</span>
763+
)}
764+
</div>
765+
</div>
766+
);
767+
})}
768+
769+
<p className="col-span-2 text-sm font-normal text-gray-400">
770+
Select one or more team members responsible for this issue
771+
</p>
772+
</div>
773+
</div>
774+
673775
<div className="grid grid-cols-4 items-start gap-4">
674776
<Label
675777
htmlFor={`${baseId}-internal-description`}

packages/api/src/routers/issues.ts

Lines changed: 128 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { z } from "zod";
55
import { ISSUE } from "@forge/consts";
66
import { and, eq, exists, inArray, sql } from "@forge/db";
77
import { db } from "@forge/db/client";
8-
import { Permissions } from "@forge/db/schemas/auth";
8+
import { Permissions, User } from "@forge/db/schemas/auth";
99
import {
1010
InsertTemplateSchema,
1111
Issue,
@@ -15,6 +15,7 @@ import {
1515
Template,
1616
} from "@forge/db/schemas/knight-hacks";
1717
import { permissions } from "@forge/utils";
18+
import * as permissionsServer from "@forge/utils/permissions.server";
1819

1920
import { permProcedure } from "../trpc";
2021

@@ -112,6 +113,48 @@ const issueTemplateSchema: z.ZodType<IssueTemplate> =
112113
});
113114

114115
export const issuesRouter = {
116+
getUsersOnTeam: permProcedure
117+
.input(
118+
z.object({
119+
teamId: z.string().uuid(),
120+
}),
121+
)
122+
.query(async ({ ctx, input }) => {
123+
permissions.controlPerms.or(["EDIT_ISSUES"], ctx);
124+
125+
const rows = await db
126+
.select({
127+
id: User.id,
128+
name: User.name,
129+
email: User.email,
130+
discordUserId: User.discordUserId,
131+
})
132+
.from(User)
133+
.innerJoin(Permissions, eq(User.id, Permissions.userId))
134+
.where(eq(Permissions.roleId, input.teamId));
135+
136+
const userById = new Map<
137+
string,
138+
{
139+
id: string;
140+
name: string;
141+
email: string | null;
142+
}
143+
>();
144+
145+
for (const row of rows) {
146+
userById.set(row.id, {
147+
id: row.id,
148+
name: row.name ?? row.email ?? row.discordUserId,
149+
email: row.email,
150+
});
151+
}
152+
153+
return [...userById.values()].sort((a, b) =>
154+
a.name.localeCompare(b.name),
155+
);
156+
}),
157+
115158
createIssue: permProcedure
116159
.input(
117160
CreateIssueInputSchema.omit({ creator: true }).extend({
@@ -124,6 +167,15 @@ export const issuesRouter = {
124167
return await db.transaction(async (tx) => {
125168
const { teamVisibilityIds, assigneeIds, children, ...rest } = input;
126169

170+
await permissionsServer.validateAssigneesBelongToTeam(
171+
tx,
172+
input.team,
173+
assigneeIds,
174+
);
175+
if (children?.length) {
176+
await permissionsServer.validateIssueNodeAssignees(tx, children);
177+
}
178+
127179
const [issue] = await tx
128180
.insert(Issue)
129181
.values({
@@ -316,59 +368,89 @@ export const issuesRouter = {
316368
)
317369
.mutation(async ({ ctx, input }) => {
318370
permissions.controlPerms.or(["EDIT_ISSUES"], ctx);
319-
await requireIssue(input.id);
371+
return await db.transaction(async (tx) => {
372+
const existingIssue = await tx.query.Issue.findFirst({
373+
where: (t, { eq }) => eq(t.id, input.id),
374+
with: {
375+
userAssignments: true,
376+
},
377+
});
320378

321-
const { id, assigneeIds, teamVisibilityIds, ...fields } = input;
322-
const updateData = Object.fromEntries(
323-
(Object.entries(fields) as [string, unknown][]).filter(
324-
([, v]) => v !== undefined,
325-
),
326-
);
379+
if (!existingIssue) {
380+
throw new TRPCError({
381+
message: "Issue not found.",
382+
code: "NOT_FOUND",
383+
});
384+
}
327385

328-
if (Object.keys(updateData).length > 0) {
329-
await db.update(Issue).set(updateData).where(eq(Issue.id, id));
330-
}
386+
const assignmentTeamId = input.team ?? existingIssue.team;
387+
const existingAssigneeIds = existingIssue.userAssignments.map(
388+
(assignment) => assignment.userId,
389+
);
390+
const assigneeIdsToValidate =
391+
input.assigneeIds ??
392+
(input.team !== undefined && input.team !== existingIssue.team
393+
? existingAssigneeIds
394+
: undefined);
395+
396+
await permissionsServer.validateAssigneesBelongToTeam(
397+
tx,
398+
assignmentTeamId,
399+
assigneeIdsToValidate,
400+
);
331401

332-
if (teamVisibilityIds !== undefined) {
333-
await db
334-
.delete(IssuesToTeamsVisibility)
335-
.where(eq(IssuesToTeamsVisibility.issueId, id));
336-
if (teamVisibilityIds.length > 0) {
337-
await db
338-
.insert(IssuesToTeamsVisibility)
339-
.values(
340-
teamVisibilityIds.map((teamId) => ({ issueId: id, teamId })),
341-
);
402+
const { id, assigneeIds, teamVisibilityIds, ...fields } = input;
403+
const updateData = Object.fromEntries(
404+
(Object.entries(fields) as [string, unknown][]).filter(
405+
([, v]) => v !== undefined,
406+
),
407+
);
408+
409+
if (Object.keys(updateData).length > 0) {
410+
await tx.update(Issue).set(updateData).where(eq(Issue.id, id));
342411
}
343-
}
344412

345-
if (assigneeIds !== undefined) {
346-
await db
347-
.delete(IssuesToUsersAssignment)
348-
.where(eq(IssuesToUsersAssignment.issueId, id));
349-
if (assigneeIds.length > 0) {
350-
await db
351-
.insert(IssuesToUsersAssignment)
352-
.values(assigneeIds.map((userId) => ({ issueId: id, userId })));
413+
if (teamVisibilityIds !== undefined) {
414+
await tx
415+
.delete(IssuesToTeamsVisibility)
416+
.where(eq(IssuesToTeamsVisibility.issueId, id));
417+
if (teamVisibilityIds.length > 0) {
418+
await tx
419+
.insert(IssuesToTeamsVisibility)
420+
.values(
421+
teamVisibilityIds.map((teamId) => ({ issueId: id, teamId })),
422+
);
423+
}
353424
}
354-
}
355425

356-
if (
357-
Object.keys(updateData).length === 0 &&
358-
(teamVisibilityIds !== undefined || assigneeIds !== undefined)
359-
) {
360-
await db
361-
.update(Issue)
362-
.set({ updatedAt: new Date() })
363-
.where(eq(Issue.id, id));
364-
}
426+
if (assigneeIds !== undefined) {
427+
await tx
428+
.delete(IssuesToUsersAssignment)
429+
.where(eq(IssuesToUsersAssignment.issueId, id));
430+
if (assigneeIds.length > 0) {
431+
await tx
432+
.insert(IssuesToUsersAssignment)
433+
.values(assigneeIds.map((userId) => ({ issueId: id, userId })));
434+
}
435+
}
365436

366-
return db.query.Issue.findFirst({
367-
where: (t, { eq }) => eq(t.id, id),
368-
with: {
369-
teamVisibility: { with: { team: true } },
370-
userAssignments: { with: { user: true } },
371-
},
437+
if (
438+
Object.keys(updateData).length === 0 &&
439+
(teamVisibilityIds !== undefined || assigneeIds !== undefined)
440+
) {
441+
await tx
442+
.update(Issue)
443+
.set({ updatedAt: new Date() })
444+
.where(eq(Issue.id, id));
445+
}
446+
447+
return tx.query.Issue.findFirst({
448+
where: (t, { eq }) => eq(t.id, id),
449+
with: {
450+
teamVisibility: { with: { team: true } },
451+
userAssignments: { with: { user: true } },
452+
},
453+
});
372454
});
373455
}),
374456
deleteIssue: permProcedure

0 commit comments

Comments
 (0)