@andrestobelem/clone 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -23
- package/app.js +645 -0
- package/bin/clone.js +11 -230
- package/dist/actors.js +49 -0
- package/dist/actors.js.map +1 -0
- package/dist/automations.js +57 -0
- package/dist/automations.js.map +1 -0
- package/dist/cli.js +565 -0
- package/dist/cli.js.map +1 -0
- package/dist/database.js +62 -0
- package/dist/database.js.map +1 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/issues.js +264 -0
- package/dist/issues.js.map +1 -0
- package/dist/paths.js +42 -0
- package/dist/paths.js.map +1 -0
- package/dist/projects.js +134 -0
- package/dist/projects.js.map +1 -0
- package/dist/recurring.js +45 -0
- package/dist/recurring.js.map +1 -0
- package/dist/schema.js +52 -0
- package/dist/schema.js.map +1 -0
- package/dist/seed.js +498 -0
- package/dist/seed.js.map +1 -0
- package/dist/server.js +232 -0
- package/dist/server.js.map +1 -0
- package/dist/sync.js +625 -0
- package/dist/sync.js.map +1 -0
- package/dist/teams.js +77 -0
- package/dist/teams.js.map +1 -0
- package/dist/time.js +4 -0
- package/dist/time.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.js +254 -0
- package/dist/validation.js.map +1 -0
- package/dist/workspace-helpers.js +75 -0
- package/dist/workspace-helpers.js.map +1 -0
- package/dist/workspace-mutations.js +160 -0
- package/dist/workspace-mutations.js.map +1 -0
- package/dist/workspace-reads.js +62 -0
- package/dist/workspace-reads.js.map +1 -0
- package/dist/workspace.js +8 -0
- package/dist/workspace.js.map +1 -0
- package/index.html +73 -0
- package/package.json +33 -4
- package/styles.css +108 -0
package/app.js
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
const $ = (selector, root = document) => root.querySelector(selector);
|
|
2
|
+
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
|
3
|
+
const content = $("#content-area"), modalLayer = $("#modal-layer"), commandLayer = $("#command-layer");
|
|
4
|
+
const PRIORITIES = ["Urgent", "High", "Medium", "Low", "No priority"];
|
|
5
|
+
const GROUPS = ["Status", "Assignee", "Project", "Priority", "Cycle", "Label", "Team"];
|
|
6
|
+
const SORTS = ["Manual", "Title", "Status", "Priority", "Assignee", "Estimate", "Updated", "Created", "Due date", "Link count", "Time in status"];
|
|
7
|
+
const ISSUE_PAGES = ["Issues", "My issues"];
|
|
8
|
+
const STATUS_CLASS = {"In Progress":"progress", "In Review":"review", Todo:"todo", Backlog:"backlog", Done:"done", Canceled:"canceled", Duplicate:"canceled"};
|
|
9
|
+
let data = null, currentPage = "Issues", currentView = "All issues", selectedIssue = null, selectedProject = null;
|
|
10
|
+
let selectedNotification = null, selectedNotifications = new Set(), inboxUnreadOnly = false, filters = {status:"All", priority:"All", assignee:"All", project:"All", label:"All", creator:"All", relations:"All", dueDate:"All", projectStatus:"All", subscribers:"All", externalLink:"All"};
|
|
11
|
+
let display = {layout:"List", groupBy:"Status", orderBy:"Updated", direction:"Descending", showCompleted:true, completedAtBottom:false, showSubissues:true, showEmptyGroups:false};
|
|
12
|
+
let searchTimer = null, searchRequest = 0, modalFocus = null, searchFocus = null;
|
|
13
|
+
let popoverNode = null, searchScope = "All", viewId = null, issueParentId = null, activeTeamId = 1, importPreview = null;
|
|
14
|
+
let settingsSection = "preferences";
|
|
15
|
+
|
|
16
|
+
function esc(value) { return String(value == null ? "" : value).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
|
17
|
+
function priorityName(v) { return typeof v === "number" ? PRIORITIES[v] || "No priority" : v || "No priority"; }
|
|
18
|
+
function priorityIndex(v) { return PRIORITIES.indexOf(priorityName(v)); }
|
|
19
|
+
function api(path, options = {}) {
|
|
20
|
+
const opts = Object.assign({headers:{"Content-Type":"application/json"}}, options);
|
|
21
|
+
if (opts.body && typeof opts.body !== "string") opts.body = JSON.stringify(opts.body);
|
|
22
|
+
return fetch(path, opts).then(async response => {
|
|
23
|
+
const body = await response.json().catch(() => ({}));
|
|
24
|
+
if (!response.ok) throw new Error(body.error || "Something went wrong");
|
|
25
|
+
return body;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function normalizeData() {
|
|
29
|
+
if (!data.teams.some(t => t.id === activeTeamId)) activeTeamId = data.teams[0]?.id || null;
|
|
30
|
+
data.settings.preferences = Object.assign({defaultHome:"Issues",displayNames:"Full name",firstDayOfWeek:"Monday",commentSubmit:"Enter",theme:"Light",fontSize:"Default",spelling:true,autoAssignToSelf:false,sidebar:{Inbox:true,"My issues":true,Projects:true,Views:true}}, data.settings.preferences || {});
|
|
31
|
+
data.settings.integrations = Array.isArray(data.settings.integrations) ? data.settings.integrations : [];
|
|
32
|
+
}
|
|
33
|
+
async function refresh() { data = await api("/api/bootstrap"); normalizeData(); updateShell(); renderPage(); }
|
|
34
|
+
function toast(message) { const node = $("#toast"); node.textContent = message; node.classList.add("show"); clearTimeout(toast.timer); toast.timer = setTimeout(() => node.classList.remove("show"), 2600); }
|
|
35
|
+
function report(error) { console.error(error); toast(error.message || "Could not save changes"); }
|
|
36
|
+
async function save(method, path, body) { const result = await api(path, {method, body}); await refresh(); return result; }
|
|
37
|
+
function priorityMark(v) { return ["⇈","↑","↗","↓","–"][priorityIndex(v)] || "–"; }
|
|
38
|
+
function statusClass(v) { return STATUS_CLASS[v] || "backlog"; }
|
|
39
|
+
function actorLabel(actor) { return actor ? actor.name + (actor.kind === "agent" ? " (Agent)" : "") : ""; }
|
|
40
|
+
function actorOptions(current = []) {
|
|
41
|
+
const selected = (Array.isArray(current) ? current : [current]).map(Number);
|
|
42
|
+
return data.actors.filter(a=>a.active || selected.includes(a.id)).map(a=>({value:a.id,label:actorLabel(a)}));
|
|
43
|
+
}
|
|
44
|
+
function avatar(name, color) {
|
|
45
|
+
const person = data.actors.find(m => actorLabel(m) === name || m.name === name) || {};
|
|
46
|
+
const initials = String(person.name || name || "?").split(/\s+/).slice(0,2).map(part => part[0] || "").join("").toUpperCase();
|
|
47
|
+
return '<span class="mini-avatar" style="background:' + esc(color || person.avatar_color || "#aaa") + '">' + esc(initials) + '</span>';
|
|
48
|
+
}
|
|
49
|
+
function team(id = activeTeamId) { return data.teams.find(t => t.id === Number(id)) || data.teams[0] || {name:"Workspace",icon:"✳",members:[],resources:[]}; }
|
|
50
|
+
function isClosed(issue) { return ["Completed","Canceled","Duplicate"].includes(issue.status_category); }
|
|
51
|
+
function statuses(teamId = activeTeamId) { return data.statuses.filter(s => s.team_id === Number(teamId)).sort((a,b) => a.position-b.position); }
|
|
52
|
+
function projectOptionsForTeam(teamId) { return data.projects.filter(p => p.teams.some(t => t.id === Number(teamId))).map(p => ({value:p.id,label:p.name})); }
|
|
53
|
+
function refreshIssueProjectOptions(form, preferredId) {
|
|
54
|
+
const projectSelect=$("select[name='projectId']",form),teamSelect=$("select[name='teamId']",form);
|
|
55
|
+
if(!projectSelect||!teamSelect)return false;
|
|
56
|
+
const selected=preferredId===undefined?projectSelect.value:preferredId;
|
|
57
|
+
const projects=projectOptionsForTeam(teamSelect.value),isAllowed=projects.some(p=>Number(p.value)===Number(selected));
|
|
58
|
+
projectSelect.innerHTML=options([{value:"",label:"No project"},...projects],isAllowed?selected:"");
|
|
59
|
+
return Boolean(selected)&&!isAllowed;
|
|
60
|
+
}
|
|
61
|
+
function options(list, current, label = "") { return (label ? '<option value="">' + esc(label) + '</option>' : "") + list.map(x => { const value=x.value==null?x:x.value, chosen=Array.isArray(current)?current.map(String).includes(String(value)):String(value)===String(current==null?"":current);return '<option value="'+esc(value)+'" '+(chosen?'selected':'')+'>'+esc(x.label==null?x:x.label)+'</option>'; }).join(""); }
|
|
62
|
+
function select(name, list, current, label = "", attrs = "") { return '<select name="' + name + '" ' + attrs + '>' + options(list,current,label) + '</select>'; }
|
|
63
|
+
function input(name, value = "", placeholder = "", type = "text", attrs = "") { return '<input name="' + name + '" type="' + type + '" value="' + esc(value) + '" placeholder="' + esc(placeholder) + '" ' + attrs + '>'; }
|
|
64
|
+
function field(label, control, wide = false) { return '<label class="form-field' + (wide ? ' wide' : '') + '"><span>' + esc(label) + '</span>' + control + '</label>'; }
|
|
65
|
+
function pageTitle(page) { return ({"Home":"Product overview","Issues":"Issues","My issues":"My issues","Team projects":"Projects","Team views":"Views","Triage":"Triage","Archive":"Archive"})[page] || page; }
|
|
66
|
+
function updateShell() {
|
|
67
|
+
$("#sidebar").classList.toggle("collapsed",localStorage.getItem("clone-sidebar-collapsed")==="true");
|
|
68
|
+
document.body.classList.toggle("dark-theme", (data.settings.preferences || {}).theme === "Dark");
|
|
69
|
+
document.documentElement.style.fontSize=({Small:"12px",Default:"13px",Large:"15px"})[(data.settings.preferences||{}).fontSize]||"13px";
|
|
70
|
+
const sidebarPrefs=(data.settings.preferences||{}).sidebar||{};
|
|
71
|
+
for(const [name,visible] of Object.entries(sidebarPrefs)) $$(".nav-item[data-page]").filter(el=>el.dataset.page===name).forEach(el=>el.classList.toggle("preference-hidden",!visible));
|
|
72
|
+
$(".workspace-name").textContent = (data.workspace.name || "Workspace").toLowerCase();
|
|
73
|
+
$(".workspace-avatar").textContent = data.workspace.icon || "A";
|
|
74
|
+
const teamNode=$(".team-name");if(teamNode){$(".team-emoji",teamNode).textContent=team().icon;const name=$$("span",teamNode)[1];if(name)name.textContent=team().name;}
|
|
75
|
+
$(".user-meta b").textContent = actorLabel(data.actors.find(m => m.id === data.currentActorId)) || "Member";
|
|
76
|
+
$("#page-title").textContent = viewId ? currentView : pageTitle(currentPage);
|
|
77
|
+
$("#tab-title").textContent = viewId ? currentView : pageTitle(currentPage);
|
|
78
|
+
$("#breadcrumb").textContent = currentPage.startsWith("Team") || ["Home","Issues","Members","Cycles"].includes(currentPage) ? team().name + " / " + pageTitle(currentPage) : "Workspace / " + pageTitle(currentPage);
|
|
79
|
+
$$(".nav-item[data-page]").forEach(node => node.classList.toggle("active", node.dataset.page === currentPage));
|
|
80
|
+
const showToolbar = ISSUE_PAGES.includes(currentPage) || ["Projects","Team projects","Views","Team views","Members","Cycles"].includes(currentPage);
|
|
81
|
+
$("#view-toolbar").classList.toggle("hidden", !showToolbar);
|
|
82
|
+
$("#new-issue-header").classList.toggle("hidden", !ISSUE_PAGES.includes(currentPage));
|
|
83
|
+
$("#new-project").classList.toggle("hidden", !["Projects","Team projects"].includes(currentPage));
|
|
84
|
+
$("#share-view").classList.toggle("hidden", !["Issues","Projects","Team projects","Views","Team views"].includes(currentPage));
|
|
85
|
+
$(".nav-count").textContent = data.notifications.filter(n => !n.is_read).length || "";
|
|
86
|
+
}
|
|
87
|
+
function setPage(page) {
|
|
88
|
+
currentPage = page; selectedIssue = null; selectedProject = null; selectedNotification = null;
|
|
89
|
+
const baseView = page === "My issues" ? "Assigned" : ["Projects","Team projects"].includes(page) ? "All projects" : ["Views","Team views"].includes(page) ? "Issues" : "All issues";
|
|
90
|
+
currentView = baseView; viewId = null;
|
|
91
|
+
localStorage.setItem("clone-page",page);
|
|
92
|
+
history.replaceState(null,"","#" + encodeURIComponent(page.toLowerCase().replaceAll(" ","-")));
|
|
93
|
+
updateShell(); renderPage();
|
|
94
|
+
}
|
|
95
|
+
function showSettings(section = "preferences") {
|
|
96
|
+
settingsSection = section;
|
|
97
|
+
if (currentPage === "Settings") renderSettings();
|
|
98
|
+
else setPage("Settings");
|
|
99
|
+
}
|
|
100
|
+
function setViewTabs() {
|
|
101
|
+
const tabs = $("#view-tabs");
|
|
102
|
+
let names = ISSUE_PAGES.includes(currentPage) ? (currentPage === "My issues" ? ["Assigned","Created","Subscribed","Activity"] : ["All issues","Active","Backlog"]) : ["All projects"];
|
|
103
|
+
if (["Views","Team views"].includes(currentPage)) names = ["Issues","Projects"];
|
|
104
|
+
if (viewId) names = [currentView];
|
|
105
|
+
tabs.innerHTML = names.map(name => '<button class="view-pill ' + (name === currentView ? "selected" : "") + '" data-view="' + esc(name) + '">' + esc(name) + '</button>').join("") + (viewId?'<button class="saved-view" data-action="update-view" title="Save current view settings">✓</button>':'') + '<button class="saved-view" data-action="create-view" title="Create saved view">+</button>';
|
|
106
|
+
tabs.classList.toggle("hidden", ["Inbox","Triage","Archive","Home","Settings","Team overview","Members","Cycles"].includes(currentPage));
|
|
107
|
+
}
|
|
108
|
+
function renderPage() {
|
|
109
|
+
if (!data) return;
|
|
110
|
+
setViewTabs();
|
|
111
|
+
if (!data.teams.length && !["Settings","Inbox","Archive","Projects","Views"].includes(currentPage)) {
|
|
112
|
+
content.innerHTML = emptyBlock("No teams yet", "Create a team before you add issues or cycles.", "new-team", "Create team");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (selectedIssue) return renderIssueDetail();
|
|
116
|
+
if (selectedProject) return renderProjectDetail();
|
|
117
|
+
if (ISSUE_PAGES.includes(currentPage)) return renderIssues();
|
|
118
|
+
if (["Projects","Team projects"].includes(currentPage)) return renderProjects();
|
|
119
|
+
if (["Views","Team views"].includes(currentPage)) return renderViews();
|
|
120
|
+
if (currentPage === "Inbox") return renderInbox();
|
|
121
|
+
if (currentPage === "Triage") return renderTriage();
|
|
122
|
+
if (currentPage === "Archive") return renderArchive();
|
|
123
|
+
if (currentPage === "Members") return renderMembers();
|
|
124
|
+
if (currentPage === "Cycles") return renderCycles();
|
|
125
|
+
if (currentPage === "Settings") return renderSettings();
|
|
126
|
+
if (currentPage === "Home" || currentPage === "Team overview") return renderTeamHome();
|
|
127
|
+
return renderTeamHome();
|
|
128
|
+
}
|
|
129
|
+
function groupValue(issue,key) {
|
|
130
|
+
if(key==="Status")return issue.status;
|
|
131
|
+
if(key==="Assignee")return issue.assignee||"Unassigned";
|
|
132
|
+
if(key==="Project")return issue.project||"No project";
|
|
133
|
+
if(key==="Priority")return priorityName(issue.priority);
|
|
134
|
+
if(key==="Cycle")return issue.cycle||"No cycle";
|
|
135
|
+
if(key==="Label")return issue.labels[0]?.name||"No label";
|
|
136
|
+
if(key==="Team")return issue.team_name;
|
|
137
|
+
return "Other";
|
|
138
|
+
}
|
|
139
|
+
function issueGroups(list,key=display.groupBy) {
|
|
140
|
+
let keys=[...new Set(list.map(issue=>groupValue(issue,key)))];
|
|
141
|
+
if(display.showEmptyGroups&&key==="Status")keys=[...new Set([...statuses().map(s=>s.name),...keys])];
|
|
142
|
+
if(key==="Status")keys.sort((a,b)=>statuses().findIndex(x=>x.name===a)-statuses().findIndex(x=>x.name===b));
|
|
143
|
+
if(key==="Priority")keys.sort((a,b)=>priorityIndex(a)-priorityIndex(b));
|
|
144
|
+
return keys.map(name=>({name,issues:list.filter(issue=>groupValue(issue,key)===name)}));
|
|
145
|
+
}
|
|
146
|
+
function filterIssues() {
|
|
147
|
+
let list = data.issues.filter(i => i.team_id === activeTeamId || currentPage === "My issues" || !["Issues","Team issues"].includes(currentPage));
|
|
148
|
+
if (!display.showSubissues) list = list.filter(i => !i.parent_id);
|
|
149
|
+
if (filters.status !== "All") list = list.filter(i => i.status === filters.status);
|
|
150
|
+
if (filters.priority !== "All") list = list.filter(i => priorityName(i.priority) === filters.priority);
|
|
151
|
+
if (filters.assignee !== "All") list = list.filter(i => filters.assignee === "Unassigned" ? !i.assignee_id : i.assignee === filters.assignee);
|
|
152
|
+
if (filters.project !== "All") list = list.filter(i => filters.project === "No project" ? !i.project_id : String(i.project_id) === String(filters.project));
|
|
153
|
+
if (filters.projectStatus !== "All") list = list.filter(i => data.projects.find(p => p.id === Number(i.project_id))?.status === filters.projectStatus);
|
|
154
|
+
if (filters.label !== "All") list = list.filter(i => filters.label === "No label" ? !i.labels.length : i.labels.some(l => l.name === filters.label));
|
|
155
|
+
if (filters.creator !== "All") list = list.filter(i => filters.creator === "Me" ? i.creator_id===data.currentActorId : String(i.creator_id)===String(filters.creator));
|
|
156
|
+
if (filters.relations === "Linked") list = list.filter(i => i.relations.length>0);
|
|
157
|
+
if (filters.relations === "No links") list = list.filter(i => !i.relations.length);
|
|
158
|
+
if (filters.subscribers === "Me") list = list.filter(i => i.subscribers.some(m => m.id === data.currentActorId));
|
|
159
|
+
if (filters.subscribers === "No subscribers") list = list.filter(i => !i.subscribers.length);
|
|
160
|
+
if (filters.subscribers === "Others") list = list.filter(i => i.subscribers.some(m => m.id !== data.currentActorId));
|
|
161
|
+
if (filters.subscribers === "Has subscribers") list = list.filter(i => i.subscribers.length > 0);
|
|
162
|
+
if (filters.externalLink === "Has link") list = list.filter(i => !!i.external_url);
|
|
163
|
+
if (filters.externalLink === "No link") list = list.filter(i => !i.external_url);
|
|
164
|
+
if (filters.dueDate !== "All") {
|
|
165
|
+
const today=new Date(),todayUtc=Date.UTC(today.getUTCFullYear(),today.getUTCMonth(),today.getUTCDate());
|
|
166
|
+
list=list.filter(i=>{if(filters.dueDate==="No date")return !i.due_date;if(!i.due_date)return false;const due=new Date(i.due_date+"T00:00:00Z").getTime(),delta=Math.round((due-todayUtc)/86400000);return filters.dueDate==="Overdue"?delta<0:delta>=0&&delta<=7;});
|
|
167
|
+
}
|
|
168
|
+
if (currentPage === "My issues") {
|
|
169
|
+
if (currentView === "Assigned") list = list.filter(i => i.assignee_id === data.currentActorId);
|
|
170
|
+
else if (currentView === "Created") list = list.filter(i => i.creator_id === data.currentActorId);
|
|
171
|
+
else if (currentView === "Subscribed") list = list.filter(i => i.subscribers.some(m => m.id === data.currentActorId));
|
|
172
|
+
else if (currentView === "Activity") list = list.filter(i => i.activity.some(a=>a.actor_id===data.currentActorId)||i.comments.some(c=>c.author_id===data.currentActorId));
|
|
173
|
+
}
|
|
174
|
+
if (currentView === "Active") list = list.filter(i => !isClosed(i));
|
|
175
|
+
if (currentView === "Backlog") list = list.filter(i => i.status_category === "Backlog");
|
|
176
|
+
if (!display.showCompleted) list = list.filter(i => !isClosed(i));
|
|
177
|
+
const order = display.orderBy;
|
|
178
|
+
list.sort((a,b) => {
|
|
179
|
+
if(display.completedAtBottom){const aDone=isClosed(a),bDone=isClosed(b);if(aDone!==bDone)return aDone?1:-1;}
|
|
180
|
+
let cmp = 0;
|
|
181
|
+
if (order === "Manual") cmp = a.number-b.number;
|
|
182
|
+
else if (order === "Priority") cmp = priorityIndex(a.priority)-priorityIndex(b.priority);
|
|
183
|
+
else if (order === "Title") cmp = a.title.localeCompare(b.title);
|
|
184
|
+
else if (order === "Status") cmp = a.status.localeCompare(b.status);
|
|
185
|
+
else if (order === "Assignee") cmp = (a.assignee||"").localeCompare(b.assignee||"");
|
|
186
|
+
else if (order === "Created") cmp = a.created_at.localeCompare(b.created_at);
|
|
187
|
+
else if (order === "Due date") cmp = (a.due_date||"9999").localeCompare(b.due_date||"9999");
|
|
188
|
+
else if (order === "Estimate") cmp = (a.estimate||0)-(b.estimate||0);
|
|
189
|
+
else if (order === "Link count") cmp = ((a.relations||[]).length+!!a.external_url)-((b.relations||[]).length+!!b.external_url);
|
|
190
|
+
else if (order === "Time in status") cmp = (a.status_changed_at||a.created_at).localeCompare(b.status_changed_at||b.created_at);
|
|
191
|
+
else cmp = a.updated_at.localeCompare(b.updated_at);
|
|
192
|
+
return display.direction === "Ascending" ? cmp : -cmp;
|
|
193
|
+
});
|
|
194
|
+
return list;
|
|
195
|
+
}
|
|
196
|
+
function renderIssues() {
|
|
197
|
+
const list = filterIssues();
|
|
198
|
+
$("#filter-button").classList.toggle("is-active", Object.values(filters).some(v => v !== "All"));
|
|
199
|
+
const defaultProperties={Status:true,Priority:true,Assignee:true,Project:false,Label:false,"Due date":false,Updated:true},properties=Object.assign({},defaultProperties,display.properties||{});
|
|
200
|
+
$("#display-button").classList.toggle("is-active", display.layout !== "List" || display.groupBy !== "Status" || (display.groupBy2&&display.groupBy2!=="None") || display.orderBy!=="Updated" || display.direction!=="Descending" || !display.showCompleted || display.completedAtBottom || !display.showSubissues || display.showEmptyGroups || Object.keys(defaultProperties).some(k=>properties[k]!==defaultProperties[k]));
|
|
201
|
+
if (!list.length && !(display.showEmptyGroups && display.groupBy === "Status" && statuses().length)) { content.innerHTML = '<div class="empty-state"><div class="empty-illustration"><span>◇</span></div><h2>No issues found</h2><p>Change your filters or create an issue for ' + esc(team().name) + '.</p><button class="primary-button" data-action="new-issue">Create issue</button></div>'; return; }
|
|
202
|
+
const groups = issueGroups(list);
|
|
203
|
+
if (display.layout === "Board") {
|
|
204
|
+
content.innerHTML = '<div class="board-grid">' + groups.map(g => '<section class="board-column" data-drop-group="' + esc(g.name) + '"><header><i class="status-dot ' + statusClass(g.name) + '"></i><b>' + esc(g.name) + '</b><span>' + g.issues.length + '</span></header>' + g.issues.map(issueCard).join("") + '</section>').join("") + '</div>';
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
content.innerHTML = '<div class="issue-list">' + groups.map(g => {
|
|
208
|
+
const secondary=display.groupBy2&&display.groupBy2!=="None"&&display.groupBy2!==display.groupBy?issueGroups(g.issues,display.groupBy2):null;
|
|
209
|
+
const rows=secondary?secondary.map(sub=>'<section class="secondary-issue-group"><button class="group-head secondary-head" data-action="toggle-group"><span class="group-chevron">⌄</span><i class="status-dot '+statusClass(sub.name)+'"></i><span>'+esc(sub.name)+'</span><span class="group-count">'+sub.issues.length+'</span></button><div class="group-rows">'+sub.issues.map(issueRow).join("")+'</div></section>').join(""):g.issues.map(issueRow).join("");
|
|
210
|
+
return '<section class="issue-group"><button class="group-head" data-action="toggle-group"><span class="group-chevron">⌄</span><i class="status-dot ' + statusClass(g.name) + '"></i><span>' + esc(g.name) + '</span><span class="group-count">' + g.issues.length + '</span></button><div class="group-rows">' + rows + '</div></section>';
|
|
211
|
+
}).join("") + '</div>';
|
|
212
|
+
}
|
|
213
|
+
function issueRow(i) {
|
|
214
|
+
const props=Object.assign({Status:true,Priority:true,Assignee:true,Project:false,Label:false,"Due date":false,Updated:true},display.properties||{}),cells=[];
|
|
215
|
+
if(props.Status)cells.push('<span class="issue-status"><i class="status-dot '+statusClass(i.status)+'"></i>'+esc(i.status)+'</span>');
|
|
216
|
+
if(props.Priority)cells.push('<span class="priority-chip"><i class="priority-mark p'+(priorityIndex(i.priority)+1)+'">'+priorityMark(i.priority)+'</i>'+esc(priorityName(i.priority))+'</span>');
|
|
217
|
+
if(props.Assignee)cells.push('<span class="assignee-chip">'+avatar(i.assignee,i.assignee_color)+esc((i.assignee||"Unassigned").split(" ")[0]+(i.assignee_kind==="agent"?" (Agent)":""))+'</span>');
|
|
218
|
+
if(props.Project)cells.push('<span class="project-chip">'+esc(i.project||"No project")+'</span>');
|
|
219
|
+
if(props.Label)cells.push('<span class="project-chip">'+esc(i.labels.map(l=>l.name).join(", ")||"No label")+'</span>');
|
|
220
|
+
if(props["Due date"])cells.push('<span class="updated-cell">'+esc(i.due_date||"No due date")+'</span>');
|
|
221
|
+
if(props.Updated)cells.push('<span class="updated-cell">'+esc(new Date(i.updated_at).toLocaleDateString())+'</span>');
|
|
222
|
+
const cols=['70px','minmax(140px,1fr)',...cells.map(()=> 'minmax(72px,auto)')].join(" ");
|
|
223
|
+
return '<div class="issue-row" style="--issue-columns:'+cols+'" data-issue="'+esc(i.identifier)+'" tabindex="0" role="button"><span class="issue-id">'+esc(i.identifier)+'</span><span class="issue-title">'+esc(i.title)+'</span>'+cells.join("")+'</div>';
|
|
224
|
+
}
|
|
225
|
+
function issueCard(i) { return '<button class="board-card" data-issue="' + esc(i.identifier) + '" draggable="true"><span class="issue-id">' + esc(i.identifier) + '</span><strong>' + esc(i.title) + '</strong><span class="card-foot"><i class="priority-mark p' + (priorityIndex(i.priority)+1) + '">' + priorityMark(i.priority) + '</i><span>' + esc(i.project||"") + '</span>' + avatar(i.assignee,i.assignee_color) + '</span></button>'; }
|
|
226
|
+
function projectCard(p) {
|
|
227
|
+
const due=projectDue(p);
|
|
228
|
+
return '<button class="project-card" data-project="' + p.id + '"><div class="project-card-top"><span class="project-icon">' + esc(p.icon) + '</span><span class="project-status">◉ ' + esc(p.status) + '</span></div><h3>' + esc(p.name) + '</h3><p>' + esc(p.summary) + '</p><div class="progress-track"><div class="progress-fill" style="width:' + p.progress + '%"></div></div><div class="project-foot"><span>' + p.completed_count + ' / ' + p.issue_count + ' issues</span><span>' + p.progress + '%</span></div><small class="'+due.className+'">' + esc(due.label) + '</small></button>';
|
|
229
|
+
}
|
|
230
|
+
function projectDue(project) {
|
|
231
|
+
if(!project.target_date)return {label:"No target date",className:"project-due"};
|
|
232
|
+
if(["Completed","Canceled"].includes(project.status))return {label:"Target · "+project.target_date,className:"project-due"};
|
|
233
|
+
const today=new Date(),nowUtc=Date.UTC(today.getUTCFullYear(),today.getUTCMonth(),today.getUTCDate()),target=new Date(project.target_date+"T00:00:00Z").getTime(),days=Math.round((target-nowUtc)/86400000);
|
|
234
|
+
if(days<0)return {label:"Overdue · "+Math.abs(days)+"d",className:"project-due overdue"};
|
|
235
|
+
if(days===0)return {label:"Due today",className:"project-due upcoming"};
|
|
236
|
+
if(days<=14)return {label:"Due in "+days+"d",className:"project-due upcoming"};
|
|
237
|
+
return {label:"Target · "+project.target_date,className:"project-due"};
|
|
238
|
+
}
|
|
239
|
+
function filterProjects() {
|
|
240
|
+
let list = data.projects.slice();
|
|
241
|
+
if (currentPage === "Team projects") list = list.filter(p => p.teams.some(t => t.id === activeTeamId));
|
|
242
|
+
if (filters.status !== "All") list = list.filter(p => p.status === filters.status);
|
|
243
|
+
if (filters.priority !== "All") list = list.filter(p => p.priority_name === filters.priority);
|
|
244
|
+
if (filters.assignee !== "All") list = list.filter(p => filters.assignee === "Unassigned" ? !p.lead_id : actorLabel(p.lead) === filters.assignee);
|
|
245
|
+
if (filters.label !== "All") list = list.filter(p => filters.label === "No label" ? !p.labels.length : p.labels.some(l=>l.name===filters.label));
|
|
246
|
+
list.sort((a,b)=>{
|
|
247
|
+
let cmp=0;
|
|
248
|
+
if (["Title","Name"].includes(display.orderBy)) cmp=a.name.localeCompare(b.name);
|
|
249
|
+
else if (display.orderBy === "Priority") cmp=priorityIndex(a.priority_name)-priorityIndex(b.priority_name);
|
|
250
|
+
else if (["Target date","Due date"].includes(display.orderBy)) cmp=(a.target_date||"9999").localeCompare(b.target_date||"9999");
|
|
251
|
+
else if (display.orderBy === "Status") cmp=a.status.localeCompare(b.status);
|
|
252
|
+
else if (display.orderBy === "Assignee") cmp=actorLabel(a.lead).localeCompare(actorLabel(b.lead));
|
|
253
|
+
else if (display.orderBy === "Created") cmp=a.created_at.localeCompare(b.created_at);
|
|
254
|
+
else if (display.orderBy === "Updated") cmp=a.updated_at.localeCompare(b.updated_at);
|
|
255
|
+
else cmp=a.id-b.id;
|
|
256
|
+
return display.direction === "Ascending" ? cmp : -cmp;
|
|
257
|
+
});
|
|
258
|
+
return list;
|
|
259
|
+
}
|
|
260
|
+
function renderProjects() {
|
|
261
|
+
const list = filterProjects();
|
|
262
|
+
if (display.layout === "Board") {
|
|
263
|
+
const names = [...new Set(list.map(p => p.status))];
|
|
264
|
+
content.innerHTML = '<div class="board-grid">' + names.map(n => '<section class="board-column"><header><i class="status-dot todo"></i><b>' + esc(n) + '</b><span>' + list.filter(p=>p.status===n).length + '</span></header>' + list.filter(p=>p.status===n).map(projectCard).join("") + '</section>').join("") + '</div>';
|
|
265
|
+
} else content.innerHTML = list.length ? '<div class="project-grid">' + list.map(projectCard).join("") + '</div>' : emptyBlock("No projects yet","Create a project to coordinate work across teams.","new-project","Create project");
|
|
266
|
+
}
|
|
267
|
+
function renderIssueDetail() {
|
|
268
|
+
const i = data.issues.find(x => x.identifier === selectedIssue);
|
|
269
|
+
if (!i) { selectedIssue = null; return renderPage(); }
|
|
270
|
+
content.innerHTML = '<div class="detail-page"><div class="detail-top"><button class="quiet-button" data-action="back">← Issues</button><span class="issue-id">' + esc(i.identifier) + '</span><div class="spacer"></div><button class="quiet-button" data-action="archive-issue">Archive</button><button class="quiet-button" aria-label="Issue actions" data-action="issue-menu">···</button></div><input class="detail-title" aria-label="Issue title" data-issue-field="title" value="' + esc(i.title) + '"/><textarea class="detail-description" aria-label="Issue description" data-issue-field="description" placeholder="Add a description...">' + esc(i.description) + '</textarea><div class="property-grid">' + propertySelect("Status", "status", statuses(i.team_id).map(s=>s.name),i.status) + propertySelect("Priority","priority",PRIORITIES,priorityName(i.priority)) + propertySelect("Assignee","assigneeId",[{value:"",label:"Unassigned"},...actorOptions(i.assignee_id)],i.assignee_id) + '<div class="property-control"><span>Team</span><strong>'+esc(i.team_name)+'</strong></div>' + propertySelect("Project","projectId",[{value:"",label:"No project"},...projectOptionsForTeam(i.team_id)],i.project_id) + propertySelect("Cycle","cycleId",[{value:"",label:"No cycle"},...data.cycles.filter(c=>c.team_id===i.team_id).map(c=>({value:c.id,label:c.name}))],i.cycle_id) + propertySelect("Estimate","estimate",["","1","2","3","5","8","13"],i.estimate||"") + propertyInput("Due date","dueDate",i.due_date||"","date") + '</div><section class="detail-section"><h3>Labels</h3><div class="chips">' + i.labels.map(l=>'<span class="label-chip" style="--label-color:' + esc(l.color) + '">' + esc(l.name) + '</span>').join("") + '<button class="tiny-button" data-action="issue-labels">+ Add label</button></div></section>' + '<section class="detail-section"><div class="section-heading"><h3>Attachments</h3><button class="tiny-button" data-action="add-attachments">+ Attach</button><input id="issue-file-picker" class="hidden" type="file" multiple></div>' + i.attachments.map(a=>'<a class="attachment-row" href="/api/attachments/'+a.id+'" download>'+esc(a.name)+' <small>'+Math.ceil(a.size/1024)+' KB</small></a>').join("") + '</section>' + detailRelated("Relations",i.relations.map(r=>'<div class="relation-line">' + esc(r.relation_type) + ' · ' + esc(r.related_identifier||r.url) + ' ' + esc(r.related_title||"") + '</div>').join(""),"add-relation") + detailRelated("Sub-issues",i.subissues.map(s=>'<button class="link-row" data-issue-id="' + s.id + '">' + esc(s.identifier) + ' · ' + esc(s.title) + '</button>').join(""),"add-subissue") + '<section class="detail-section"><h3>Activity</h3><form id="comment-form"><textarea name="body" aria-label="Comment" placeholder="Leave a comment…" required></textarea><button class="primary-button">Comment</button></form>' + i.comments.map(c=>'<div class="activity-item">' + avatar(c.author,c.avatar_color) + '<div><b>' + esc(c.author) + '</b><p>' + esc(c.body) + '</p><small>' + esc(new Date(c.created_at).toLocaleString()) + '</small></div></div>').join("") + i.activity.map(a=>'<div class="activity-item"><span class="activity-dot"></span><div><b>' + esc(a.actor||"Workspace") + '</b><p>' + esc(a.action.replaceAll("_"," ")) + '</p><small>' + esc(new Date(a.created_at).toLocaleString()) + '</small></div></div>').join("") + '</section><footer class="detail-actions"><button class="tiny-button" data-action="add-relation">Link issue</button><button class="tiny-button" data-action="add-subissue">Add sub-issue</button><button class="tiny-button" data-action="subscribe">' + (i.subscribers.some(m=>m.id===data.currentActorId)?"Unsubscribe":"Subscribe") + '</button></footer></div>';
|
|
271
|
+
}
|
|
272
|
+
function propertySelect(label,key,list,value) { return '<label class="property-control"><span>' + esc(label) + '</span>' + select(key,list,value) + '</label>'; }
|
|
273
|
+
function propertyInput(label,key,value,type) { return '<label class="property-control"><span>' + esc(label) + '</span><input data-issue-field="' + key + '" type="' + type + '" value="' + esc(value) + '"></label>'; }
|
|
274
|
+
function detailRelated(title,rows,action) { return '<section class="detail-section"><div class="section-heading"><h3>' + title + '</h3><button class="tiny-button" data-action="' + action + '" aria-label="Add ' + esc(title) + '">+</button></div>' + (rows || '<small class="muted">Nothing linked yet</small>') + '</section>'; }
|
|
275
|
+
function renderProjectDetail() {
|
|
276
|
+
const p = data.projects.find(x => x.id === Number(selectedProject));
|
|
277
|
+
if (!p) { selectedProject = null; return renderPage(); }
|
|
278
|
+
const progress = p.progress || 0;
|
|
279
|
+
content.innerHTML = '<div class="detail-page project-detail"><div class="detail-top"><button class="quiet-button" data-action="back-projects">← Projects</button><span class="issue-id">Project</span><div class="spacer"></div><button class="quiet-button" data-action="edit-project">Edit project</button><button class="quiet-button" data-action="archive-project">Archive</button></div><div class="project-heading"><span class="project-icon large">' + esc(p.icon) + '</span><div><h2>' + esc(p.name) + '</h2><p>' + esc(p.summary) + '</p></div></div><div class="property-grid">' + propertySelect("Status","projectStatus",["Backlog","Planned","In Progress","Completed","Canceled"],p.status) + propertySelect("Priority","projectPriority",PRIORITIES,p.priority_name) + '<div class="property-control"><span>Lead</span><strong>' + esc(actorLabel(p.lead)||"Unassigned") + '</strong></div><div class="property-control"><span>Teams</span><strong>' + esc(p.teams.map(t=>t.name).join(", ")) + '</strong></div><div class="property-control"><span>Participants</span><strong>' + esc(p.members.map(actorLabel).join(", ")) + '</strong></div><div class="property-control"><span>Start date</span><strong>' + esc(p.start_date||"Not set") + '</strong></div><div class="property-control"><span>Target date</span><strong>' + esc(p.target_date||"Not set") + '</strong></div></div><section class="detail-section"><div class="section-heading"><h3>Progress</h3><span>' + p.completed_count + ' of ' + p.issue_count + ' complete · ' + progress + '%</span></div><div class="progress-track"><div class="progress-fill" style="width:' + progress + '%"></div></div></section><section class="detail-section"><div class="section-heading"><h3>Milestones</h3><button class="tiny-button" data-action="add-milestone">+ Add milestone</button></div>' + (p.milestones.length ? p.milestones.map(m=>'<div class="milestone-row"><i class="status-dot ' + statusClass(m.status) + '"></i><b>' + esc(m.name) + '</b><span>' + esc(m.due_date||"No date") + '</span><span>' + esc(m.status) + '</span></div>').join("") : '<small class="muted">No milestones yet</small>') + '</section><section class="detail-section"><div class="section-heading"><h3>Issues</h3><button class="tiny-button" data-action="add-project-issue">+ Add issue</button></div>' + (p.issues.length ? p.issues.map(x=>'<button class="link-row" data-issue="' + esc(x.identifier) + '"><span class="issue-id">' + esc(x.identifier) + '</span>' + esc(x.title) + '<span class="row-end">' + esc(x.status) + '</span></button>').join("") : '<small class="muted">No linked issues yet</small>') + '</section><section class="detail-section"><div class="section-heading"><h3>Dependencies</h3><button class="tiny-button" data-action="add-dependency">+ Add</button></div>' + (p.dependencies.map(d=>'<div class="relation-line">Depends on · ' + esc(d.name) + '</div>').join("")||'<small class="muted">No dependencies</small>') + '</section><section class="detail-section"><h3>Description</h3><textarea class="detail-description" aria-label="Project description" data-project-field="description" placeholder="Add project description…">' + esc(p.description) + '</textarea></section><section class="detail-section"><div class="section-heading"><h3>Updates</h3><button class="tiny-button" data-action="add-project-update">+ Post update</button></div>' + (p.updates.length?p.updates.map(u=>'<article class="project-update"><span class="status-pill">'+esc(u.health)+'</span><p>'+esc(u.body)+'</p><small>'+esc(u.author||"")+' · '+esc(new Date(u.created_at).toLocaleString())+'</small></article>').join(""):'<small class="muted">No updates yet</small>') + '</section><section class="detail-section"><h3>Activity</h3>' + (p.activity||[]).map(a=>'<div class="activity-item"><span class="activity-dot"></span><div><b>'+esc(a.actor||"Workspace")+'</b><p>'+esc(a.action.replaceAll("_"," "))+'</p><small>'+esc(new Date(a.created_at).toLocaleString())+'</small></div></div>').join("") + '</section></div>';
|
|
280
|
+
}
|
|
281
|
+
function emptyBlock(title,description,action,label) { return '<div class="empty-state"><div class="empty-illustration"><span>◇</span></div><h2>' + esc(title) + '</h2><p>' + esc(description) + '</p><button class="primary-button" data-action="' + action + '">' + esc(label) + '</button></div>'; }
|
|
282
|
+
function renderViews() {
|
|
283
|
+
const entity = currentView === "Projects" ? "projects" : "issues";
|
|
284
|
+
const list = data.views.filter(v => v.entity === entity && (currentPage !== "Team views" || (v.team_id === activeTeamId && v.scope === "Team")));
|
|
285
|
+
content.innerHTML = list.length ? '<div class="view-grid">' + list.map(v=>'<article class="view-card"><button class="view-open" data-saved-view="' + v.id + '"><span class="project-icon">' + esc(v.icon) + '</span><h3>' + esc(v.name) + '</h3><p>' + esc(v.description) + '</p><small>' + esc(v.scope) + ' · ' + (v.entity === "issues" ? "Issues" : "Projects") + '</small></button><div><button class="tiny-button" data-action="favorite-view" aria-label="Toggle favorite view" data-id="' + v.id + '">' + (v.is_favorite?"★":"☆") + '</button><button class="tiny-button" data-action="edit-view" data-id="' + v.id + '" aria-label="Edit view">✎</button><button class="tiny-button" data-action="delete-view" aria-label="Delete view" data-id="' + v.id + '">···</button></div></article>').join("") + '</div>' : emptyBlock("No saved views","Save your filters and display settings as a view.","create-view","Create a view");
|
|
286
|
+
}
|
|
287
|
+
function renderInbox() {
|
|
288
|
+
const rows = data.notifications;
|
|
289
|
+
const visible = rows.filter(n=>!inboxUnreadOnly||!n.is_read);
|
|
290
|
+
const selected = visible.find(n=>n.id===Number(selectedNotification)) || visible[0];
|
|
291
|
+
selectedNotification = selected ? selected.id : null;
|
|
292
|
+
selectedNotifications = new Set([...selectedNotifications].filter(id=>rows.some(n=>n.id===id)));
|
|
293
|
+
const selectedCount=selectedNotifications.size,visibleRows=rows.filter(n=>!inboxUnreadOnly||!n.is_read),allVisibleSelected=visibleRows.length&&visibleRows.every(n=>selectedNotifications.has(n.id));
|
|
294
|
+
content.innerHTML = '<div class="inbox-layout"><section class="inbox-list"><div class="inbox-toolbar"><label><input id="unread-only" type="checkbox" '+(inboxUnreadOnly?'checked':'')+'> Unread only</label><label class="select-all-label"><input id="select-all-notifications" type="checkbox" '+(allVisibleSelected?'checked':'')+'> Select all</label><button class="tiny-button" data-action="read-all">Mark all read</button><button class="tiny-button" data-action="read-selected" '+(!selectedCount?'disabled':'')+'>Mark selected read</button><button class="tiny-button" data-action="archive-selected" '+(!selectedCount?'disabled':'')+'>Archive selected'+(selectedCount?' ('+selectedCount+')':'')+'</button></div>' + (rows.length ? rows.map(n=>'<div class="notification-row ' + (!n.is_read?'unread ':'') + (selected&&selected.id===n.id?'selected':'') + (inboxUnreadOnly&&n.is_read?' filtered-out':'') + '" data-notification="' + n.id + '" role="button" tabindex="0"><input type="checkbox" aria-label="Select notification" data-notification-select="' + n.id + '" '+(selectedNotifications.has(n.id)?'checked':'')+'><i class="status-dot ' + (!n.is_read?'progress':'') + '"></i><span><b>' + esc(n.title) + '</b><small>' + esc(n.body) + '</small></span><time>' + esc(new Date(n.created_at).toLocaleDateString()) + '</time></div>').join("") : '<div class="empty-inline">You are all caught up.</div>') + '</section><aside class="inbox-detail">' + (selected ? '<div class="detail-top"><span class="muted">Notification</span><div class="spacer"></div><button class="tiny-button" data-action="notification-read" data-id="' + selected.id + '">' + (selected.is_read?'Mark unread':'Mark read') + '</button><button class="tiny-button" data-action="notification-archive" data-id="' + selected.id + '">Archive</button></div><h2>' + esc(selected.title) + '</h2><p>' + esc(selected.body) + '</p><small>' + esc(new Date(selected.created_at).toLocaleString()) + '</small>' + (selected.entity_type==='issue'?'<button class="primary-button" data-issue-id="' + selected.entity_id + '">Open issue</button>':selected.entity_type==='project'?'<button class="primary-button" data-project="' + selected.entity_id + '">Open project</button>':'') : '<p class="muted">Select a notification to read it.</p>') + '</aside></div>';
|
|
295
|
+
}
|
|
296
|
+
function renderTriage() {
|
|
297
|
+
const incoming=data.issues.filter(i=>i.team_id===activeTeamId&&!i.assignee_id&&!isClosed(i));
|
|
298
|
+
content.innerHTML='<div class="content-heading"><div><h2>Triage</h2><p>Incoming unassigned issues for '+esc(team().name)+'. Assign one to yourself to move it into Todo.</p></div><button class="primary-button" data-action="new-issue">+ New issue</button></div>'+(incoming.length?'<div class="simple-list">'+incoming.map(i=>'<article class="triage-row"><span class="issue-id">'+esc(i.identifier)+'</span><button class="triage-title" data-issue="'+esc(i.identifier)+'">'+esc(i.title)+'</button><span class="status-pill">'+esc(i.status)+'</span><i class="priority-mark p'+(priorityIndex(i.priority)+1)+'">'+priorityMark(i.priority)+'</i><button class="tiny-button" data-action="triage-accept" data-issue-id="'+esc(i.identifier)+'">Assign to me</button></article>').join('')+'</div>':emptyBlock("Triage is clear","No unassigned active issues need review.","new-issue","Create issue"));
|
|
299
|
+
}
|
|
300
|
+
function renderArchive() {
|
|
301
|
+
const issues=data.archives.issues,projects=data.archives.projects;
|
|
302
|
+
content.innerHTML='<div class="content-heading"><div><h2>Archive</h2><p>Restore issues and projects when you need them again.</p></div></div><section class="detail-section"><h3>Issues</h3>'+(issues.length?issues.map(i=>'<div class="archive-row"><span class="issue-id">'+esc(i.identifier)+'</span><span>'+esc(i.title)+'</span><small>'+esc(i.status)+'</small><button class="tiny-button" data-action="restore-issue" data-identifier="'+esc(i.identifier)+'">Restore</button></div>').join(''):'<small class="muted">No archived issues</small>')+'</section><section class="detail-section"><h3>Projects</h3>'+(projects.length?projects.map(p=>'<div class="archive-row"><span>'+esc(p.name)+'</span><small>'+esc(p.status)+'</small><button class="tiny-button" data-action="restore-project" data-id="'+p.id+'">Restore</button></div>').join(''):'<small class="muted">No archived projects</small>')+'</section>';
|
|
303
|
+
}
|
|
304
|
+
function renderMembers() {
|
|
305
|
+
const members = team().members.filter(m=>m.kind==="member");
|
|
306
|
+
content.innerHTML = '<div class="content-heading"><div><h2>' + esc(team().name) + ' members</h2><p>' + members.length + ' people in this team</p></div></div><div class="member-list">' + members.map(m=>'<div class="member-row">'+avatar(m.name,m.avatar_color)+'<span><b>'+esc(m.name)+'</b><small>'+esc(m.email)+'</small></span><span class="row-end">'+esc(m.role)+'</span></div>').join("") + '</div>';
|
|
307
|
+
}
|
|
308
|
+
function renderCycles() {
|
|
309
|
+
const cycles = data.cycles.filter(c=>c.team_id===activeTeamId);
|
|
310
|
+
content.innerHTML = '<div class="content-heading"><div><h2>Cycles</h2><p>Time-box planning and track the team’s work.</p></div><button class="primary-button" data-action="new-cycle">Create cycle</button></div>' + (cycles.length?'<div class="cycle-list">'+cycles.map(c=>'<article class="cycle-card"><div><span class="status-pill">'+esc(c.status)+'</span><h3>'+esc(c.name)+'</h3><p>'+esc(new Date(c.starts_at).toLocaleDateString())+' – '+esc(new Date(c.ends_at).toLocaleDateString())+'</p></div><div class="cycle-capacity">Capacity <b>'+esc(c.estimate_points)+'</b> / '+esc(c.capacity)+' pts</div><div class="progress-track"><div class="progress-fill" style="width:'+c.progress+'%"></div></div><small class="muted">'+c.completed_count+' of '+c.issue_count+' issues complete · '+c.progress+'%</small></article>').join("")+'</div>':emptyBlock("No cycles","Create cycles to plan work in short, focused periods.","new-cycle","Create cycle"));
|
|
311
|
+
}
|
|
312
|
+
function renderTeamResources(resources) {
|
|
313
|
+
const links = resources.filter(r => r.url && /^https?:\/\//i.test(r.url));
|
|
314
|
+
const sections = [...new Set(links.map(r => r.section || "Resources"))];
|
|
315
|
+
return sections.map(section => '<div class="resource-section"><h4>' + esc(section) + '</h4>' + links.filter(r => (r.section || "Resources") === section).map(r => '<div class="resource-row"><a class="resource-title" href="' + esc(r.url) + '" target="_blank" rel="noopener noreferrer">' + esc(r.title) + '</a><button class="tiny-button" data-action="delete-resource" data-id="' + r.id + '" aria-label="Remove ' + esc(r.title) + '">×</button></div>').join('') + '</div>').join('') || '<small class="muted">No team resources yet</small>';
|
|
316
|
+
}
|
|
317
|
+
function renderTeamHome() {
|
|
318
|
+
const t = team();
|
|
319
|
+
const active = data.issues.filter(i=>i.team_id===t.id&&!isClosed(i));
|
|
320
|
+
const projects = data.projects.filter(p=>p.teams.some(x=>x.id===t.id));
|
|
321
|
+
content.innerHTML = '<div class="team-home"><div class="team-hero"><div class="team-hero-icon">'+esc(t.icon)+'</div><div><h2>'+esc(t.name)+'</h2><p>'+esc(t.description)+'</p></div><div class="spacer"></div><button class="tiny-button" data-action="edit-team">Edit team</button><button class="tiny-button" data-action="team-settings">Team settings</button></div><div class="team-shortcuts"><button data-page-go="Issues">Issues <b>'+active.length+'</b></button><button data-page-go="Team projects">Projects <b>'+projects.length+'</b></button><button data-page-go="Members">Members <b>'+t.members.filter(m=>m.kind==="member").length+'</b></button></div><div class="home-columns"><section class="detail-section"><div class="section-heading"><h3>Active issues</h3><button class="tiny-button" data-page-go="Issues">View issues</button></div>'+active.slice(0,5).map(i=>'<button class="link-row" data-issue="'+esc(i.identifier)+'"><span class="issue-id">'+esc(i.identifier)+'</span>'+esc(i.title)+'<span class="row-end">'+esc(i.status)+'</span></button>').join("")+'</section><section class="detail-section"><div class="section-heading"><h3>Projects</h3><button class="tiny-button" data-page-go="Team projects">View all</button></div>'+projects.slice(0,4).map(p=>'<button class="link-row" data-project="'+p.id+'">'+esc(p.name)+'<span class="row-end">'+p.progress+'%</span></button>').join("")+'</section><section class="detail-section"><div class="section-heading"><h3>Resources</h3><button class="tiny-button" data-action="new-resource">+ Add</button></div>'+renderTeamResources(t.resources)+'</section></div></div>';
|
|
322
|
+
}
|
|
323
|
+
function renderSettings() {
|
|
324
|
+
const prefs = data.settings.preferences || {}, ws = data.settings.workspace || {};
|
|
325
|
+
const rules = data.recurring, automations = data.automations;
|
|
326
|
+
const sections = [
|
|
327
|
+
["preferences", "Preferences"],
|
|
328
|
+
["workspace", "Workspace"],
|
|
329
|
+
["issues", "Issues & workflow"],
|
|
330
|
+
["integrations", "Integrations"],
|
|
331
|
+
["data", "Import & export"],
|
|
332
|
+
];
|
|
333
|
+
const preferences = '<h2>Preferences</h2><p>Personal defaults for this local workspace.</p><form id="preferences-form" class="settings-form">'+field("Default home view",select("defaultHome",["Issues","Projects","My issues","Inbox","Home"],prefs.defaultHome))+field("Display names",select("displayNames",["Full name","First name","Username"],prefs.displayNames))+field("First day of week",select("firstDayOfWeek",["Monday","Sunday","Saturday"],prefs.firstDayOfWeek))+field("Comment submit",select("commentSubmit",["Enter","⌘ Enter"],prefs.commentSubmit))+field("Theme",select("theme",["Light","Dark","System"],prefs.theme))+field("Font size",select("fontSize",["Small","Default","Large"],prefs.fontSize))+field("Automatically assign new issues to me",'<input type="checkbox" name="autoAssignToSelf" '+(prefs.autoAssignToSelf?'checked':'')+'>')+field("Spelling suggestions",'<input type="checkbox" name="spelling" '+(prefs.spelling?'checked':'')+'>')+field("Show Inbox",'<input type="checkbox" name="showInbox" '+(prefs.sidebar?.Inbox?'checked':'')+'>')+field("Show My issues",'<input type="checkbox" name="showMyIssues" '+(prefs.sidebar?.["My issues"]?'checked':'')+'>')+field("Show Projects",'<input type="checkbox" name="showProjects" '+(prefs.sidebar?.Projects?'checked':'')+'>')+field("Show Views",'<input type="checkbox" name="showViews" '+(prefs.sidebar?.Views?'checked':'')+'>')+'<button class="primary-button">Save preferences</button></form>';
|
|
334
|
+
const workspace = '<h2>Workspace</h2><p>Name this workspace and set its local defaults.</p><form id="workspace-form" class="settings-form">'+field("Workspace name",input("name",ws.name||data.workspace.name))+field("Timezone",input("timezone",ws.timezone||"America/Argentina/Buenos_Aires"))+field("Issue identifier",input("issueIdentifier",ws.issueIdentifier||"PRO"))+'<button class="primary-button">Save workspace settings</button></form>';
|
|
335
|
+
const issues = '<h2>Issues & workflow</h2><p>Configure workflow statuses, labels, recurring issues, and automations for '+esc(team().name)+'.</p><section class="detail-section"><h3>Workflow statuses</h3><div class="chips">'+statuses().map(s=>'<span class="label-chip">'+esc(s.name)+' · '+esc(s.category)+'</span>').join("")+'<button class="tiny-button" data-action="new-status">+ Add status</button></div><button class="tiny-button" data-action="new-label">+ Add label</button></section><section class="detail-section"><div class="section-heading"><h3>Recurring issues</h3><button class="tiny-button" data-action="new-recurring">+ Add</button></div>'+rules.map(r=>'<div class="simple-row"><div><b>'+esc(r.title)+'</b><small>'+esc(r.cadence)+' · next '+esc(r.next_run)+'</small></div><span class="row-end">'+(r.enabled?'On':'Off')+'</span></div>').join("")+'</section><section class="detail-section"><div class="section-heading"><h3>Automations</h3><button class="tiny-button" data-action="new-automation">+ Add</button></div>'+automations.map(a=>'<div class="simple-row"><div><b>'+esc(a.name)+'</b><small>Team '+esc(team(a.team_id).name)+'</small></div><span class="row-end">'+(a.enabled?'On':'Off')+'</span></div>').join("")+'</section>';
|
|
336
|
+
const integrations = '<h2>Integrations</h2><p>Review services that can connect to this local workspace.</p><section class="detail-section settings-list">'+(data.settings.integrations.length?data.settings.integrations.map(i=>'<div class="simple-row"><div><b>'+esc(i.name)+'</b><small>'+(i.connected?'Connected':'Not connected')+'</small></div><button class="tiny-button" data-action="integrations">Configure</button></div>').join(""):'<div class="empty-inline">No integrations are configured.</div>')+'</section>';
|
|
337
|
+
const dataPanel = '<h2>Import & export</h2><p>Move workspace records through JSON files.</p><section class="detail-section settings-actions"><div><h3>Export workspace</h3><p class="muted">Download application data as a JSON file.</p><button class="secondary-button" data-action="export">Export workspace JSON</button></div><div><h3>Import issues</h3><p class="muted">Preview a JSON file before you add issues to '+esc(team().name)+'.</p><button class="secondary-button" data-action="import">Choose JSON file</button></div><input id="import-file" class="hidden" type="file" accept="application/json,.json"></section>';
|
|
338
|
+
const panels = {preferences, workspace, issues, integrations, data:dataPanel};
|
|
339
|
+
if (!panels[settingsSection]) settingsSection = "preferences";
|
|
340
|
+
const nav = sections.map(([key,label])=>'<button class="'+(settingsSection===key?'selected':'')+'" data-action="settings-'+key+'" aria-current="'+(settingsSection===key?'page':'false')+'">'+label+'</button>').join("");
|
|
341
|
+
content.innerHTML = '<div class="settings-layout"><aside class="settings-nav"><b>Settings</b>'+nav+'</aside><div class="settings-content" data-settings-panel="'+settingsSection+'">'+panels[settingsSection]+'</div></div>';
|
|
342
|
+
}
|
|
343
|
+
function openModal(title, formId, body, submitLabel = "Save") {
|
|
344
|
+
closePopover();
|
|
345
|
+
closeSearch();
|
|
346
|
+
modalFocus = document.activeElement;
|
|
347
|
+
modalLayer.classList.remove("hidden");
|
|
348
|
+
setBackgroundInert(true);
|
|
349
|
+
modalLayer.innerHTML='<section class="issue-modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title"><div class="modal-top"><strong id="modal-title">'+esc(title)+'</strong><span class="spacer"></span><button class="modal-close" aria-label="Close">×</button></div><form id="'+esc(formId)+'" class="issue-form"><div class="modal-fields">'+body+'</div><div class="modal-bottom"><span class="spacer"></span><button type="button" class="secondary-button modal-cancel">Cancel</button><button class="primary-button" type="submit">'+esc(submitLabel)+'</button></div></form></section>';
|
|
350
|
+
$(".modal-close",modalLayer).onclick=closeModal; $(".modal-cancel",modalLayer).onclick=closeModal;
|
|
351
|
+
$("input:not([type=hidden]),textarea,select",modalLayer)?.focus();
|
|
352
|
+
}
|
|
353
|
+
function setBackgroundInert(value) {
|
|
354
|
+
$(".workspace-shell").inert = value;
|
|
355
|
+
$(".window-bar").inert = value;
|
|
356
|
+
}
|
|
357
|
+
function closeModal() {
|
|
358
|
+
const wasOpen = !modalLayer.classList.contains("hidden");
|
|
359
|
+
modalLayer.classList.add("hidden"); modalLayer.innerHTML="";
|
|
360
|
+
if (wasOpen) { setBackgroundInert(false); modalFocus?.focus(); modalFocus = null; }
|
|
361
|
+
}
|
|
362
|
+
function formObject(form) {
|
|
363
|
+
const fd=new FormData(form), out={};
|
|
364
|
+
$$("select[multiple][name]",form).forEach(select=>out[select.name.replace(/\[\]$/, "")]=[]);
|
|
365
|
+
for(const [key,value] of fd.entries()) { if(key.endsWith("[]")){const name=key.slice(0,-2);out[name]??=[];out[name].push(value);} else out[key]=value; }
|
|
366
|
+
$$('input[type="checkbox"]',form).forEach(box=>out[box.name]=box.checked);
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
function openIssueModal(extra = {}) {
|
|
370
|
+
if (!data.teams.length) return openSimpleModal("team");
|
|
371
|
+
issueParentId=extra.parentId||null;
|
|
372
|
+
const project=extra.projectId?data.projects.find(p=>p.id===Number(extra.projectId)):null;
|
|
373
|
+
const projectTeamIds=project?.teams.map(t=>t.id)||[];
|
|
374
|
+
const availableTeams=projectTeamIds.length?data.teams.filter(t=>projectTeamIds.includes(t.id)):data.teams;
|
|
375
|
+
const requestedTeam=Number(extra.teamId||activeTeamId);
|
|
376
|
+
const teamId=availableTeams.some(t=>t.id===requestedTeam)?requestedTeam:(availableTeams[0]?.id||activeTeamId);
|
|
377
|
+
const projectId=projectTeamIds.includes(teamId)?project.id:"";
|
|
378
|
+
const defaultStatus=statuses(teamId).find(s=>s.is_default)?.name||"Backlog";
|
|
379
|
+
const body=field("Title",input("title","","Issue title","text","required maxlength="+"160"),true)+field("Description",'<textarea name="description" placeholder="Add a description…"></textarea>',true)+field("Attachments",'<input type="file" multiple data-issue-attachments><small class="muted">Up to 3 MB total</small>',true)+field("Team",select("teamId",availableTeams.map(t=>({value:t.id,label:t.name})),teamId))+field("Status",select("status",statuses(teamId).map(s=>s.name),defaultStatus))+field("Priority",select("priority",PRIORITIES,"No priority"))+field("Assignee",select("assigneeId",[{value:"",label:"Unassigned"},...actorOptions()],(data.settings.preferences||{}).autoAssignToSelf?data.currentActorId:""))+field("Project",select("projectId",[{value:"",label:"No project"},...projectOptionsForTeam(teamId)],projectId))+field("Labels",select("labelIds[]",data.labels.filter(l=>!l.team_id||l.team_id===teamId).map(l=>({value:l.id,label:l.name})),"","Choose labels","multiple"))+field("Cycle",select("cycleId",[{value:"",label:"No cycle"},...data.cycles.filter(c=>c.team_id===teamId).map(c=>({value:c.id,label:c.name}))],""))+field("Estimate",select("estimate",["","1","2","3","5","8","13"],""))+field("Due date",input("dueDate","","","date"))+field("External link",input("externalUrl","","https://…"),true);
|
|
380
|
+
openModal(issueParentId?"New sub-issue":"New issue","issue-form",body,"Create issue");
|
|
381
|
+
}
|
|
382
|
+
function openProjectModal(project = null) {
|
|
383
|
+
if (!data.teams.length) return openSimpleModal("team");
|
|
384
|
+
const body=field("Name",input("name",project?.name||"","Project name","text","required"),true)+field("Summary",input("summary",project?.summary||"","One sentence about the project"),true)+field("Icon",select("icon",["◈","◎","✳","⬡","◇","▤"],project?.icon||"◈"))+field("Status",select("status",["Backlog","Planned","In Progress","Completed","Canceled"],project?.status||"Backlog"))+field("Priority",select("priority",PRIORITIES,project?.priority_name||"No priority"))+field("Lead",select("leadId",actorOptions(project?.lead_id),project?.lead_id||data.currentActorId))+field("Teams",select("teamIds[]",data.teams.map(t=>({value:t.id,label:t.name})),project?.teams.map(t=>t.id)||[activeTeamId],"","multiple"))+field("Participants",select("memberIds[]",actorOptions(project?.members.map(m=>m.id)),project?.members.map(m=>m.id)||[data.currentActorId],"","multiple"))+field("Start date",input("startDate",project?.start_date||"","","date"))+field("Target date",input("targetDate",project?.target_date||"","","date"))+field("Labels",select("labelIds[]",data.labels.map(l=>({value:l.id,label:l.name})),project?.labels.map(l=>l.id)||[],"","multiple"))+field("Description",'<textarea name="description" placeholder="What is this project about?">'+esc(project?.description||"")+'</textarea>',true);
|
|
385
|
+
openModal(project?"Edit project":"New project","project-form",body,project?"Save changes":"Create project");
|
|
386
|
+
$("#project-form").dataset.id=project?.id||"";
|
|
387
|
+
}
|
|
388
|
+
function openViewModal(existing = null) {
|
|
389
|
+
const entity = existing?.entity || (["Projects","Team projects"].includes(currentPage) || currentView==="Projects" ? "projects" : "issues");
|
|
390
|
+
const body=field("Name",input("name",existing?.name||"","View name","text","required"),true)+field("Description",input("description",existing?.description||"","Optional description"),true)+field("Icon",select("icon",["◈","◎","✳","⬡","◇","▤"],existing?.icon||"◈"))+field("Scope",select("scope",["Personal","Workspace","Team"],existing?.scope||"Personal"))+field("Favorite",'<input type="checkbox" name="isFavorite" '+(existing?.is_favorite?'checked':'')+'>')+field("Entity",select("entity",[{value:"issues",label:"Issues"},{value:"projects",label:"Projects"}],entity))+field("Team",select("teamId",data.teams.map(t=>({value:t.id,label:t.name})),existing?.team_id||activeTeamId));
|
|
391
|
+
openModal(existing?"Edit saved view":"Create saved view","view-form",body,existing?"Save changes":"Save view");
|
|
392
|
+
if(existing){const form=$("#view-form");form.dataset.id=existing.id;form.dataset.filters=JSON.stringify(existing.filters||{});form.dataset.display=JSON.stringify(existing.display||{});}
|
|
393
|
+
}
|
|
394
|
+
function openSimpleModal(kind) {
|
|
395
|
+
if (kind === "cycle" && !data.teams.length) kind = "team";
|
|
396
|
+
let title="",id="",body="",submit="Create";
|
|
397
|
+
if(kind==="team"){title="Create team";id="team-form";body=field("Name",input("name","","Team name","text","required"))+field("Key",input("key","","PRO"))+field("Description",input("description","","What does this team work on?"),true);}
|
|
398
|
+
if(kind==="cycle"){title="Create cycle";id="cycle-form";body=field("Name",input("name","","Cycle name","text","required"))+field("Team",select("teamId",data.teams.map(t=>({value:t.id,label:t.name})),activeTeamId))+field("Starts",input("startsAt","","","date","required"))+field("Ends",input("endsAt","","","date","required"))+field("Capacity",input("capacity","20","","number"));}
|
|
399
|
+
if(kind==="milestone"){title="Add milestone";id="milestone-form";body=field("Name",input("name","","Milestone name","text","required"))+field("Due date",input("dueDate","","","date"))+field("Status",select("status",["Todo","In Progress","Done"],"Todo"));}
|
|
400
|
+
if(kind==="project-update"){title="Project update";id="project-update-form";body=field("Health",select("health",["On track","At risk","Off track"],"On track"))+field("Update",'<textarea name="body" placeholder="Share progress, risks, or next steps…" required></textarea>',true);}
|
|
401
|
+
if(kind==="dependency"){title="Add dependency";id="dependency-form";body=field("Project",select("dependsOnId",data.projects.filter(p=>p.id!==Number(selectedProject)).map(p=>({value:p.id,label:p.name})),""),true);}
|
|
402
|
+
if(kind==="relation"){title="Link issue";id="relation-form";body=field("Relation",select("type",["relates to","blocks","blocked by","duplicate of","parent of"],"relates to"))+field("Issue",select("relatedIssueId",data.issues.filter(i=>i.identifier!==selectedIssue).map(i=>({value:i.identifier,label:i.identifier+" · "+i.title})),"","Choose an issue"),true)+field("Or external URL",input("url","","https://…","url"),true);}
|
|
403
|
+
if(kind==="milestone") submit="Add milestone";
|
|
404
|
+
if(kind==="project-update") submit="Post update";
|
|
405
|
+
openModal(title,id,body,submit);
|
|
406
|
+
}
|
|
407
|
+
function openLabelsModal() {
|
|
408
|
+
const issue=data.issues.find(i=>i.identifier===selectedIssue); if(!issue)return;
|
|
409
|
+
const body=field("Labels",select("labelIds[]",data.labels.filter(l=>!l.team_id||l.team_id===issue.team_id||issue.labels.some(x=>x.id===l.id)).map(l=>({value:l.id,label:l.name})),issue.labels.map(l=>l.id),"","multiple"),true);
|
|
410
|
+
openModal("Edit labels","labels-form",body,"Save labels");
|
|
411
|
+
}
|
|
412
|
+
function openCreateView() { openViewModal(); }
|
|
413
|
+
function popover(anchor, items, onPick, current) {
|
|
414
|
+
closePopover(); const rect=anchor.getBoundingClientRect(), node=document.createElement("div"); node.className="popover"; node.style.top=Math.min(innerHeight-250,rect.bottom+5)+"px"; node.style.left=Math.max(8,Math.min(rect.left,innerWidth-225))+"px";
|
|
415
|
+
node.innerHTML=items.map(item=>'<button class="popover-item" data-value="'+esc(item)+'">'+esc(item)+(item===current?'<span class="check">✓</span>':'')+'</button>').join(""); document.body.appendChild(node);popoverNode=node;
|
|
416
|
+
$$(".popover-item",node).forEach(b=>b.onclick=async()=>{closePopover();try{await onPick(b.dataset.value);}catch(error){report(error);}});
|
|
417
|
+
}
|
|
418
|
+
function closePopover(){if(popoverNode)popoverNode.remove();popoverNode=null;}
|
|
419
|
+
function openSearch() {
|
|
420
|
+
closePopover(); closeModal(); closeSearch();
|
|
421
|
+
searchFocus = document.activeElement;
|
|
422
|
+
setBackgroundInert(true);
|
|
423
|
+
commandLayer.classList.remove("hidden");commandLayer.innerHTML='<section class="search-modal" role="dialog" aria-modal="true" aria-label="Search workspace"><div class="search-field"><span>⌕</span><input aria-label="Search issues and projects" placeholder="Search issues and projects…" autocomplete="off"><kbd class="shortcut">ESC</kbd></div><div class="search-scopes">'+["All","Issues","Projects"].map(s=>'<button class="scope-chip '+(s===searchScope?'selected':'')+'" data-scope="'+s+'">'+s+'</button>').join("")+'</div><div class="search-results" id="search-results-list"><div class="search-hint">Type to search this workspace</div></div></section>';
|
|
424
|
+
const field=$("input",commandLayer);field.focus();
|
|
425
|
+
field.oninput=()=>{
|
|
426
|
+
clearTimeout(searchTimer);
|
|
427
|
+
const request=++searchRequest, query=field.value, scope=searchScope;
|
|
428
|
+
searchTimer=setTimeout(async()=>{
|
|
429
|
+
try {
|
|
430
|
+
const res=await api("/api/search?q="+encodeURIComponent(query)+"&scope="+scope);
|
|
431
|
+
const box=$("#search-results-list",commandLayer);
|
|
432
|
+
if(request!==searchRequest||!box)return;
|
|
433
|
+
box.innerHTML=res.results.length?res.results.map(r=>'<button class="search-result" data-result-type="'+esc(r.type)+'" data-result-id="'+esc(r.key)+'"><span class="issue-id">'+esc(r.type)+'</span><span>'+esc(r.title)+'</span><small>'+esc(r.status||"")+'</small></button>').join(""):'<div class="search-hint">No matching results</div>';
|
|
434
|
+
} catch(error) { if(request===searchRequest)report(error); }
|
|
435
|
+
},120);
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function closeSearch() {
|
|
439
|
+
clearTimeout(searchTimer); ++searchRequest;
|
|
440
|
+
const wasOpen=!commandLayer.classList.contains("hidden");
|
|
441
|
+
commandLayer.classList.add("hidden");commandLayer.innerHTML="";
|
|
442
|
+
if(wasOpen){setBackgroundInert(false);searchFocus?.focus();searchFocus=null;}
|
|
443
|
+
}
|
|
444
|
+
function getIssueForm(form) {
|
|
445
|
+
const obj=formObject(form);obj.teamId=Number(obj.teamId||activeTeamId);obj.priority=obj.priority||"No priority";obj.assigneeId=obj.assigneeId?Number(obj.assigneeId):null;obj.projectId=obj.projectId?Number(obj.projectId):null;obj.cycleId=obj.cycleId?Number(obj.cycleId):null;obj.estimate=obj.estimate?Number(obj.estimate):null;obj.labelIds=(obj.labelIds||[]).map(Number);if(issueParentId)obj.parentId=Number(issueParentId);return obj;
|
|
446
|
+
}
|
|
447
|
+
async function attachmentPayload(fileList) {
|
|
448
|
+
const files=[...fileList];if(files.reduce((sum,file)=>sum+file.size,0)>3_000_000)throw new Error("Attachments must total 3 MB or less");
|
|
449
|
+
return Promise.all(files.map(async file=>{const bytes=new Uint8Array(await file.arrayBuffer());let binary="";for(let i=0;i<bytes.length;i+=0x8000)binary+=String.fromCharCode(...bytes.subarray(i,i+0x8000));return{name:file.name,type:file.type,data:btoa(binary)};}));
|
|
450
|
+
}
|
|
451
|
+
async function attachFiles(identifier,fileList) { if(!fileList?.length)return;const files=await attachmentPayload(fileList);await api("/api/issues/"+identifier+"/attachments",{method:"POST",body:{files}});await refresh();toast("Attachments added"); }
|
|
452
|
+
const ACTION_HANDLERS = {
|
|
453
|
+
"new-issue": () => openIssueModal({teamId:activeTeamId,projectId:selectedProject}),
|
|
454
|
+
"new-project": () => openProjectModal(),
|
|
455
|
+
"create-view": () => openCreateView(),
|
|
456
|
+
"new-team": () => openSimpleModal("team"),
|
|
457
|
+
"new-cycle": () => openSimpleModal("cycle"),
|
|
458
|
+
"add-milestone": () => openSimpleModal("milestone"),
|
|
459
|
+
"add-project-update": () => openSimpleModal("project-update"),
|
|
460
|
+
"add-dependency": () => openSimpleModal("dependency"),
|
|
461
|
+
"add-relation": () => openSimpleModal("relation"),
|
|
462
|
+
"issue-labels": () => openLabelsModal(),
|
|
463
|
+
"add-subissue": () => openIssueModal({parentId:data.issues.find(i=>i.identifier===selectedIssue)?.id,teamId:data.issues.find(i=>i.identifier===selectedIssue)?.team_id}),
|
|
464
|
+
"add-project-issue": () => openIssueModal({projectId:Number(selectedProject),teamId:data.projects.find(p=>p.id===Number(selectedProject))?.teams[0]?.id||activeTeamId}),
|
|
465
|
+
"back": () => { selectedIssue=null;renderPage(); },
|
|
466
|
+
"back-projects": () => { selectedProject=null;renderPage(); },
|
|
467
|
+
"add-attachments": () => $("#issue-file-picker")?.click(),
|
|
468
|
+
};
|
|
469
|
+
async function action(action, node) {
|
|
470
|
+
const handler=ACTION_HANDLERS[action];
|
|
471
|
+
if(handler)return handler(node);
|
|
472
|
+
const id=Number(node?.dataset.id||0);
|
|
473
|
+
if(action==="update-view") { if(!viewId)return;await api("/api/views/"+viewId,{method:"PATCH",body:{filters,display}});await refresh();toast("Saved view updated");return; }
|
|
474
|
+
if(action==="edit-view") { const view=data.views.find(v=>v.id===id);if(view)openViewModal(view);return; }
|
|
475
|
+
if(action==="new-resource") { const body=field("Title",input("title","","Resource name","text","required"),true)+field("Section",input("section","Resources","Section name"))+field("Link",input("url","","https://…","url","required"),true);openModal("Add team resource","team-resource-form",body,"Add resource");return; }
|
|
476
|
+
if(action==="delete-resource") { if(!confirm("Remove this team resource?"))return;await api("/api/teams/"+activeTeamId+"/resources/"+id,{method:"DELETE",body:{}});return refresh(); }
|
|
477
|
+
if(action==="triage-accept") { const issue=data.issues.find(i=>i.identifier===node.dataset.issueId);if(!issue)return;const next=statuses(issue.team_id).some(s=>s.name==="Todo")?"Todo":issue.status;await api("/api/issues/"+issue.identifier,{method:"PATCH",body:{assigneeId:data.currentActorId,status:next}});await refresh();toast(issue.identifier+" assigned to you");return; }
|
|
478
|
+
if(action==="restore-issue") { await api("/api/issues/"+encodeURIComponent(node.dataset.identifier)+"/restore",{method:"POST",body:{}});await refresh();toast("Issue restored");return; }
|
|
479
|
+
if(action==="restore-project") { await api("/api/projects/"+id+"/restore",{method:"POST",body:{}});await refresh();toast("Project restored");return; }
|
|
480
|
+
if(action==="archive-issue") { if(!confirm("Archive this issue? You can recover it from the archive later."))return;await api("/api/issues/"+selectedIssue,{method:"DELETE",body:{}});selectedIssue=null;await refresh();toast("Issue archived");return; }
|
|
481
|
+
if(action==="archive-project") { if(!confirm("Archive this project?"))return;await api("/api/projects/"+selectedProject,{method:"DELETE",body:{}});selectedProject=null;await refresh();toast("Project archived");return; }
|
|
482
|
+
if(action==="edit-project") return openProjectModal(data.projects.find(p=>p.id===Number(selectedProject)));
|
|
483
|
+
if(action==="subscribe") { const i=data.issues.find(x=>x.identifier===selectedIssue),ids=i.subscribers.map(m=>m.id);const next=ids.includes(data.currentActorId)?ids.filter(x=>x!==data.currentActorId):ids.concat(data.currentActorId);await api("/api/issues/"+selectedIssue,{method:"PATCH",body:{subscriberIds:next}});return refresh(); }
|
|
484
|
+
if(action==="notification-read"||action==="notification-archive") { const verb=action==="notification-archive"?"archive":(data.notifications.find(n=>n.id===id)?.is_read?"unread":"read");await api("/api/notifications/"+id+"/"+verb,{method:"POST",body:{}});await refresh();selectedNotification=id;return renderInbox(); }
|
|
485
|
+
if(action==="read-selected"||action==="archive-selected") { const ids=[...selectedNotifications];if(!ids.length)return;if(action==="archive-selected"&&!confirm("Archive "+ids.length+" selected notifications?"))return;await api("/api/notifications/"+(action==="archive-selected"?"archive-selected":"read-selected"),{method:"POST",body:{ids}});selectedNotifications.clear();if(action==="archive-selected"&&ids.includes(Number(selectedNotification)))selectedNotification=null;await refresh();toast(action==="archive-selected"?ids.length+" notifications archived":ids.length+" notifications marked read");return; }
|
|
486
|
+
if(action==="read-all") { await api("/api/notifications/read-all",{method:"POST",body:{}});return refresh(); }
|
|
487
|
+
if(action==="favorite-view") { const v=data.views.find(x=>x.id===id);await api("/api/views/"+id,{method:"PATCH",body:{isFavorite:!v.is_favorite}});return refresh(); }
|
|
488
|
+
if(action==="delete-view") { if(!confirm("Delete this saved view?"))return;await api("/api/views/"+id,{method:"DELETE",body:{}});return refresh(); }
|
|
489
|
+
if(action==="export") { const out=await api("/api/export");const blob=new Blob([JSON.stringify(out,null,2)],{type:"application/json"}),url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download="workspace-export.json";a.click();URL.revokeObjectURL(url);toast("Workspace export downloaded");return; }
|
|
490
|
+
if(action==="import") { $("#import-file").click();return; }
|
|
491
|
+
if(action==="integrations") { toast("Connectors need their service credentials and are not connected in local mode.");return; }
|
|
492
|
+
if(action.startsWith("settings-")) { showSettings(action.slice(9));return; }
|
|
493
|
+
if(["new-status","new-label","new-recurring","new-automation","new-resource","edit-team"].includes(action)&&!data.teams.length)return openSimpleModal("team");
|
|
494
|
+
if(action==="new-status") { const body=field("Name",input("name","","Status name","text","required"))+field("Category",select("category",["Backlog","Unstarted","Started","Completed","Canceled","Duplicate"],"Unstarted"));openModal("Add workflow status","status-form",body,"Add status");return; }
|
|
495
|
+
if(action==="new-label") { const body=field("Name",input("name","","Label name","text","required"))+field("Color",input("color","#8585cc","","color"));openModal("Add label","label-form",body,"Create label");return; }
|
|
496
|
+
if(action==="new-recurring") { const body=field("Issue title",input("title","","Recurring issue title","text","required"),true)+field("Description",'<textarea name="description"></textarea>',true)+field("Cadence",select("cadence",["Daily","Weekly","Every 2 weeks","Monthly"],"Weekly"))+field("Next run",input("nextRun","","","date","required"));openModal("Recurring issue","recurring-form",body,"Save recurring issue");return; }
|
|
497
|
+
if(action==="new-automation") { const body=field("Name",input("name","","Automation name","text","required"),true)+field("When",select("event",["issue.created","issue.updated","issue.completed"],"issue.created"))+field("Priority condition",select("priority",["Any","Urgent","High","Medium","Low"],"Any"))+field("Label condition",select("label",["Any",...data.labels.map(l=>l.name)],"Any"))+field("Action",select("action",["Assign to me","Add Bug label","Add Feature label","Set status: Todo","Set status: In Progress"],"Assign to me"));openModal("Create automation","automation-form",body,"Create automation");return; }
|
|
498
|
+
if(action==="edit-team") { const t=team();const body=field("Name",input("name",t.name,"Team name","text","required"))+field("Description",input("description",t.description,"","text"),true)+field("Timezone",input("timezone",t.timezone||"America/Argentina/Buenos_Aires"))+field("Participants",select("memberIds[]",actorOptions(t.members.map(m=>m.id)),t.members.map(m=>m.id),"","multiple"));openModal("Edit team","team-edit-form",body,"Save changes");return; }
|
|
499
|
+
if(action==="team-settings") { showSettings("issues");return; }
|
|
500
|
+
if(action==="toggle-group") { node.nextElementSibling?.classList.toggle("hidden");return; }
|
|
501
|
+
if(action==="issue-menu") { popover(node,["Set due date","Make recurring","Add link","Add sub-issue","Archive issue"],async pick=>{if(pick==="Add sub-issue")openIssueModal({parentId:data.issues.find(i=>i.identifier===selectedIssue)?.id,teamId:data.issues.find(i=>i.identifier===selectedIssue)?.team_id});else if(pick==="Add link")openSimpleModal("relation");else if(pick==="Make recurring"){const body=field("Issue title",input("title","","Recurring issue title","text","required"),true)+field("Description",'<textarea name="description"></textarea>',true)+field("Cadence",select("cadence",["Daily","Weekly","Every 2 weeks","Monthly"],"Weekly"))+field("Next run",input("nextRun","","","date","required"));openModal("Recurring issue","recurring-form",body,"Save recurring issue");}else if(pick==="Archive issue"){if(confirm("Archive this issue?")){await api("/api/issues/"+selectedIssue,{method:"DELETE",body:{}});selectedIssue=null;await refresh();}}else toast("Set the due date from the issue properties.");});return; }
|
|
502
|
+
}
|
|
503
|
+
const FORM_HANDLERS = {
|
|
504
|
+
"team-form": async f => { const res=await api("/api/teams",{method:"POST",body:f});closeModal();await refresh();activeTeamId=res.teamId;localStorage.setItem("clone-team",String(activeTeamId));setPage("Home");toast("Team created"); },
|
|
505
|
+
"milestone-form": async f => { await api("/api/projects/"+selectedProject+"/milestones",{method:"POST",body:f});closeModal();await refresh(); },
|
|
506
|
+
"project-update-form": async f => { await api("/api/projects/"+selectedProject+"/updates",{method:"POST",body:f});closeModal();await refresh();toast("Project update posted"); },
|
|
507
|
+
"dependency-form": async f => { f.dependsOnId=Number(f.dependsOnId);await api("/api/projects/"+selectedProject+"/dependencies",{method:"POST",body:f});closeModal();await refresh(); },
|
|
508
|
+
"relation-form": async f => { if(!f.relatedIssueId)delete f.relatedIssueId;await api("/api/issues/"+selectedIssue+"/relations",{method:"POST",body:f});closeModal();await refresh(); },
|
|
509
|
+
"labels-form": async f => { f.labelIds=(f.labelIds||[]).map(Number);await api("/api/issues/"+selectedIssue,{method:"PATCH",body:{labelIds:f.labelIds}});closeModal();await refresh(); },
|
|
510
|
+
"status-form": async f => { f.teamId=activeTeamId;await api("/api/statuses",{method:"POST",body:f});closeModal();await refresh(); },
|
|
511
|
+
"label-form": async f => { f.teamId=activeTeamId;await api("/api/labels",{method:"POST",body:f});closeModal();await refresh(); },
|
|
512
|
+
"recurring-form": async f => { f.teamId=activeTeamId;await api("/api/recurring",{method:"POST",body:f});closeModal();await refresh(); },
|
|
513
|
+
"comment-form": async f => { await api("/api/issues/"+selectedIssue+"/comments",{method:"POST",body:{body:f.body}});await refresh(); },
|
|
514
|
+
};
|
|
515
|
+
async function submitForm(form) {
|
|
516
|
+
const f=formObject(form), id=form.dataset.id;
|
|
517
|
+
if(form.id==="issue-form") {
|
|
518
|
+
const body=getIssueForm(form),files=form.querySelector("[data-issue-attachments]")?.files;const uploads=files?.length?await attachmentPayload(files):[];const result=await api("/api/issues",{method:"POST",body});closeModal();issueParentId=null;let attachmentError=null;if(uploads.length){try{await api("/api/issues/"+result.issue.identifier+"/attachments",{method:"POST",body:{files:uploads}});}catch(error){attachmentError=error;}}await refresh();selectedIssue=result.issue.identifier;renderPage();toast(attachmentError?"Issue created, but attachments could not be added":result.issue.identifier+" created");return;
|
|
519
|
+
}
|
|
520
|
+
if(form.id==="project-form") {
|
|
521
|
+
f.priority=f.priority||"No priority";f.leadId=Number(f.leadId);f.teamIds=(f.teamIds||[]).map(Number);f.memberIds=(f.memberIds||[]).map(Number);f.labelIds=(f.labelIds||[]).map(Number);f.startDate=f.startDate||null;f.targetDate=f.targetDate||null;
|
|
522
|
+
const result=await api(id?"/api/projects/"+id:"/api/projects",{method:id?"PATCH":"POST",body:f});closeModal();await refresh();selectedProject=Number(id||result.projectId);renderPage();toast(id?"Project updated":"Project created");return;
|
|
523
|
+
}
|
|
524
|
+
if(form.id==="view-form") { f.isFavorite=!!f.isFavorite;f.teamId=f.teamId?Number(f.teamId):null;if(id){f.filters=JSON.parse(form.dataset.filters||"{}");f.display=JSON.parse(form.dataset.display||"{}");await api("/api/views/"+id,{method:"PATCH",body:f});closeModal();await refresh();if(viewId===Number(id)){const updated=data.views.find(v=>v.id===Number(id));if(updated)applyView(updated);}toast("View updated");return;}f.filters=filters;f.display=display;const result=await api("/api/views",{method:"POST",body:f});closeModal();await refresh();const v=data.views.find(x=>x.id===result.viewId);if(v)applyView(v);toast("View saved");return; }
|
|
525
|
+
const handler=FORM_HANDLERS[form.id];
|
|
526
|
+
if(handler)return handler(f);
|
|
527
|
+
if(form.id==="team-edit-form") { f.memberIds=(f.memberIds||[]).map(Number); await api("/api/teams/"+activeTeamId,{method:"PATCH",body:f});closeModal();await refresh();return; }
|
|
528
|
+
if(form.id==="team-resource-form") { await api("/api/teams/"+activeTeamId+"/resources",{method:"POST",body:f});closeModal();await refresh();toast("Team resource added");return; }
|
|
529
|
+
if(form.id==="cycle-form") { f.teamId=Number(f.teamId);f.capacity=Number(f.capacity);f.startsAt=new Date(f.startsAt+"T00:00:00Z").toISOString();f.endsAt=new Date(f.endsAt+"T23:59:00Z").toISOString();await api("/api/cycles",{method:"POST",body:f});closeModal();await refresh();toast("Cycle created");return; }
|
|
530
|
+
if(form.id==="automation-form") { f.teamId=activeTeamId;f.trigger={event:f.event};f.condition={priority:f.priority,label:f.label};f.action={name:f.action};delete f.event;delete f.priority;delete f.label;await api("/api/automations",{method:"POST",body:f});closeModal();return refresh(); }
|
|
531
|
+
if(form.id==="preferences-form") { f.sidebar={Inbox:f.showInbox,"My issues":f.showMyIssues,Projects:f.showProjects,Views:f.showViews};for(const key of ["showInbox","showMyIssues","showProjects","showViews"])delete f[key];await api("/api/preferences",{method:"PATCH",body:f});await refresh();if(f.defaultHome&¤tPage!==f.defaultHome){currentPage=f.defaultHome;localStorage.setItem("clone-page",currentPage);updateShell();renderPage();}toast("Preferences saved");return; }
|
|
532
|
+
if(form.id==="workspace-form") { await api("/api/settings",{method:"PATCH",body:{key:"workspace",value:f}});closeModal();await refresh();toast("Workspace settings saved");return; }
|
|
533
|
+
if(form.id==="import-form") { if(!importPreview)return;const rows=importPreview.issues||importPreview;const created=await api("/api/import",{method:"POST",body:{issues:rows.map(row=>Object.assign({teamId:activeTeamId},row))}});closeModal();importPreview=null;await refresh();toast(created.count+" issues imported");return; }
|
|
534
|
+
}
|
|
535
|
+
function applyView(v) { const savedFilters=Object.assign({status:"All",priority:"All",assignee:"All",project:"All",label:"All",creator:"All",relations:"All",dueDate:"All",projectStatus:"All",subscribers:"All",externalLink:"All"},v.filters||{});if(String(savedFilters.status||"").toLowerCase()==="all")savedFilters.status="All";if(String(savedFilters.priority||"").toLowerCase()==="all")savedFilters.priority="All";if(savedFilters.assignee==="Anyone")savedFilters.assignee="All";const savedDisplay=Object.assign({},display,v.display||{});if(v.filters?.completed===false)savedDisplay.showCompleted=false;if(savedDisplay.groupBy==="Agent")savedDisplay.groupBy="Status";if(savedDisplay.orderBy==="Agent")savedDisplay.orderBy="Updated";if(savedDisplay.orderBy)savedDisplay.orderBy=savedDisplay.orderBy==="Last updated"?"Updated":savedDisplay.orderBy;filters=savedFilters;display=savedDisplay;if(v.entity==="projects")setPage("Projects");else {if(v.team_id&&data.teams.some(t=>t.id===v.team_id)){activeTeamId=v.team_id;localStorage.setItem("clone-team",String(activeTeamId));}setPage("Issues");}viewId=v.id;currentView=v.name;history.replaceState(null,"","#view-"+v.id);updateShell();renderPage(); }
|
|
536
|
+
function selectIssue(identifier) { const issue=data.issues.find(i=>i.identifier===identifier)||data.issues.find(i=>i.id===Number(identifier));if(!issue)return;selectedIssue=issue.identifier;renderPage(); }
|
|
537
|
+
function selectProject(id) { if(!data.projects.some(p=>p.id===Number(id)))return;selectedProject=Number(id);selectedIssue=null;renderPage(); }
|
|
538
|
+
function openSavedView(id) { const v=data.views.find(x=>x.id===Number(id));if(v)applyView(v); }
|
|
539
|
+
function openPopoverForFilters(button) {
|
|
540
|
+
const projectPage=["Projects","Team projects"].includes(currentPage),statusOptions=projectPage?["Backlog","Planned","In Progress","Completed","Canceled"]:statuses().map(s=>s.name);
|
|
541
|
+
const issueOnlyValues=projectPage?[]:[...(["All",...new Set(data.projects.map(p=>p.status))].map(s=>"Project status · "+s)),"Relations · All","Relations · Linked","Relations · No links","External link · All","External link · Has link","External link · No link","Subscribers · All","Subscribers · Has subscribers","Subscribers · Me","Subscribers · Others","Subscribers · No subscribers","Due date · All","Due date · Overdue","Due date · Next 7 days","Due date · No date"];
|
|
542
|
+
const values=["Status · All",...statusOptions.map(s=>"Status · "+s),"Priority · All",...PRIORITIES.map(p=>"Priority · "+p),"Assignee · All",...data.actors.map(m=>"Assignee · "+actorLabel(m)),"Assignee · Unassigned","Creator · All","Creator · Me",...data.actors.map(m=>"Creator · "+actorLabel(m)),"Project · All",...data.projects.map(p=>"Project · "+p.name),"Project · No project","Label · All",...data.labels.map(l=>"Label · "+l.name),"Label · No label",...issueOnlyValues];
|
|
543
|
+
popover(button,values,value=>{const parts=value.split(" · "),key=parts[0].toLowerCase(),chosen=parts.slice(1).join(" · ");if(key==="project"){const p=data.projects.find(x=>x.name===chosen);filters.project=chosen==="All"?"All":chosen==="No project"?"No project":String(p?.id||"");}else if(key==="creator"){const m=data.actors.find(x=>actorLabel(x)===chosen);filters.creator=chosen==="All"?"All":chosen==="Me"?"Me":String(m?.id||"");}else if(key==="project status")filters.projectStatus=chosen;else if(key==="subscribers")filters.subscribers=chosen;else if(key==="external link")filters.externalLink=chosen;else if(key==="relations")filters.relations=chosen;else if(key==="due date")filters.dueDate=chosen==="Next 7 days"?"Next 7 days":chosen;else filters[key]=chosen;renderPage();});
|
|
544
|
+
}
|
|
545
|
+
function openPopoverForDisplay(button) {
|
|
546
|
+
const properties=Object.assign({Status:true,Priority:true,Assignee:true,Project:false,Label:false,"Due date":false,Updated:true},display.properties||{});
|
|
547
|
+
const values=["Layout · List","Layout · Board",...GROUPS.map(x=>"Group by · "+x),"Secondary group · None",...GROUPS.map(x=>"Secondary group · "+x),...SORTS.map(x=>"Sort by · "+x),"Direction · Ascending","Direction · Descending",...Object.keys(properties).map(x=>"Property · "+x+" · "+(properties[x]?"On":"Off")),"Show completed · "+(display.showCompleted?"On":"Off"),"Completed at bottom · "+(display.completedAtBottom?"On":"Off"),"Show sub-issues · "+(display.showSubissues?"On":"Off"),"Show empty groups · "+(display.showEmptyGroups?"On":"Off")];
|
|
548
|
+
popover(button,values,value=>{const [kind,...rest]=value.split(" · "),picked=rest.join(" · ");if(kind==="Layout")display.layout=picked;else if(kind==="Group by")display.groupBy=picked;else if(kind==="Secondary group")display.groupBy2=picked;else if(kind==="Sort by")display.orderBy=picked;else if(kind==="Direction")display.direction=picked;else if(kind==="Property"){const state=rest.pop(),name=rest.join(" · ");display.properties=Object.assign({},properties,{[name]:state!=="On"});}else if(kind==="Show completed")display.showCompleted=picked!=="On";else if(kind==="Completed at bottom")display.completedAtBottom=picked!=="On";else if(kind==="Show sub-issues")display.showSubissues=picked!=="On";else if(kind==="Show empty groups")display.showEmptyGroups=picked!=="On";renderPage();});
|
|
549
|
+
}
|
|
550
|
+
document.addEventListener("click", async event=>{
|
|
551
|
+
const actionNode=event.target.closest("[data-action]");
|
|
552
|
+
if(actionNode){event.preventDefault();try{await action(actionNode.dataset.action,actionNode);}catch(error){report(error);}return;}
|
|
553
|
+
const workspacePicker=event.target.closest("#workspace-open");
|
|
554
|
+
if(workspacePicker){const workspaceName=data.workspace.name||"Workspace";popover(workspacePicker,[workspaceName+" · Current","Workspace settings"],value=>value==="Workspace settings"?showSettings("workspace"):setPage("Home"));return;}
|
|
555
|
+
if(event.target.closest(".add-team")){openSimpleModal("team");return;}
|
|
556
|
+
const pageNode=event.target.closest("[data-page-go]");if(pageNode){setPage(pageNode.dataset.pageGo);return;}
|
|
557
|
+
const pageItem=event.target.closest(".nav-item[data-page]");if(pageItem){if(pageItem.dataset.page==="More"){popover(pageItem,["Triage","Archive","Members","Cycles","Settings","Create team"],value=>value==="Create team"?openSimpleModal("team"):setPage(value==="Create team"?"Home":value));return;}setPage(pageItem.dataset.page);return;}
|
|
558
|
+
const teamPicker=event.target.closest(".team-name");if(teamPicker){popover(teamPicker,data.teams.map(t=>t.name).concat("Create team"),value=>{if(value==="Create team")openSimpleModal("team");else{activeTeamId=data.teams.find(t=>t.name===value).id;localStorage.setItem("clone-team",String(activeTeamId));setPage("Home");}});return;}
|
|
559
|
+
const saved=event.target.closest("[data-saved-view]");if(saved){openSavedView(saved.dataset.savedView);return;}
|
|
560
|
+
const issueLink=event.target.closest("[data-issue]");if(issueLink){selectIssue(issueLink.dataset.issue);return;}
|
|
561
|
+
const issueIdLink=event.target.closest("[data-issue-id]");if(issueIdLink){selectIssue(issueIdLink.dataset.issueId);return;}
|
|
562
|
+
const projectLink=event.target.closest("[data-project]");if(projectLink){selectProject(projectLink.dataset.project);return;}
|
|
563
|
+
if(event.target.closest("[data-notification-select],#select-all-notifications"))return;
|
|
564
|
+
const notification=event.target.closest("[data-notification]");if(notification){selectedNotification=Number(notification.dataset.notification);const n=data.notifications.find(x=>x.id===selectedNotification);if(n&&!n.is_read)await api("/api/notifications/"+n.id+"/read",{method:"POST",body:{}}).then(refresh).catch(report);else renderInbox();return;}
|
|
565
|
+
const result=event.target.closest("[data-result-type]");if(result){closeSearch();if(result.dataset.resultType==="Issue")selectIssue(result.dataset.resultId);else if(result.dataset.resultType==="Project")selectProject(result.dataset.resultId);return;}
|
|
566
|
+
const scope=event.target.closest("[data-scope]");if(scope){searchScope=scope.dataset.scope;$$(".scope-chip",commandLayer).forEach(x=>x.classList.toggle("selected",x===scope));$(".search-field input",commandLayer).dispatchEvent(new Event("input"));return;}
|
|
567
|
+
const tab=event.target.closest(".view-pill");if(tab){if(["Views","Team views"].includes(currentPage))currentView=tab.dataset.view;else currentView=tab.dataset.view;$$(".view-pill",$("#view-tabs")).forEach(x=>x.classList.toggle("selected",x===tab));renderPage();return;}
|
|
568
|
+
const collapsed=event.target.closest(".window-sidebar");if(collapsed){const sidebar=$("#sidebar");sidebar.classList.toggle("collapsed");localStorage.setItem("clone-sidebar-collapsed",String(sidebar.classList.contains("collapsed")));return;}
|
|
569
|
+
});
|
|
570
|
+
$("#search-open").onclick=openSearch;
|
|
571
|
+
$("#create-open").onclick=()=>openIssueModal({teamId:activeTeamId});
|
|
572
|
+
$("#new-project").onclick=()=>openProjectModal();
|
|
573
|
+
$("#new-issue-header").onclick=()=>openIssueModal({teamId:activeTeamId});
|
|
574
|
+
$("#filter-button").onclick=event=>openPopoverForFilters(event.currentTarget);
|
|
575
|
+
$("#display-button").onclick=event=>openPopoverForDisplay(event.currentTarget);
|
|
576
|
+
$("#details-toggle").onclick=()=>{if(selectedIssue)renderPage();else toast("Open an issue to see its details");};
|
|
577
|
+
$("#share-view").onclick=async()=>{try{await navigator.clipboard.writeText(location.href);toast("Link copied");}catch{toast("Copy this page URL to share it");}};
|
|
578
|
+
$("#view-tabs").addEventListener("click",event=>{if(event.target.closest(".view-pill")&&viewId){const v=data.views.find(x=>x.id===viewId);if(v)api("/api/views/"+v.id,{method:"PATCH",body:{filters,display}}).catch(report);}});
|
|
579
|
+
content.addEventListener("submit",async event=>{const form=event.target.closest("form");if(!form)return;event.preventDefault();try{await submitForm(form);}catch(error){report(error);}});
|
|
580
|
+
modalLayer.addEventListener("submit",async event=>{const form=event.target.closest("form");if(!form)return;event.preventDefault();try{await submitForm(form);}catch(error){report(error);}});
|
|
581
|
+
content.addEventListener("change",async event=>{
|
|
582
|
+
const target=event.target;
|
|
583
|
+
if(target.id==="issue-file-picker"){const files=target.files;attachFiles(selectedIssue,files).catch(report).finally(()=>{target.value="";});return;}
|
|
584
|
+
if(target.id==="unread-only"){ inboxUnreadOnly=target.checked; renderInbox();return; }
|
|
585
|
+
if(target.id==="select-all-notifications"){
|
|
586
|
+
$$(".notification-row:not(.filtered-out) [data-notification-select]").forEach(box=>{const id=Number(box.dataset.notificationSelect);box.checked=target.checked;if(target.checked)selectedNotifications.add(id);else selectedNotifications.delete(id);});
|
|
587
|
+
}
|
|
588
|
+
if(target.matches("[data-notification-select]")){const id=Number(target.dataset.notificationSelect);if(target.checked)selectedNotifications.add(id);else selectedNotifications.delete(id);}
|
|
589
|
+
if(target.id==="select-all-notifications"||target.matches("[data-notification-select]")){
|
|
590
|
+
const visible=$$(".notification-row:not(.filtered-out) [data-notification-select]"),allChecked=visible.length>0&&visible.every(box=>box.checked),head=$("#select-all-notifications");if(head)head.checked=allChecked;
|
|
591
|
+
const count=selectedNotifications.size,read=$("[data-action=read-selected]"),archive=$("[data-action=archive-selected]");if(read){read.disabled=!count;read.textContent="Mark selected read";}if(archive){archive.disabled=!count;archive.textContent="Archive selected"+(count?" ("+count+")":"");}
|
|
592
|
+
}
|
|
593
|
+
if(target.matches("select[name]")&&selectedIssue){const key=target.name,value=target.value;let body={};if(key==="status"||key==="priority")body[key]=value;else if(key==="assigneeId"||key==="projectId"||key==="cycleId"||key==="estimate")body[key]=value?Number(value):null;else if(key==="teamId")body.teamId=Number(value);if(Object.keys(body).length){try{await api("/api/issues/"+selectedIssue,{method:"PATCH",body});await refresh();}catch(e){report(e);}}}
|
|
594
|
+
if(target.name==="projectStatus"||target.name==="projectPriority"){const body=target.name==="projectStatus"?{status:target.value}:{priority:target.value};try{await api("/api/projects/"+selectedProject,{method:"PATCH",body});await refresh();}catch(e){report(e);}}
|
|
595
|
+
});
|
|
596
|
+
content.addEventListener("blur",async event=>{
|
|
597
|
+
const target=event.target;
|
|
598
|
+
if(target.dataset.issueField){const key=target.dataset.issueField,body={};body[key]=target.value;if(key==="estimate")body[key]=target.value?Number(target.value):null;try{await api("/api/issues/"+selectedIssue,{method:"PATCH",body});await refresh();}catch(e){report(e);}}
|
|
599
|
+
if(target.dataset.projectField){try{await api("/api/projects/"+selectedProject,{method:"PATCH",body:{[target.dataset.projectField]:target.value}});await refresh();}catch(e){report(e);}}
|
|
600
|
+
},{capture:true});
|
|
601
|
+
content.addEventListener("dragstart",event=>{const card=event.target.closest("[data-issue]");if(card){event.dataTransfer.setData("text/plain",card.dataset.issue);}});
|
|
602
|
+
content.addEventListener("dragover",event=>{if(event.target.closest("[data-drop-group]"))event.preventDefault();});
|
|
603
|
+
content.addEventListener("drop",async event=>{const column=event.target.closest("[data-drop-group]");if(!column)return;event.preventDefault();const identifier=event.dataTransfer.getData("text/plain");const status=column.dataset.dropGroup;if(GROUPS.includes(display.groupBy)&&display.groupBy!=="Status")return;try{await api("/api/issues/"+identifier,{method:"PATCH",body:{status}});await refresh();}catch(e){report(e);}});
|
|
604
|
+
modalLayer.addEventListener("click",event=>{if(event.target===modalLayer)closeModal();});
|
|
605
|
+
commandLayer.addEventListener("click",event=>{if(event.target===commandLayer)closeSearch();});
|
|
606
|
+
document.addEventListener("change",async event=>{
|
|
607
|
+
const target=event.target,issueForm=target.closest("#issue-form");
|
|
608
|
+
if(issueForm&&target.name==="teamId"){
|
|
609
|
+
const teamId=Number(target.value), availableStatuses=statuses(teamId);
|
|
610
|
+
const statusSelect=$("select[name=status]",issueForm), currentStatus=statusSelect.value;
|
|
611
|
+
statusSelect.innerHTML=options(availableStatuses.map(s=>s.name),availableStatuses.some(s=>s.name===currentStatus)?currentStatus:availableStatuses.find(s=>s.is_default)?.name);
|
|
612
|
+
$("select[name=cycleId]",issueForm).innerHTML=options([{value:"",label:"No cycle"},...data.cycles.filter(c=>c.team_id===teamId).map(c=>({value:c.id,label:c.name}))],"");
|
|
613
|
+
$("select[name='labelIds[]']",issueForm).innerHTML=options(data.labels.filter(l=>!l.team_id||l.team_id===teamId).map(l=>({value:l.id,label:l.name})),[]);
|
|
614
|
+
if(refreshIssueProjectOptions(issueForm))toast("Project removed because it is not linked to this team");
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if(target.id!=="import-file")return;
|
|
618
|
+
const file=target.files[0];if(!file)return;
|
|
619
|
+
try{const parsed=JSON.parse(await file.text());importPreview=Array.isArray(parsed)?{issues:parsed}:parsed;if(!Array.isArray(importPreview.issues))throw new Error("JSON must contain an issues array");const sample=importPreview.issues.slice(0,5).map(x=>'<li>'+esc(x.identifier||"")+ ' '+esc(x.title||x.name||"Untitled")+'</li>').join("");const body='<p>Found <b>'+importPreview.issues.length+'</b> issues. Review a sample before importing into '+esc(team().name)+'.</p><ul>'+sample+'</ul><input type="hidden" name="confirm" value="yes">';openModal("Preview import","import-form",body,"Import issues");}catch(error){report(new Error("Could not read import file: "+error.message));}target.value="";
|
|
620
|
+
});
|
|
621
|
+
document.addEventListener("keydown",event=>{
|
|
622
|
+
if(event.key==="Tab"){
|
|
623
|
+
const layer=!modalLayer.classList.contains("hidden")?modalLayer:!commandLayer.classList.contains("hidden")?commandLayer:null;
|
|
624
|
+
if(layer){
|
|
625
|
+
const items=$$("button:not([disabled]),input:not([type=hidden]):not([disabled]),textarea,select,a[href]",layer);
|
|
626
|
+
const first=items[0],last=items[items.length-1];
|
|
627
|
+
if(event.shiftKey&&document.activeElement===first){event.preventDefault();last?.focus();}
|
|
628
|
+
else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first?.focus();}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if(event.key==="Enter"||event.key===" "){
|
|
632
|
+
const row=event.target.closest?.("[role=button]:not(button)");
|
|
633
|
+
if(row&&event.target===row){event.preventDefault();row.click();return;}
|
|
634
|
+
}
|
|
635
|
+
if(event.key==="Escape"){closeModal();closeSearch();closePopover();return;}
|
|
636
|
+
if(event.target.matches?.("#comment-form textarea")&&event.key==="Enter"&&!event.shiftKey){const mode=(data.settings.preferences||{}).commentSubmit||"Enter";if(mode==="Enter"||event.metaKey||event.ctrlKey){event.preventDefault();event.target.closest("form").requestSubmit();return;}}
|
|
637
|
+
const typing=["INPUT","TEXTAREA","SELECT"].includes(document.activeElement?.tagName);
|
|
638
|
+
if(!typing&&(event.metaKey||event.ctrlKey)&&event.key.toLowerCase()==="k"){event.preventDefault();openSearch();}
|
|
639
|
+
else if(!typing&&modalLayer.classList.contains("hidden")&&commandLayer.classList.contains("hidden")&&event.key.toLowerCase()==="c"){event.preventDefault();openIssueModal({teamId:activeTeamId});}
|
|
640
|
+
});
|
|
641
|
+
async function boot(){
|
|
642
|
+
try{data=await api("/api/bootstrap");normalizeData();const savedTeam=Number(localStorage.getItem("clone-team"));if(data.teams.some(t=>t.id===savedTeam))activeTeamId=savedTeam;const pages={inbox:"Inbox","my-issues":"My issues",projects:"Projects",views:"Views",home:"Home",issues:"Issues","team-projects":"Team projects","team-views":"Team views",members:"Members",cycles:"Cycles",settings:"Settings",triage:"Triage",archive:"Archive"};const route=decodeURIComponent(location.hash.slice(1));if(route.startsWith("view-")){const view=data.views.find(v=>v.id===Number(route.slice(5)));if(view){updateShell();return applyView(view);}}const routePage=pages[route];const savedPage=localStorage.getItem("clone-page");const homePage=(data.settings.preferences||{}).defaultHome||"Issues";if(routePage)currentPage=routePage;else if(savedPage&&Object.values(pages).includes(savedPage))currentPage=savedPage;else if(Object.values(pages).includes(homePage))currentPage=homePage;updateShell();renderPage();}
|
|
643
|
+
catch(error){content.innerHTML='<div class="empty-state"><h2>Could not connect to Clone</h2><p>Start Clone, then reload this page.</p></div>';report(error);}
|
|
644
|
+
}
|
|
645
|
+
boot();
|