mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
implemented organisation features
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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={
|
||||
|
||||
3
todo.md
3
todo.md
@@ -12,12 +12,9 @@
|
||||
|
||||
# LOW PRIORITY
|
||||
|
||||
- disable self-hosting stuff
|
||||
- make closed source
|
||||
- dedicated /register route (currently login/register are combined on /login)
|
||||
- real logo
|
||||
- org settings
|
||||
- disable individual features
|
||||
- manage issue types, default is [bug, feature]
|
||||
- create, edit, delete
|
||||
- assign icons to issue types (ensure each available icon is in EACH icon set)
|
||||
|
||||
Reference in New Issue
Block a user