implemented organisation features

This commit is contained in:
2026-01-24 19:44:40 +00:00
parent c37c3742b9
commit f65ad0c593
8 changed files with 120 additions and 106 deletions

View File

@@ -5,7 +5,7 @@
"identifier": "com.hex248.sprint",
"build": {
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420/app",
"devUrl": "http://localhost:1420/issues",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},
@@ -18,7 +18,7 @@
"minWidth": 640,
"minHeight": 360,
"decorations": false,
"url": "/app"
"url": "/issues"
}
],
"security": {

View File

@@ -1,5 +1,6 @@
import { useSession } from "@/components/session-provider";
import Icon from "@/components/ui/icon";
import { useSelectedOrganisation } from "@/lib/query/hooks";
import { cn } from "@/lib/utils";
const FALLBACK_COLOURS = [
@@ -59,6 +60,7 @@ export default function Avatar({
className?: string;
}) {
// if the username matches the authed user, use their avatarURL and name (avoid stale data)
const selectedOrganisation = useSelectedOrganisation();
const { user } = useSession();
const avatarURL = !strong && username && user && username === user.username ? user.avatarURL : _avatarURL;
const name = !strong && username && user && username === user.username ? user.name : _name;
@@ -73,14 +75,15 @@ export default function Avatar({
"flex items-center justify-center rounded-full",
"text-white font-medium select-none",
name && "border",
!avatarURL && backgroundClass,
(!avatarURL || !selectedOrganisation?.Organisation.features.userAvatars) && backgroundClass,
"transition-colors",
`w-${size || 6}`,
`h-${size || 6}`,
className,
)}
>
{avatarURL ? (
{selectedOrganisation?.Organisation.features.userAvatars && avatarURL ? (
<img
src={avatarURL}
alt="Avatar"

View File

@@ -18,7 +18,7 @@ import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input";
import { SelectTrigger } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useDeleteIssue, useUpdateIssue } from "@/lib/query/hooks";
import { useDeleteIssue, useSelectedOrganisation, useUpdateIssue } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn, issueID } from "@/lib/utils";
@@ -51,6 +51,7 @@ export function IssueDetails({
showHeader?: boolean;
}) {
const { user } = useSession();
const organisation = useSelectedOrganisation();
const updateIssue = useUpdateIssue();
const deleteIssue = useDeleteIssue();
@@ -320,21 +321,23 @@ export function IssueDetails({
<div className="flex flex-col w-full p-2 py-2 gap-2 max-h-[75vh] overflow-y-scroll">
<div className="flex gap-2">
<StatusSelect
statuses={statuses}
value={status}
onChange={handleStatusChange}
trigger={({ isOpen, value }) => (
<SelectTrigger
className="group w-auto flex items-center"
variant="unstyled"
chevronClassName="hidden"
isOpen={isOpen}
>
<StatusTag status={value} colour={statuses[value]} className="hover:opacity-85" />
</SelectTrigger>
)}
/>
{organisation?.Organisation.features.issueStatus && (
<StatusSelect
statuses={statuses}
value={status}
onChange={handleStatusChange}
trigger={({ isOpen, value }) => (
<SelectTrigger
className="group w-auto flex items-center"
variant="unstyled"
chevronClassName="hidden"
isOpen={isOpen}
>
<StatusTag status={value} colour={statuses[value]} className="hover:opacity-85" />
</SelectTrigger>
)}
/>
)}
<div className="flex w-full items-center min-w-0">
<Input
value={title}
@@ -357,59 +360,67 @@ export function IssueDetails({
/>
</div>
</div>
{description || isEditingDescription ? (
<Textarea
ref={descriptionRef}
value={description}
onChange={(event) => setDescription(event.target.value)}
onBlur={handleDescriptionSave}
onKeyDown={(event) => {
if (event.key === "Escape" || (event.ctrlKey && event.key === "Enter")) {
setDescription(originalDescription);
if (originalDescription === "") {
setIsEditingDescription(false);
{organisation?.Organisation.features.description &&
(description || isEditingDescription ? (
<Textarea
ref={descriptionRef}
value={description}
onChange={(event) => setDescription(event.target.value)}
onBlur={handleDescriptionSave}
onKeyDown={(event) => {
if (event.key === "Escape" || (event.ctrlKey && event.key === "Enter")) {
setDescription(originalDescription);
if (originalDescription === "") {
setIsEditingDescription(false);
}
event.currentTarget.blur();
}
event.currentTarget.blur();
}
}}
placeholder="Add a description..."
disabled={isSavingDescription}
className="text-sm border-input/50 hover:border-input focus:border-input resize-none !bg-background min-h-fit"
/>
) : (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground justify-start px-2"
onClick={() => {
setIsEditingDescription(true);
setTimeout(() => descriptionRef.current?.focus(), 0);
}}
>
Add description
</Button>
}}
placeholder="Add a description..."
disabled={isSavingDescription}
className="text-sm border-input/50 hover:border-input focus:border-input resize-none !bg-background min-h-fit"
/>
) : (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground justify-start px-2"
onClick={() => {
setIsEditingDescription(true);
setTimeout(() => descriptionRef.current?.focus(), 0);
}}
>
Add description
</Button>
))}
{organisation?.Organisation.features.sprints && (
<div className="flex items-center gap-2">
<span className="text-sm">Sprint:</span>
<SprintSelect sprints={sprints} value={sprintId} onChange={handleSprintChange} />
</div>
)}
<div className="flex items-center gap-2">
<span className="text-sm">Sprint:</span>
<SprintSelect sprints={sprints} value={sprintId} onChange={handleSprintChange} />
</div>
<div className="flex items-start gap-2">
<span className="text-sm pt-2">Assignees:</span>
<MultiAssigneeSelect
users={members}
assigneeIds={assigneeIds}
onChange={handleAssigneeChange}
fallbackUsers={issueData.Assignees}
/>
</div>
{organisation?.Organisation.features.issueAssignees && (
<div className="flex items-start gap-2">
<span className="text-sm pt-2">Assignees:</span>
<MultiAssigneeSelect
users={members}
assigneeIds={assigneeIds}
onChange={handleAssigneeChange}
fallbackUsers={issueData.Assignees}
/>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-sm">Created by:</span>
<SmallUserDisplay user={issueData.Creator} className={"text-sm"} />
</div>
{organisation?.Organisation.features.issueCreator && (
<div className="flex items-center gap-2">
<span className="text-sm">Created by:</span>
<SmallUserDisplay user={issueData.Creator} className={"text-sm"} />
</div>
)}
{isAssignee && (
{organisation?.Organisation.features.issueTimeTracking && isAssignee && (
<div className={cn("flex flex-col gap-2", hasMultipleAssignees && "cursor-not-allowed")}>
<div className="flex items-center gap-2">
<TimerModal issueId={issueData.Issue.id} disabled={hasMultipleAssignees} />
@@ -423,7 +434,9 @@ export function IssueDetails({
</div>
)}
<IssueComments issueId={issueData.Issue.id} className="pt-2" />
{organisation?.Organisation.features.issueComments && (
<IssueComments issueId={issueData.Issue.id} className="pt-2" />
)}
<ConfirmDialog
open={deleteOpen}

View File

@@ -90,9 +90,10 @@ export function IssuesTable({
onClick={handleLinkClick}
className="flex items-center gap-2 min-w-0 w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
>
{(columns.status == null || columns.status === true) && (
<StatusTag status={issueData.Issue.status} colour={statuses[issueData.Issue.status]} />
)}
{selectedOrganisation?.Organisation.features.issueStatus &&
(columns.status == null || columns.status === true) && (
<StatusTag status={issueData.Issue.status} colour={statuses[issueData.Issue.status]} />
)}
<span className="truncate">{issueData.Issue.title}</span>
</a>
</TableCell>
@@ -115,25 +116,27 @@ export function IssuesTable({
onClick={handleLinkClick}
className="flex items-center justify-end w-full h-full px-2"
>
{issueData.Assignees && issueData.Assignees.length > 0 && (
<div className="flex items-center -space-x-2 pr-1.5">
{issueData.Assignees.slice(0, 3).map((assignee) => (
<Avatar
key={assignee.id}
name={assignee.name}
username={assignee.username}
avatarURL={assignee.avatarURL}
textClass="text-xs"
className="ring-1 ring-background"
/>
))}
{issueData.Assignees.length > 3 && (
<span className="flex items-center justify-center w-6 h-6 text-[10px] font-medium bg-muted text-muted-foreground rounded-full ring-1 ring-background">
+{issueData.Assignees.length - 3}
</span>
)}
</div>
)}
{selectedOrganisation?.Organisation.features.issueAssigneesShownInTable &&
issueData.Assignees &&
issueData.Assignees.length > 0 && (
<div className="flex items-center -space-x-2 pr-1.5">
{issueData.Assignees.slice(0, 3).map((assignee) => (
<Avatar
key={assignee.id}
name={assignee.name}
username={assignee.username}
avatarURL={assignee.avatarURL}
textClass="text-xs"
className="ring-1 ring-background"
/>
))}
{issueData.Assignees.length > 3 && (
<span className="flex items-center justify-center w-6 h-6 text-[10px] font-medium bg-muted text-muted-foreground rounded-full ring-1 ring-background">
+{issueData.Assignees.length - 3}
</span>
)}
</div>
)}
</a>
</TableCell>
)}

View File

@@ -929,8 +929,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
<h2 className="text-xl font-600 mb-2">Features</h2>
<div className="flex flex-col gap-2 w-full">
{Object.keys(DEFAULT_FEATURES).map((feature) => (
<div key={feature}>
{unCamelCase(feature)}:{" "}
<div key={feature} className="flex items-center gap-2 p-1">
<Switch
checked={Boolean(selectedOrganisation?.Organisation.features[feature])}
onCheckedChange={async (checked) => {
@@ -949,7 +948,9 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
);
await invalidateOrganisations();
}}
color={"#ff0000"}
/>
<span className={"text-sm"}>{unCamelCase(feature)}</span>
</div>
))}
</div>

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import Account from "@/components/account";
import { IssueForm } from "@/components/issue-form";
@@ -38,6 +38,11 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole
[organisationsData, selectedOrganisationId],
);
useEffect(() => {
if (selectedOrganisation?.Organisation.features.sprints === false && activeView === "timeline") {
navigate("/issues");
}
}, [selectedOrganisation, activeView, navigate]);
return (
<div className="flex gap-12 items-center justify-between">
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
@@ -55,7 +60,7 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole
/>
{selectedOrganisationId && <ProjectSelect showLabel />}
{selectedOrganisationId && (
{selectedOrganisation?.Organisation.features.sprints && selectedOrganisationId && (
<Tabs
value={activeView}
onValueChange={(value) => {

View File

@@ -29,14 +29,6 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<Route path="/login" element={<Login />} />
{/* authed routes */}
<Route
path="/app"
element={
<RequireAuth>
<Navigate to="/issues" replace />
</RequireAuth>
}
/>
<Route
path="/issues"
element={