Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 106 additions & 4 deletions apps/blade/src/app/_components/issues/create-edit-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
}: Partial<ISSUE.IssueSubmitValues> = initialValues ?? {};
const resolvedEventData = initial.eventData;
const resolvedRoles = initial.teamVisibilityIds ?? defaults.roles;
const resolvedAssigneeIds = initial.assigneeIds ?? defaults.assigneeIds;
if (initial.isEvent) {
return {
...defaults,
Expand All @@ -200,6 +201,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
links: initial.links ?? defaults.links,
date: normalizeTaskDueDate(initial.date ?? defaults.date),
roles: resolvedRoles,
assigneeIds: resolvedAssigneeIds,
};
}
return {
Expand All @@ -211,6 +213,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
date: normalizeTaskDueDate(initial.date ?? defaults.date),
links: initial.links ?? defaults.links,
roles: resolvedRoles,
assigneeIds: resolvedAssigneeIds,
};
}, [initialValues]);
const [formValues, setFormValues] = useState<ISSUE.IssueEditNode>(
Expand Down Expand Up @@ -262,6 +265,16 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {

const baseId = useId();
const effectiveTeam = formValues.team || (rolesData?.[0]?.id ?? "");
const usersOnTeamQuery = api.issues.getUsersOnTeam.useQuery(
{ teamId: effectiveTeam },
{ enabled: isOpen && !!effectiveTeam },
);
const assigneesForTeam = useMemo<ISSUE.IssueAssigneeOption[]>(
() => usersOnTeamQuery.data ?? [],
[usersOnTeamQuery.data],
);
const isAssigneesLoading = usersOnTeamQuery.isLoading;
const assigneesError = usersOnTeamQuery.error;

const isFormValid = issueFormSchema.safeParse({
...formValues,
Expand All @@ -285,6 +298,17 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
),
[formValues.eventData?.roles, roleIdSet],
);
const assigneeIdSet = useMemo(
() => new Set(assigneesForTeam.map((user) => user.id)),
[assigneesForTeam],
);
const safeAssigneeIds = useMemo(
() =>
usersOnTeamQuery.isSuccess
? formValues.assigneeIds.filter((userId) => assigneeIdSet.has(userId))
: formValues.assigneeIds,
[assigneeIdSet, formValues.assigneeIds, usersOnTeamQuery.isSuccess],
);
Comment thread
mchdich marked this conversation as resolved.

// Helper for event form
const updateEventData = <K extends keyof ISSUE.EventFormValues>(
Expand Down Expand Up @@ -410,7 +434,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
team: effectiveTeam,
teamVisibilityIds:
safeVisibilityIds.length > 0 ? safeVisibilityIds : undefined,
assigneeIds: formValues.assigneeIds,
assigneeIds: safeAssigneeIds,
};

try {
Expand Down Expand Up @@ -481,7 +505,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
: normalizeTaskDueDate(formValues.date),
teamVisibilityIds:
safeVisibilityIds.length > 0 ? safeVisibilityIds : undefined,
assigneeIds: formValues.assigneeIds,
assigneeIds: safeAssigneeIds,
});

await utils.issues.invalidate();
Expand Down Expand Up @@ -522,7 +546,7 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
eventData: formValues.eventData,
teamVisibilityIds:
safeVisibilityIds.length > 0 ? safeVisibilityIds : undefined,
assigneeIds: formValues.assigneeIds,
assigneeIds: safeAssigneeIds,
});
}
};
Expand Down Expand Up @@ -663,13 +687,91 @@ export function CreateEditDialog(props: CreateEditDialogComponentProps) {
<TeamSelect
className={cn(baseField, "col-span-3")}
value={effectiveTeam}
onValueChange={(v) => updateForm("team", v)}
onValueChange={(v) => {
setFormValues((previous) => ({
...previous,
team: v,
assigneeIds: [],
}));
}}
roles={rolesData ?? []}
isLoading={isRolesLoading}
error={rolesError}
/>
</div>

<div className="grid grid-cols-4 items-start gap-4">
<Label className="mt-1 text-right text-sm">Assignees</Label>
<div className="col-span-3 mt-1 grid grid-cols-2 gap-x-2 gap-y-3">
{isAssigneesLoading && (
<p className="col-span-2 text-sm text-muted-foreground">
Loading team members...
</p>
)}

{!!assigneesError && (
<p className="col-span-2 text-sm text-destructive">
Failed to load team members
</p>
)}

{!isAssigneesLoading &&
!assigneesError &&
assigneesForTeam.length === 0 && (
<p className="col-span-2 text-sm text-muted-foreground">
No team members found for this team
</p>
)}

{!isAssigneesLoading &&
!assigneesError &&
assigneesForTeam.map((user) => {
const assigneeCheckboxId = `${baseId}-assignee-${user.id}`;
return (
<div
key={user.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<Checkbox
id={assigneeCheckboxId}
checked={formValues.assigneeIds.includes(user.id)}
onCheckedChange={(checked) => {
const selectedIds = formValues.assigneeIds;
updateForm(
"assigneeIds",
checked
? Array.from(
new Set([...selectedIds, user.id]),
)
: selectedIds.filter(
(id) => id !== user.id,
),
);
}}
/>
<div className="flex flex-col">
<Label
htmlFor={assigneeCheckboxId}
className="cursor-pointer font-normal"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{user.name}
</Label>
{user.email && (
<span className="text-xs text-muted-foreground">
{user.email}
</span>
)}
</div>
</div>
);
})}

<p className="col-span-2 text-sm font-normal text-gray-400">
Select one or more team members responsible for this issue
</p>
</div>
</div>

<div className="grid grid-cols-4 items-start gap-4">
<Label
htmlFor={`${baseId}-internal-description`}
Expand Down
174 changes: 128 additions & 46 deletions packages/api/src/routers/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { z } from "zod";
import { ISSUE } from "@forge/consts";
import { and, eq, exists, inArray, sql } from "@forge/db";
import { db } from "@forge/db/client";
import { Permissions } from "@forge/db/schemas/auth";
import { Permissions, User } from "@forge/db/schemas/auth";
import {
InsertTemplateSchema,
Issue,
Expand All @@ -15,6 +15,7 @@ import {
Template,
} from "@forge/db/schemas/knight-hacks";
import { permissions } from "@forge/utils";
import * as permissionsServer from "@forge/utils/permissions.server";

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

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

export const issuesRouter = {
getUsersOnTeam: permProcedure
.input(
z.object({
teamId: z.string().uuid(),
}),
)
.query(async ({ ctx, input }) => {
permissions.controlPerms.or(["EDIT_ISSUES"], ctx);

const rows = await db
.select({
id: User.id,
name: User.name,
email: User.email,
discordUserId: User.discordUserId,
})
.from(User)
.innerJoin(Permissions, eq(User.id, Permissions.userId))
.where(eq(Permissions.roleId, input.teamId));

const userById = new Map<
string,
{
id: string;
name: string;
email: string | null;
}
>();

for (const row of rows) {
userById.set(row.id, {
id: row.id,
name: row.name ?? row.email ?? row.discordUserId,
email: row.email,
});
}

return [...userById.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.

createIssue: permProcedure
.input(
CreateIssueInputSchema.omit({ creator: true }).extend({
Expand All @@ -124,6 +167,15 @@ export const issuesRouter = {
return await db.transaction(async (tx) => {
const { teamVisibilityIds, assigneeIds, children, ...rest } = input;

await permissionsServer.validateAssigneesBelongToTeam(
tx,
input.team,
assigneeIds,
);
if (children?.length) {
await permissionsServer.validateIssueNodeAssignees(tx, children);
}

const [issue] = await tx
.insert(Issue)
.values({
Expand Down Expand Up @@ -316,59 +368,89 @@ export const issuesRouter = {
)
.mutation(async ({ ctx, input }) => {
permissions.controlPerms.or(["EDIT_ISSUES"], ctx);
await requireIssue(input.id);
return await db.transaction(async (tx) => {
const existingIssue = await tx.query.Issue.findFirst({
where: (t, { eq }) => eq(t.id, input.id),
with: {
userAssignments: true,
},
});

const { id, assigneeIds, teamVisibilityIds, ...fields } = input;
const updateData = Object.fromEntries(
(Object.entries(fields) as [string, unknown][]).filter(
([, v]) => v !== undefined,
),
);
if (!existingIssue) {
throw new TRPCError({
message: "Issue not found.",
code: "NOT_FOUND",
});
}

if (Object.keys(updateData).length > 0) {
await db.update(Issue).set(updateData).where(eq(Issue.id, id));
}
const assignmentTeamId = input.team ?? existingIssue.team;
const existingAssigneeIds = existingIssue.userAssignments.map(
(assignment) => assignment.userId,
);
const assigneeIdsToValidate =
input.assigneeIds ??
(input.team !== undefined && input.team !== existingIssue.team
? existingAssigneeIds
: undefined);

await permissionsServer.validateAssigneesBelongToTeam(
tx,
assignmentTeamId,
assigneeIdsToValidate,
);

if (teamVisibilityIds !== undefined) {
await db
.delete(IssuesToTeamsVisibility)
.where(eq(IssuesToTeamsVisibility.issueId, id));
if (teamVisibilityIds.length > 0) {
await db
.insert(IssuesToTeamsVisibility)
.values(
teamVisibilityIds.map((teamId) => ({ issueId: id, teamId })),
);
const { id, assigneeIds, teamVisibilityIds, ...fields } = input;
const updateData = Object.fromEntries(
(Object.entries(fields) as [string, unknown][]).filter(
([, v]) => v !== undefined,
),
);

if (Object.keys(updateData).length > 0) {
await tx.update(Issue).set(updateData).where(eq(Issue.id, id));
}
}

if (assigneeIds !== undefined) {
await db
.delete(IssuesToUsersAssignment)
.where(eq(IssuesToUsersAssignment.issueId, id));
if (assigneeIds.length > 0) {
await db
.insert(IssuesToUsersAssignment)
.values(assigneeIds.map((userId) => ({ issueId: id, userId })));
if (teamVisibilityIds !== undefined) {
await tx
.delete(IssuesToTeamsVisibility)
.where(eq(IssuesToTeamsVisibility.issueId, id));
if (teamVisibilityIds.length > 0) {
await tx
.insert(IssuesToTeamsVisibility)
.values(
teamVisibilityIds.map((teamId) => ({ issueId: id, teamId })),
);
}
}
}

if (
Object.keys(updateData).length === 0 &&
(teamVisibilityIds !== undefined || assigneeIds !== undefined)
) {
await db
.update(Issue)
.set({ updatedAt: new Date() })
.where(eq(Issue.id, id));
}
if (assigneeIds !== undefined) {
await tx
.delete(IssuesToUsersAssignment)
.where(eq(IssuesToUsersAssignment.issueId, id));
if (assigneeIds.length > 0) {
await tx
.insert(IssuesToUsersAssignment)
.values(assigneeIds.map((userId) => ({ issueId: id, userId })));
}
}

return db.query.Issue.findFirst({
where: (t, { eq }) => eq(t.id, id),
with: {
teamVisibility: { with: { team: true } },
userAssignments: { with: { user: true } },
},
if (
Object.keys(updateData).length === 0 &&
(teamVisibilityIds !== undefined || assigneeIds !== undefined)
) {
await tx
.update(Issue)
.set({ updatedAt: new Date() })
.where(eq(Issue.id, id));
}

return tx.query.Issue.findFirst({
where: (t, { eq }) => eq(t.id, id),
with: {
teamVisibility: { with: { team: true } },
userAssignments: { with: { user: true } },
},
});
});
}),
deleteIssue: permProcedure
Expand Down
Loading
Loading