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

View File

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

View File

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

View File

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

View File

@@ -929,8 +929,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
<h2 className="text-xl font-600 mb-2">Features</h2> <h2 className="text-xl font-600 mb-2">Features</h2>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{Object.keys(DEFAULT_FEATURES).map((feature) => ( {Object.keys(DEFAULT_FEATURES).map((feature) => (
<div key={feature}> <div key={feature} className="flex items-center gap-2 p-1">
{unCamelCase(feature)}:{" "}
<Switch <Switch
checked={Boolean(selectedOrganisation?.Organisation.features[feature])} checked={Boolean(selectedOrganisation?.Organisation.features[feature])}
onCheckedChange={async (checked) => { onCheckedChange={async (checked) => {
@@ -949,7 +948,9 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
); );
await invalidateOrganisations(); await invalidateOrganisations();
}} }}
color={"#ff0000"}
/> />
<span className={"text-sm"}>{unCamelCase(feature)}</span>
</div> </div>
))} ))}
</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 { useLocation, useNavigate } from "react-router-dom";
import Account from "@/components/account"; import Account from "@/components/account";
import { IssueForm } from "@/components/issue-form"; import { IssueForm } from "@/components/issue-form";
@@ -38,6 +38,11 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole
[organisationsData, selectedOrganisationId], [organisationsData, selectedOrganisationId],
); );
useEffect(() => {
if (selectedOrganisation?.Organisation.features.sprints === false && activeView === "timeline") {
navigate("/issues");
}
}, [selectedOrganisation, activeView, navigate]);
return ( return (
<div className="flex gap-12 items-center justify-between"> <div className="flex gap-12 items-center justify-between">
<div className={`flex gap-${BREATHING_ROOM} items-center`}> <div className={`flex gap-${BREATHING_ROOM} items-center`}>
@@ -55,7 +60,7 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole
/> />
{selectedOrganisationId && <ProjectSelect showLabel />} {selectedOrganisationId && <ProjectSelect showLabel />}
{selectedOrganisationId && ( {selectedOrganisation?.Organisation.features.sprints && selectedOrganisationId && (
<Tabs <Tabs
value={activeView} value={activeView}
onValueChange={(value) => { onValueChange={(value) => {

View File

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

View File

@@ -12,12 +12,9 @@
# LOW PRIORITY # LOW PRIORITY
- disable self-hosting stuff
- make closed source
- dedicated /register route (currently login/register are combined on /login) - dedicated /register route (currently login/register are combined on /login)
- real logo - real logo
- org settings - org settings
- disable individual features
- manage issue types, default is [bug, feature] - manage issue types, default is [bug, feature]
- create, edit, delete - create, edit, delete
- assign icons to issue types (ensure each available icon is in EACH icon set) - assign icons to issue types (ensure each available icon is in EACH icon set)