mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
customise organisation issue types
This commit is contained in:
@@ -145,6 +145,44 @@ export async function replaceIssueStatus(organisationId: number, oldStatus: stri
|
|||||||
return { updated: result.rowCount ?? 0 };
|
return { updated: result.rowCount ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getIssueTypeCountByOrganisation(organisationId: number, type: string) {
|
||||||
|
const { Project } = await import("@sprint/shared");
|
||||||
|
|
||||||
|
const projects = await db
|
||||||
|
.select({ id: Project.id })
|
||||||
|
.from(Project)
|
||||||
|
.where(eq(Project.organisationId, organisationId));
|
||||||
|
const projectIds = projects.map((p) => p.id);
|
||||||
|
|
||||||
|
if (projectIds.length === 0) return { count: 0 };
|
||||||
|
|
||||||
|
const [result] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(Issue)
|
||||||
|
.where(and(eq(Issue.type, type), inArray(Issue.projectId, projectIds)));
|
||||||
|
|
||||||
|
return { count: result?.count ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceIssueType(organisationId: number, oldType: string, newType: string) {
|
||||||
|
const { Project } = await import("@sprint/shared");
|
||||||
|
|
||||||
|
const projects = await db
|
||||||
|
.select({ id: Project.id })
|
||||||
|
.from(Project)
|
||||||
|
.where(eq(Project.organisationId, organisationId));
|
||||||
|
const projectIds = projects.map((p) => p.id);
|
||||||
|
|
||||||
|
if (projectIds.length === 0) return { updated: 0 };
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(Issue)
|
||||||
|
.set({ type: newType })
|
||||||
|
.where(and(eq(Issue.type, oldType), inArray(Issue.projectId, projectIds)));
|
||||||
|
|
||||||
|
return { updated: result.rowCount ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getIssuesWithUsersByProject(projectId: number): Promise<IssueResponse[]> {
|
export async function getIssuesWithUsersByProject(projectId: number): Promise<IssueResponse[]> {
|
||||||
const Creator = aliasedTable(User, "Creator");
|
const Creator = aliasedTable(User, "Creator");
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export async function updateOrganisation(
|
|||||||
iconURL?: string | null;
|
iconURL?: string | null;
|
||||||
statuses?: Record<string, string>;
|
statuses?: Record<string, string>;
|
||||||
features?: Record<string, boolean>;
|
features?: Record<string, boolean>;
|
||||||
|
issueTypes?: Record<string, { icon: string; color: string }>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const [organisation] = await db
|
const [organisation] = await db
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ const main = async () => {
|
|||||||
|
|
||||||
"/issues/by-project": withGlobal(withAuth(routes.issuesByProject)),
|
"/issues/by-project": withGlobal(withAuth(routes.issuesByProject)),
|
||||||
"/issues/replace-status": withGlobal(withAuth(withCSRF(routes.issuesReplaceStatus))),
|
"/issues/replace-status": withGlobal(withAuth(withCSRF(routes.issuesReplaceStatus))),
|
||||||
|
"/issues/replace-type": withGlobal(withAuth(withCSRF(routes.issuesReplaceType))),
|
||||||
"/issues/status-count": withGlobal(withAuth(routes.issuesStatusCount)),
|
"/issues/status-count": withGlobal(withAuth(routes.issuesStatusCount)),
|
||||||
|
"/issues/type-count": withGlobal(withAuth(routes.issuesTypeCount)),
|
||||||
"/issues/all": withGlobal(withAuth(routes.issues)),
|
"/issues/all": withGlobal(withAuth(routes.issues)),
|
||||||
"/issue-comments/by-issue": withGlobal(withAuth(routes.issueCommentsByIssue)),
|
"/issue-comments/by-issue": withGlobal(withAuth(routes.issueCommentsByIssue)),
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import issueCommentsByIssue from "./issue-comments/by-issue";
|
|||||||
import issues from "./issues/all";
|
import issues from "./issues/all";
|
||||||
import issuesByProject from "./issues/by-project";
|
import issuesByProject from "./issues/by-project";
|
||||||
import issuesReplaceStatus from "./issues/replace-status";
|
import issuesReplaceStatus from "./issues/replace-status";
|
||||||
|
import issuesReplaceType from "./issues/replace-type";
|
||||||
import issuesStatusCount from "./issues/status-count";
|
import issuesStatusCount from "./issues/status-count";
|
||||||
|
import issuesTypeCount from "./issues/type-count";
|
||||||
import organisationAddMember from "./organisation/add-member";
|
import organisationAddMember from "./organisation/add-member";
|
||||||
import organisationById from "./organisation/by-id";
|
import organisationById from "./organisation/by-id";
|
||||||
import organisationsByUser from "./organisation/by-user";
|
import organisationsByUser from "./organisation/by-user";
|
||||||
@@ -64,7 +66,9 @@ export const routes = {
|
|||||||
issuesByProject,
|
issuesByProject,
|
||||||
issues,
|
issues,
|
||||||
issuesReplaceStatus,
|
issuesReplaceStatus,
|
||||||
|
issuesReplaceType,
|
||||||
issuesStatusCount,
|
issuesStatusCount,
|
||||||
|
issuesTypeCount,
|
||||||
|
|
||||||
organisationCreate,
|
organisationCreate,
|
||||||
organisationById,
|
organisationById,
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ export default async function issueUpdate(req: AuthedRequest) {
|
|||||||
if (!requesterMember) {
|
if (!requesterMember) {
|
||||||
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
|
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
|
||||||
}
|
}
|
||||||
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
|
|
||||||
return errorResponse("only organisation owners and admins can edit issues", "PERMISSION_DENIED", 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
let issue: IssueRecord | undefined = existingIssue;
|
let issue: IssueRecord | undefined = existingIssue;
|
||||||
if (hasIssueFieldUpdates) {
|
if (hasIssueFieldUpdates) {
|
||||||
|
|||||||
24
packages/backend/src/routes/issues/replace-type.ts
Normal file
24
packages/backend/src/routes/issues/replace-type.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { IssuesReplaceTypeRequestSchema } from "@sprint/shared";
|
||||||
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
|
import { getOrganisationMemberRole, replaceIssueType } from "../../db/queries";
|
||||||
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
|
export default async function issuesReplaceType(req: AuthedRequest) {
|
||||||
|
const parsed = await parseJsonBody(req, IssuesReplaceTypeRequestSchema);
|
||||||
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
|
const { organisationId, oldType, newType } = parsed.data;
|
||||||
|
|
||||||
|
const membership = await getOrganisationMemberRole(organisationId, req.userId);
|
||||||
|
if (!membership) {
|
||||||
|
return errorResponse("not a member of this organisation", "NOT_MEMBER", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership.role !== "owner" && membership.role !== "admin") {
|
||||||
|
return errorResponse("only admins and owners can replace types", "PERMISSION_DENIED", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await replaceIssueType(organisationId, oldType, newType);
|
||||||
|
|
||||||
|
return Response.json(result);
|
||||||
|
}
|
||||||
21
packages/backend/src/routes/issues/type-count.ts
Normal file
21
packages/backend/src/routes/issues/type-count.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { IssuesTypeCountQuerySchema } from "@sprint/shared";
|
||||||
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
|
import { getIssueTypeCountByOrganisation, getOrganisationMemberRole } from "../../db/queries";
|
||||||
|
import { errorResponse, parseQueryParams } from "../../validation";
|
||||||
|
|
||||||
|
export default async function issuesTypeCount(req: AuthedRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const parsed = parseQueryParams(url, IssuesTypeCountQuerySchema);
|
||||||
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
|
const { organisationId, type } = parsed.data;
|
||||||
|
|
||||||
|
const membership = await getOrganisationMemberRole(organisationId, req.userId);
|
||||||
|
if (!membership) {
|
||||||
|
return errorResponse("not a member of this organisation", "NOT_MEMBER", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getIssueTypeCountByOrganisation(organisationId, type);
|
||||||
|
|
||||||
|
return Response.json(result);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export default async function organisationUpdate(req: AuthedRequest) {
|
|||||||
const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
|
const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
|
||||||
if ("error" in parsed) return parsed.error;
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
const { id, name, description, slug, iconURL, statuses, features } = parsed.data;
|
const { id, name, description, slug, iconURL, statuses, features, issueTypes } = parsed.data;
|
||||||
|
|
||||||
const existingOrganisation = await getOrganisationById(id);
|
const existingOrganisation = await getOrganisationById(id);
|
||||||
if (!existingOrganisation) {
|
if (!existingOrganisation) {
|
||||||
@@ -22,9 +22,9 @@ export default async function organisationUpdate(req: AuthedRequest) {
|
|||||||
return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
|
return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name && !description && !slug && !statuses && !features && iconURL === undefined) {
|
if (!name && !description && !slug && !statuses && !features && !issueTypes && iconURL === undefined) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"at least one of name, description, slug, iconURL, or statuses must be provided",
|
"at least one of name, description, slug, iconURL, statuses, issueTypes, or features must be provided",
|
||||||
"NO_UPDATES",
|
"NO_UPDATES",
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
@@ -37,6 +37,7 @@ export default async function organisationUpdate(req: AuthedRequest) {
|
|||||||
iconURL,
|
iconURL,
|
||||||
statuses,
|
statuses,
|
||||||
features,
|
features,
|
||||||
|
issueTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(organisation);
|
return Response.json(organisation);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_FEATURES,
|
DEFAULT_FEATURES,
|
||||||
|
DEFAULT_ISSUE_TYPES,
|
||||||
DEFAULT_STATUS_COLOUR,
|
DEFAULT_STATUS_COLOUR,
|
||||||
ISSUE_STATUS_MAX_LENGTH,
|
ISSUE_STATUS_MAX_LENGTH,
|
||||||
|
ISSUE_TYPE_MAX_LENGTH,
|
||||||
type SprintRecord,
|
type SprintRecord,
|
||||||
} from "@sprint/shared";
|
} from "@sprint/shared";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -29,7 +31,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import Icon from "@/components/ui/icon";
|
import Icon, { type IconName, iconNames } from "@/components/ui/icon";
|
||||||
import { IconButton } from "@/components/ui/icon-button";
|
import { IconButton } from "@/components/ui/icon-button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@@ -43,6 +45,7 @@ import {
|
|||||||
useProjects,
|
useProjects,
|
||||||
useRemoveOrganisationMember,
|
useRemoveOrganisationMember,
|
||||||
useReplaceIssueStatus,
|
useReplaceIssueStatus,
|
||||||
|
useReplaceIssueType,
|
||||||
useSprints,
|
useSprints,
|
||||||
useUpdateOrganisation,
|
useUpdateOrganisation,
|
||||||
useUpdateOrganisationMemberRole,
|
useUpdateOrganisationMemberRole,
|
||||||
@@ -67,6 +70,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
const deleteProject = useDeleteProject();
|
const deleteProject = useDeleteProject();
|
||||||
const deleteSprint = useDeleteSprint();
|
const deleteSprint = useDeleteSprint();
|
||||||
const replaceIssueStatus = useReplaceIssueStatus();
|
const replaceIssueStatus = useReplaceIssueStatus();
|
||||||
|
const replaceIssueType = useReplaceIssueType();
|
||||||
|
|
||||||
const organisations = useMemo(
|
const organisations = useMemo(
|
||||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||||
@@ -122,6 +126,18 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
const [issuesUsingStatus, setIssuesUsingStatus] = useState<number>(0);
|
const [issuesUsingStatus, setIssuesUsingStatus] = useState<number>(0);
|
||||||
const [reassignToStatus, setReassignToStatus] = useState<string>("");
|
const [reassignToStatus, setReassignToStatus] = useState<string>("");
|
||||||
|
|
||||||
|
// issue types state
|
||||||
|
type IssueTypeConfig = { icon: string; color: string };
|
||||||
|
const [issueTypes, setIssueTypes] = useState<Record<string, IssueTypeConfig>>({});
|
||||||
|
const [isCreatingType, setIsCreatingType] = useState(false);
|
||||||
|
const [newTypeName, setNewTypeName] = useState("");
|
||||||
|
const [newTypeIcon, setNewTypeIcon] = useState<IconName>("checkBox");
|
||||||
|
const [newTypeColour, setNewTypeColour] = useState(DEFAULT_ISSUE_TYPES.Task.color);
|
||||||
|
const [typeError, setTypeError] = useState<string | null>(null);
|
||||||
|
const [typeToRemove, setTypeToRemove] = useState<string | null>(null);
|
||||||
|
const [issuesUsingType, setIssuesUsingType] = useState<number>(0);
|
||||||
|
const [reassignToType, setReassignToType] = useState<string>("");
|
||||||
|
|
||||||
// edit/delete state for organisations, projects, and sprints
|
// edit/delete state for organisations, projects, and sprints
|
||||||
const [editOrgOpen, setEditOrgOpen] = useState(false);
|
const [editOrgOpen, setEditOrgOpen] = useState(false);
|
||||||
const [editProjectOpen, setEditProjectOpen] = useState(false);
|
const [editProjectOpen, setEditProjectOpen] = useState(false);
|
||||||
@@ -239,6 +255,8 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedOrganisation) {
|
if (selectedOrganisation) {
|
||||||
setStatuses(selectedOrganisation.Organisation.statuses);
|
setStatuses(selectedOrganisation.Organisation.statuses);
|
||||||
|
const orgIssueTypes = selectedOrganisation.Organisation.issueTypes as Record<string, IssueTypeConfig>;
|
||||||
|
setIssueTypes(orgIssueTypes ?? {});
|
||||||
}
|
}
|
||||||
}, [selectedOrganisation]);
|
}, [selectedOrganisation]);
|
||||||
|
|
||||||
@@ -431,6 +449,183 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// issue types functions
|
||||||
|
const updateIssueTypes = async (
|
||||||
|
newIssueTypes: Record<string, IssueTypeConfig>,
|
||||||
|
typeRemoved?: { name: string; icon: string; color: string },
|
||||||
|
typeAdded?: { name: string; icon: string; color: string },
|
||||||
|
typeMoved?: { name: string; icon: string; color: string; currentIndex: number; nextIndex: number },
|
||||||
|
) => {
|
||||||
|
if (!selectedOrganisation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateOrganisation.mutateAsync({
|
||||||
|
id: selectedOrganisation.Organisation.id,
|
||||||
|
issueTypes: newIssueTypes,
|
||||||
|
});
|
||||||
|
setIssueTypes(newIssueTypes);
|
||||||
|
if (typeAdded) {
|
||||||
|
toast.success(
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
Created <Icon icon={typeAdded.icon as IconName} size={14} color={typeAdded.color} />
|
||||||
|
{typeAdded.name} type successfully
|
||||||
|
</span>,
|
||||||
|
{ dismissible: false },
|
||||||
|
);
|
||||||
|
} else if (typeRemoved) {
|
||||||
|
toast.success(
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
Removed <Icon icon={typeRemoved.icon as IconName} size={14} color={typeRemoved.color} />
|
||||||
|
{typeRemoved.name} type successfully
|
||||||
|
</span>,
|
||||||
|
{ dismissible: false },
|
||||||
|
);
|
||||||
|
} else if (typeMoved) {
|
||||||
|
toast.success(
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
Moved <Icon icon={typeMoved.icon as IconName} size={14} color={typeMoved.color} />
|
||||||
|
{typeMoved.name} from position {typeMoved.currentIndex + 1} to {typeMoved.nextIndex + 1}
|
||||||
|
</span>,
|
||||||
|
{ dismissible: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await invalidateOrganisations();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("error updating issue types:", err);
|
||||||
|
if (typeAdded) {
|
||||||
|
toast.error(
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
Error adding <Icon icon={typeAdded.icon as IconName} size={14} color={typeAdded.color} />
|
||||||
|
{typeAdded.name} to {selectedOrganisation.Organisation.name}: {String(err)}
|
||||||
|
</span>,
|
||||||
|
{ dismissible: false },
|
||||||
|
);
|
||||||
|
} else if (typeRemoved) {
|
||||||
|
toast.error(
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
Error removing <Icon icon={typeRemoved.icon as IconName} size={14} color={typeRemoved.color} />
|
||||||
|
{typeRemoved.name} from {selectedOrganisation.Organisation.name}: {String(err)}
|
||||||
|
</span>,
|
||||||
|
{ dismissible: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateType = async () => {
|
||||||
|
const trimmed = newTypeName.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
if (trimmed.length > ISSUE_TYPE_MAX_LENGTH) {
|
||||||
|
setTypeError(`type name must be <= ${ISSUE_TYPE_MAX_LENGTH} characters`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(issueTypes).includes(trimmed)) {
|
||||||
|
setNewTypeName("");
|
||||||
|
setIsCreatingType(false);
|
||||||
|
setTypeError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIssueTypes = { ...issueTypes };
|
||||||
|
newIssueTypes[trimmed] = { icon: newTypeIcon, color: newTypeColour };
|
||||||
|
await updateIssueTypes(newIssueTypes, undefined, {
|
||||||
|
name: trimmed,
|
||||||
|
icon: newTypeIcon,
|
||||||
|
color: newTypeColour,
|
||||||
|
});
|
||||||
|
|
||||||
|
setNewTypeName("");
|
||||||
|
setNewTypeIcon("checkBox");
|
||||||
|
setNewTypeColour(DEFAULT_ISSUE_TYPES.Task.color);
|
||||||
|
setIsCreatingType(false);
|
||||||
|
setTypeError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTypeClick = async (typeName: string) => {
|
||||||
|
if (Object.keys(issueTypes).length <= 1 || !selectedOrganisation) return;
|
||||||
|
try {
|
||||||
|
const data = await issue.typeCount(selectedOrganisation.Organisation.id, typeName);
|
||||||
|
const count = data.count ?? 0;
|
||||||
|
if (count > 0) {
|
||||||
|
setTypeToRemove(typeName);
|
||||||
|
setIssuesUsingType(count);
|
||||||
|
const remaining = Object.keys(issueTypes).filter((t) => t !== typeName);
|
||||||
|
setReassignToType(remaining[0] || "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTypes = Object.keys(issueTypes).filter((t) => t !== typeName);
|
||||||
|
await updateIssueTypes(Object.fromEntries(nextTypes.map((t) => [t, issueTypes[t]])), {
|
||||||
|
name: typeName,
|
||||||
|
...issueTypes[typeName],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("error checking type usage:", err);
|
||||||
|
toast.error(
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
Error checking type usage for{" "}
|
||||||
|
<Icon icon={issueTypes[typeName].icon as IconName} size={14} color={issueTypes[typeName].color} />
|
||||||
|
{typeName}: {String(err)}
|
||||||
|
</span>,
|
||||||
|
{ dismissible: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveType = async (typeName: string, direction: "up" | "down") => {
|
||||||
|
const keys = Object.keys(issueTypes);
|
||||||
|
const currentIndex = keys.indexOf(typeName);
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
const nextIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
||||||
|
if (nextIndex < 0 || nextIndex >= keys.length) return;
|
||||||
|
|
||||||
|
const nextKeys = [...keys];
|
||||||
|
[nextKeys[currentIndex], nextKeys[nextIndex]] = [nextKeys[nextIndex], nextKeys[currentIndex]];
|
||||||
|
|
||||||
|
await updateIssueTypes(
|
||||||
|
Object.fromEntries(nextKeys.map((t) => [t, issueTypes[t]])),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ name: typeName, ...issueTypes[typeName], currentIndex, nextIndex },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRemoveType = async () => {
|
||||||
|
if (!typeToRemove || !reassignToType || !selectedOrganisation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await replaceIssueType.mutateAsync({
|
||||||
|
organisationId: selectedOrganisation.Organisation.id,
|
||||||
|
oldType: typeToRemove,
|
||||||
|
newType: reassignToType,
|
||||||
|
});
|
||||||
|
const nextTypes = Object.keys(issueTypes).filter((t) => t !== typeToRemove);
|
||||||
|
await updateIssueTypes(Object.fromEntries(nextTypes.map((t) => [t, issueTypes[t]])), {
|
||||||
|
name: typeToRemove,
|
||||||
|
...issueTypes[typeToRemove],
|
||||||
|
});
|
||||||
|
setTypeToRemove(null);
|
||||||
|
setReassignToType("");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("error replacing type:", error);
|
||||||
|
toast.error(
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
Error removing{" "}
|
||||||
|
<Icon
|
||||||
|
icon={issueTypes[typeToRemove].icon as IconName}
|
||||||
|
size={14}
|
||||||
|
color={issueTypes[typeToRemove].color}
|
||||||
|
/>
|
||||||
|
{typeToRemove} from {selectedOrganisation.Organisation.name}: {String(error)}
|
||||||
|
</span>,
|
||||||
|
{ dismissible: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !selectedOrganisationId) return;
|
if (!open || !selectedOrganisationId) return;
|
||||||
void invalidateMembers();
|
void invalidateMembers();
|
||||||
@@ -815,6 +1010,148 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="issues">
|
<TabsContent value="issues">
|
||||||
|
{/* Issue Types section */}
|
||||||
|
<div className="border p-2 min-w-0 overflow-hidden mb-2">
|
||||||
|
<h2 className="text-xl font-600 mb-2">Issue Types</h2>
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<div className="flex flex-col gap-2 max-h-86 overflow-y-scroll grid grid-cols-2">
|
||||||
|
{Object.keys(issueTypes).map((typeName, index) => {
|
||||||
|
const typeConfig = issueTypes[typeName];
|
||||||
|
return (
|
||||||
|
<div key={typeName} className="flex items-center justify-between p-2 border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm tabular-nums">{index + 1}</span>
|
||||||
|
<Icon icon={typeConfig.icon as IconName} size={16} color={typeConfig.color} />
|
||||||
|
<span className="text-sm">{typeName}</span>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
asChild
|
||||||
|
size={"sm"}
|
||||||
|
noStyle
|
||||||
|
className="hover:opacity-80 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Icon icon="ellipsisVertical" className="size-4 text-foreground" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" sideOffset={4} className="bg-background">
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={index === 0}
|
||||||
|
onSelect={() => void moveType(typeName, "up")}
|
||||||
|
className="hover:bg-primary-foreground"
|
||||||
|
>
|
||||||
|
<Icon icon="chevronUp" className="size-4 text-muted-foreground" />
|
||||||
|
Move up
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={index === Object.keys(issueTypes).length - 1}
|
||||||
|
onSelect={() => void moveType(typeName, "down")}
|
||||||
|
className="hover:bg-primary-foreground"
|
||||||
|
>
|
||||||
|
<Icon icon="chevronDown" className="size-4 text-muted-foreground" />
|
||||||
|
Move down
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
disabled={Object.keys(issueTypes).length <= 1}
|
||||||
|
onSelect={() => void handleRemoveTypeClick(typeName)}
|
||||||
|
className="hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Icon icon="x" className="size-4" />
|
||||||
|
Remove
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{isAdmin &&
|
||||||
|
(isCreatingType ? (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 w-full min-w-0">
|
||||||
|
<Input
|
||||||
|
value={newTypeName}
|
||||||
|
maxLength={ISSUE_TYPE_MAX_LENGTH}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewTypeName(e.target.value);
|
||||||
|
if (typeError) setTypeError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Type name"
|
||||||
|
className="flex-1 w-0 min-w-0"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
void handleCreateType();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setIsCreatingType(false);
|
||||||
|
setNewTypeName("");
|
||||||
|
setTypeError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select value={newTypeIcon} onValueChange={(v) => setNewTypeIcon(v as IconName)}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="group flex items-center w-min"
|
||||||
|
variant="default"
|
||||||
|
chevronClassName="hidden"
|
||||||
|
>
|
||||||
|
<Icon icon={newTypeIcon} size={20} color={newTypeColour} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
side="bottom"
|
||||||
|
position="popper"
|
||||||
|
align="start"
|
||||||
|
className="max-h-64"
|
||||||
|
>
|
||||||
|
{iconNames.map((iconName) => (
|
||||||
|
<SelectItem key={iconName} value={iconName}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon icon={iconName} size={16} color={newTypeColour} />
|
||||||
|
<span className="text-xs">
|
||||||
|
{unCamelCase(iconName).replace(" Icon", "").replace("2Icon", " 2")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<ColourPicker
|
||||||
|
colour={newTypeColour}
|
||||||
|
onChange={setNewTypeColour}
|
||||||
|
asChild={false}
|
||||||
|
className="w-9 h-9"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
onClick={() => void handleCreateType()}
|
||||||
|
disabled={newTypeName.trim().length > ISSUE_TYPE_MAX_LENGTH}
|
||||||
|
>
|
||||||
|
<Icon icon="plus" className="size-4" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{typeError && <p className="text-xs text-destructive">{typeError}</p>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCreatingType(true);
|
||||||
|
setTypeError(null);
|
||||||
|
}}
|
||||||
|
className="flex gap-2 w-full min-w-0"
|
||||||
|
>
|
||||||
|
Create type <Icon icon="plus" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issue Statuses section */}
|
||||||
<div className="border p-2 min-w-0 overflow-hidden">
|
<div className="border p-2 min-w-0 overflow-hidden">
|
||||||
<h2 className="text-xl font-600 mb-2">Issue Statuses</h2>
|
<h2 className="text-xl font-600 mb-2">Issue Statuses</h2>
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
@@ -1033,6 +1370,77 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Type removal dialog with reassignment */}
|
||||||
|
<Dialog
|
||||||
|
open={typeToRemove !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setTypeToRemove(null);
|
||||||
|
setReassignToType("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove Type</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Are you sure you want to remove the{" "}
|
||||||
|
{typeToRemove && issueTypes[typeToRemove] ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Icon
|
||||||
|
icon={issueTypes[typeToRemove].icon as IconName}
|
||||||
|
size={14}
|
||||||
|
color={issueTypes[typeToRemove].color}
|
||||||
|
/>
|
||||||
|
{typeToRemove}
|
||||||
|
</span>
|
||||||
|
) : null}{" "}
|
||||||
|
type? <span className="font-700 text-foreground">{issuesUsingType}</span> issues are using it.
|
||||||
|
Which type would you like these issues to use instead?
|
||||||
|
</p>
|
||||||
|
<Select value={reassignToType} onValueChange={setReassignToType}>
|
||||||
|
<SelectTrigger className="w-min">
|
||||||
|
<SelectValue placeholder="Select type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side={"bottom"} position="popper" align="start">
|
||||||
|
{Object.keys(issueTypes)
|
||||||
|
.filter((t) => t !== typeToRemove)
|
||||||
|
.map((typeName) => (
|
||||||
|
<SelectItem key={typeName} value={typeName}>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Icon
|
||||||
|
icon={issueTypes[typeName].icon as IconName}
|
||||||
|
size={14}
|
||||||
|
color={issueTypes[typeName].color}
|
||||||
|
/>
|
||||||
|
{typeName}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex gap-2 justify-end mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setTypeToRemove(null);
|
||||||
|
setReassignToType("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void confirmRemoveType()}
|
||||||
|
disabled={!reassignToType}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -181,17 +181,20 @@ export default function Icon({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fill = "transparent";
|
||||||
|
// lucide fills sillily
|
||||||
|
if (color && resolvedStyle !== "lucide") {
|
||||||
|
fill = color;
|
||||||
|
} else if (resolvedStyle === "pixel" && ["bug", "moon", "hash"].includes(icon)) {
|
||||||
|
fill = "var(--foreground)";
|
||||||
|
} else if (resolvedStyle === "phosphor") {
|
||||||
|
fill = "var(--foreground)";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconComponent
|
<IconComponent
|
||||||
size={size}
|
size={size}
|
||||||
fill={
|
fill={fill}
|
||||||
color
|
|
||||||
? color
|
|
||||||
: (resolvedStyle === "pixel" && ["bug", "moon", "hash"].includes(icon)) ||
|
|
||||||
resolvedStyle === "phosphor"
|
|
||||||
? "var(--foreground)"
|
|
||||||
: "transparent"
|
|
||||||
}
|
|
||||||
style={{ color: color ? color : "var(--foreground)" }}
|
style={{ color: color ? color : "var(--foreground)" }}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import type {
|
|||||||
IssueRecord,
|
IssueRecord,
|
||||||
IssueResponse,
|
IssueResponse,
|
||||||
IssuesReplaceStatusRequest,
|
IssuesReplaceStatusRequest,
|
||||||
|
IssuesReplaceTypeRequest,
|
||||||
IssueUpdateRequest,
|
IssueUpdateRequest,
|
||||||
StatusCountResponse,
|
StatusCountResponse,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
|
TypeCountResponse,
|
||||||
} from "@sprint/shared";
|
} from "@sprint/shared";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "@/lib/query/keys";
|
import { queryKeys } from "@/lib/query/keys";
|
||||||
@@ -76,3 +78,23 @@ export function useReplaceIssueStatus() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useIssueTypeCount(organisationId?: number | null, type?: string | null) {
|
||||||
|
return useQuery<TypeCountResponse>({
|
||||||
|
queryKey: queryKeys.issues.typeCount(organisationId ?? 0, type ?? ""),
|
||||||
|
queryFn: () => issue.typeCount(organisationId ?? 0, type ?? ""),
|
||||||
|
enabled: Boolean(organisationId && type),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReplaceIssueType() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<unknown, Error, IssuesReplaceTypeRequest>({
|
||||||
|
mutationKey: ["issues", "replace-type"],
|
||||||
|
mutationFn: issue.replaceType,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const queryKeys = {
|
|||||||
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
|
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
|
||||||
statusCount: (organisationId: number, status: string) =>
|
statusCount: (organisationId: number, status: string) =>
|
||||||
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
|
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
|
||||||
|
typeCount: (organisationId: number, type: string) =>
|
||||||
|
[...queryKeys.issues.all, "type-count", organisationId, type] as const,
|
||||||
},
|
},
|
||||||
issueComments: {
|
issueComments: {
|
||||||
all: ["issue-comments"] as const,
|
all: ["issue-comments"] as const,
|
||||||
|
|||||||
@@ -2,5 +2,7 @@ export { byProject } from "@/lib/server/issue/byProject";
|
|||||||
export { create } from "@/lib/server/issue/create";
|
export { create } from "@/lib/server/issue/create";
|
||||||
export { remove as delete } from "@/lib/server/issue/delete";
|
export { remove as delete } from "@/lib/server/issue/delete";
|
||||||
export { replaceStatus } from "@/lib/server/issue/replaceStatus";
|
export { replaceStatus } from "@/lib/server/issue/replaceStatus";
|
||||||
|
export { replaceType } from "@/lib/server/issue/replaceType";
|
||||||
export { statusCount } from "@/lib/server/issue/statusCount";
|
export { statusCount } from "@/lib/server/issue/statusCount";
|
||||||
|
export { typeCount } from "@/lib/server/issue/typeCount";
|
||||||
export { update } from "@/lib/server/issue/update";
|
export { update } from "@/lib/server/issue/update";
|
||||||
|
|||||||
24
packages/frontend/src/lib/server/issue/replaceType.ts
Normal file
24
packages/frontend/src/lib/server/issue/replaceType.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { IssuesReplaceTypeRequest, ReplaceTypeResponse } from "@sprint/shared";
|
||||||
|
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||||
|
import { getErrorMessage } from "..";
|
||||||
|
|
||||||
|
export async function replaceType(request: IssuesReplaceTypeRequest): Promise<ReplaceTypeResponse> {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
|
||||||
|
const res = await fetch(`${getServerURL()}/issues/replace-type`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = await getErrorMessage(res, `failed to replace type (${res.status})`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
20
packages/frontend/src/lib/server/issue/typeCount.ts
Normal file
20
packages/frontend/src/lib/server/issue/typeCount.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { TypeCountResponse } from "@sprint/shared";
|
||||||
|
import { getServerURL } from "@/lib/utils";
|
||||||
|
import { getErrorMessage } from "..";
|
||||||
|
|
||||||
|
export async function typeCount(organisationId: number, type: string): Promise<TypeCountResponse> {
|
||||||
|
const url = new URL(`${getServerURL()}/issues/type-count`);
|
||||||
|
url.searchParams.set("organisationId", `${organisationId}`);
|
||||||
|
url.searchParams.set("type", type);
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = await getErrorMessage(res, `failed to get issue type count (${res.status})`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -116,6 +116,21 @@ export const IssuesReplaceStatusRequestSchema = z.object({
|
|||||||
|
|
||||||
export type IssuesReplaceStatusRequest = z.infer<typeof IssuesReplaceStatusRequestSchema>;
|
export type IssuesReplaceStatusRequest = z.infer<typeof IssuesReplaceStatusRequestSchema>;
|
||||||
|
|
||||||
|
export const IssuesTypeCountQuerySchema = z.object({
|
||||||
|
organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"),
|
||||||
|
type: z.string().min(1, "Type is required").max(ISSUE_TYPE_MAX_LENGTH),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IssuesTypeCountQuery = z.infer<typeof IssuesTypeCountQuerySchema>;
|
||||||
|
|
||||||
|
export const IssuesReplaceTypeRequestSchema = z.object({
|
||||||
|
organisationId: z.number().int().positive("organisationId must be a positive integer"),
|
||||||
|
oldType: z.string().min(1, "oldType is required").max(ISSUE_TYPE_MAX_LENGTH),
|
||||||
|
newType: z.string().min(1, "newType is required").max(ISSUE_TYPE_MAX_LENGTH),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IssuesReplaceTypeRequest = z.infer<typeof IssuesReplaceTypeRequestSchema>;
|
||||||
|
|
||||||
export const IssueCommentCreateRequestSchema = z.object({
|
export const IssueCommentCreateRequestSchema = z.object({
|
||||||
issueId: z.number().int().positive("issueId must be a positive integer"),
|
issueId: z.number().int().positive("issueId must be a positive integer"),
|
||||||
body: z.string().min(1, "Comment is required").max(ISSUE_COMMENT_MAX_LENGTH),
|
body: z.string().min(1, "Comment is required").max(ISSUE_COMMENT_MAX_LENGTH),
|
||||||
@@ -175,6 +190,14 @@ export const OrgUpdateRequestSchema = z.object({
|
|||||||
"Features must include all default features",
|
"Features must include all default features",
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
issueTypes: z
|
||||||
|
.record(z.object({ icon: z.string(), color: z.string() }))
|
||||||
|
.refine((obj) => Object.keys(obj).length > 0, "Issue types must have at least one entry")
|
||||||
|
.refine(
|
||||||
|
(obj) => Object.keys(obj).every((key) => key.length <= ISSUE_TYPE_MAX_LENGTH),
|
||||||
|
`Issue type keys must be <= ${ISSUE_TYPE_MAX_LENGTH} characters`,
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type OrgUpdateRequest = z.infer<typeof OrgUpdateRequestSchema>;
|
export type OrgUpdateRequest = z.infer<typeof OrgUpdateRequestSchema>;
|
||||||
@@ -504,6 +527,18 @@ export const ReplaceStatusResponseSchema = z.object({
|
|||||||
|
|
||||||
export type ReplaceStatusResponse = z.infer<typeof ReplaceStatusResponseSchema>;
|
export type ReplaceStatusResponse = z.infer<typeof ReplaceStatusResponseSchema>;
|
||||||
|
|
||||||
|
export const TypeCountResponseSchema = z.object({
|
||||||
|
count: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TypeCountResponse = z.infer<typeof TypeCountResponseSchema>;
|
||||||
|
|
||||||
|
export const ReplaceTypeResponseSchema = z.object({
|
||||||
|
rowCount: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ReplaceTypeResponse = z.infer<typeof ReplaceTypeResponseSchema>;
|
||||||
|
|
||||||
// general
|
// general
|
||||||
|
|
||||||
export const SuccessResponseSchema = z.object({
|
export const SuccessResponseSchema = z.object({
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export type {
|
|||||||
IssueResponseType,
|
IssueResponseType,
|
||||||
IssuesByProjectQuery,
|
IssuesByProjectQuery,
|
||||||
IssuesReplaceStatusRequest,
|
IssuesReplaceStatusRequest,
|
||||||
|
IssuesReplaceTypeRequest,
|
||||||
IssuesStatusCountQuery,
|
IssuesStatusCountQuery,
|
||||||
|
IssuesTypeCountQuery,
|
||||||
IssueUpdateRequest,
|
IssueUpdateRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
OrgAddMemberRequest,
|
OrgAddMemberRequest,
|
||||||
@@ -31,6 +33,7 @@ export type {
|
|||||||
ProjectUpdateRequest,
|
ProjectUpdateRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
ReplaceStatusResponse,
|
ReplaceStatusResponse,
|
||||||
|
ReplaceTypeResponse,
|
||||||
SprintCreateRequest,
|
SprintCreateRequest,
|
||||||
SprintDeleteRequest,
|
SprintDeleteRequest,
|
||||||
SprintResponseType,
|
SprintResponseType,
|
||||||
@@ -42,6 +45,7 @@ export type {
|
|||||||
TimerGetQuery,
|
TimerGetQuery,
|
||||||
TimerStateType,
|
TimerStateType,
|
||||||
TimerToggleRequest,
|
TimerToggleRequest,
|
||||||
|
TypeCountResponse,
|
||||||
UserByUsernameQuery,
|
UserByUsernameQuery,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
UserUpdateRequest,
|
UserUpdateRequest,
|
||||||
@@ -61,7 +65,9 @@ export {
|
|||||||
IssueResponseSchema,
|
IssueResponseSchema,
|
||||||
IssuesByProjectQuerySchema,
|
IssuesByProjectQuerySchema,
|
||||||
IssuesReplaceStatusRequestSchema,
|
IssuesReplaceStatusRequestSchema,
|
||||||
|
IssuesReplaceTypeRequestSchema,
|
||||||
IssuesStatusCountQuerySchema,
|
IssuesStatusCountQuerySchema,
|
||||||
|
IssuesTypeCountQuerySchema,
|
||||||
IssueUpdateRequestSchema,
|
IssueUpdateRequestSchema,
|
||||||
LoginRequestSchema,
|
LoginRequestSchema,
|
||||||
OrgAddMemberRequestSchema,
|
OrgAddMemberRequestSchema,
|
||||||
@@ -85,6 +91,7 @@ export {
|
|||||||
ProjectUpdateRequestSchema,
|
ProjectUpdateRequestSchema,
|
||||||
RegisterRequestSchema,
|
RegisterRequestSchema,
|
||||||
ReplaceStatusResponseSchema,
|
ReplaceStatusResponseSchema,
|
||||||
|
ReplaceTypeResponseSchema,
|
||||||
SprintCreateRequestSchema,
|
SprintCreateRequestSchema,
|
||||||
SprintDeleteRequestSchema,
|
SprintDeleteRequestSchema,
|
||||||
SprintRecordSchema,
|
SprintRecordSchema,
|
||||||
@@ -96,6 +103,7 @@ export {
|
|||||||
TimerGetQuerySchema,
|
TimerGetQuerySchema,
|
||||||
TimerStateSchema,
|
TimerStateSchema,
|
||||||
TimerToggleRequestSchema,
|
TimerToggleRequestSchema,
|
||||||
|
TypeCountResponseSchema,
|
||||||
UserByUsernameQuerySchema,
|
UserByUsernameQuerySchema,
|
||||||
UserResponseSchema,
|
UserResponseSchema,
|
||||||
UserUpdateRequestSchema,
|
UserUpdateRequestSchema,
|
||||||
@@ -105,6 +113,7 @@ export {
|
|||||||
ISSUE_DESCRIPTION_MAX_LENGTH,
|
ISSUE_DESCRIPTION_MAX_LENGTH,
|
||||||
ISSUE_STATUS_MAX_LENGTH,
|
ISSUE_STATUS_MAX_LENGTH,
|
||||||
ISSUE_TITLE_MAX_LENGTH,
|
ISSUE_TITLE_MAX_LENGTH,
|
||||||
|
ISSUE_TYPE_MAX_LENGTH,
|
||||||
ORG_DESCRIPTION_MAX_LENGTH,
|
ORG_DESCRIPTION_MAX_LENGTH,
|
||||||
ORG_NAME_MAX_LENGTH,
|
ORG_NAME_MAX_LENGTH,
|
||||||
ORG_SLUG_MAX_LENGTH,
|
ORG_SLUG_MAX_LENGTH,
|
||||||
@@ -145,6 +154,7 @@ export type {
|
|||||||
} from "./schema";
|
} from "./schema";
|
||||||
export {
|
export {
|
||||||
DEFAULT_FEATURES,
|
DEFAULT_FEATURES,
|
||||||
|
DEFAULT_ISSUE_TYPES,
|
||||||
DEFAULT_SPRINT_COLOUR,
|
DEFAULT_SPRINT_COLOUR,
|
||||||
DEFAULT_STATUS_COLOUR,
|
DEFAULT_STATUS_COLOUR,
|
||||||
DEFAULT_STATUS_COLOURS,
|
DEFAULT_STATUS_COLOURS,
|
||||||
|
|||||||
Reference in New Issue
Block a user