@starlove/orbit 1.4.0 → 2.0.0
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/Dockerfile +10 -8
- package/README.md +146 -164
- package/dist/full/csp.txt +1 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
- package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
- package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
- package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
- package/dist/full/fonts/Mona_Sans/README.txt +10 -0
- package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
- package/dist/full/fonts/Newsreader/README.txt +8 -0
- package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
- package/dist/full/index.html +36 -653
- package/dist/full/js/app.BmuponPJ.js +32 -0
- package/dist/full/js/app.BmuponPJ.js.map +1 -0
- package/dist/full/js/chunk.1fJGQGbW.js +2 -0
- package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
- package/dist/full/js/chunk.5D7JO2zp.js +2 -0
- package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
- package/dist/full/js/chunk.BrNn_D79.js +2 -0
- package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
- package/dist/full/js/chunk.CokJXIqM.js +2 -0
- package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
- package/dist/full/js/chunk.D_Haqy6R.js +2 -0
- package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
- package/dist/full/js/chunk.RO0jsH6I.js +15 -0
- package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
- package/dist/full/js/chunk.dqWOEtBM.js +2 -0
- package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
- package/dist/full/js/warm.f260bbbc.js +15 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg-wide.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/styles.2b154ffe.css +1 -0
- package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
- package/dist/full/version.json +2 -2
- package/docs/AGENT_GUIDE.md +114 -0
- package/docs/UPDATING.md +249 -64
- package/lib/adapters/indexeddb/blobs.d.ts +3 -0
- package/lib/adapters/indexeddb/blobs.js +54 -0
- package/lib/adapters/indexeddb/database.d.ts +60 -0
- package/lib/adapters/indexeddb/database.js +126 -0
- package/lib/adapters/indexeddb/index.d.ts +5 -0
- package/lib/adapters/indexeddb/index.js +3 -0
- package/lib/adapters/indexeddb/locks.d.ts +14 -0
- package/lib/adapters/indexeddb/locks.js +40 -0
- package/lib/adapters/indexeddb/store.d.ts +40 -0
- package/lib/adapters/indexeddb/store.js +424 -0
- package/lib/adapters/indexeddb/tables.d.ts +43 -0
- package/lib/adapters/indexeddb/tables.js +127 -0
- package/lib/adapters/memory/blobs.d.ts +3 -0
- package/lib/adapters/memory/blobs.js +44 -0
- package/lib/adapters/memory/board.d.ts +41 -0
- package/lib/adapters/memory/board.js +521 -0
- package/lib/adapters/memory/index.d.ts +2 -0
- package/lib/adapters/memory/index.js +2 -0
- package/lib/adapters/memory/search.d.ts +6 -0
- package/lib/adapters/memory/search.js +30 -0
- package/lib/adapters/memory/store.d.ts +34 -0
- package/lib/adapters/memory/store.js +266 -0
- package/lib/adapters/sqlite/backups.d.ts +53 -0
- package/lib/adapters/sqlite/backups.js +232 -0
- package/lib/adapters/sqlite/blobs.d.ts +14 -0
- package/lib/adapters/sqlite/blobs.js +109 -0
- package/lib/adapters/sqlite/board.d.ts +77 -0
- package/lib/adapters/sqlite/board.js +328 -0
- package/lib/adapters/sqlite/database.d.ts +67 -0
- package/lib/adapters/sqlite/database.js +285 -0
- package/lib/adapters/sqlite/index.d.ts +3 -0
- package/lib/adapters/sqlite/index.js +2 -0
- package/lib/adapters/sqlite/lock.d.ts +11 -0
- package/lib/adapters/sqlite/lock.js +34 -0
- package/lib/adapters/sqlite/records.d.ts +54 -0
- package/lib/adapters/sqlite/records.js +546 -0
- package/lib/adapters/sqlite/schema.d.ts +54 -0
- package/lib/adapters/sqlite/schema.js +276 -0
- package/lib/adapters/sqlite/store.d.ts +39 -0
- package/lib/adapters/sqlite/store.js +385 -0
- package/lib/browser/in-page.d.ts +31 -0
- package/lib/browser/in-page.js +46 -0
- package/lib/browser/old-preview.d.ts +13 -0
- package/lib/browser/old-preview.js +80 -0
- package/lib/browser/preview-board.d.ts +8 -0
- package/lib/browser/preview-board.js +69 -0
- package/lib/cli/main.d.ts +7 -0
- package/lib/cli/main.js +343 -0
- package/lib/cli/orbit.d.ts +8 -0
- package/lib/cli/orbit.js +25 -0
- package/lib/client/http.d.ts +38 -0
- package/lib/client/http.js +334 -0
- package/lib/client/local.d.ts +26 -0
- package/lib/client/local.js +190 -0
- package/lib/client/object-url.d.ts +8 -0
- package/lib/client/object-url.js +21 -0
- package/lib/client/types.d.ts +268 -0
- package/lib/client/types.js +8 -0
- package/lib/commands/agent-tools.d.ts +31 -0
- package/lib/commands/agent-tools.js +100 -0
- package/lib/commands/index.d.ts +56 -0
- package/lib/commands/index.js +298 -0
- package/lib/commands/parse.d.ts +73 -0
- package/lib/commands/parse.js +231 -0
- package/lib/commands/present.d.ts +134 -0
- package/lib/commands/present.js +198 -0
- package/lib/commands/session.d.ts +85 -0
- package/lib/commands/session.js +138 -0
- package/lib/commands/verbs.d.ts +35 -0
- package/lib/commands/verbs.js +842 -0
- package/lib/core/actors.d.ts +18 -0
- package/lib/core/actors.js +45 -0
- package/lib/core/agent.d.ts +128 -0
- package/lib/core/agent.js +323 -0
- package/lib/core/boards.d.ts +113 -0
- package/lib/core/boards.js +288 -0
- package/lib/core/bootstrap.d.ts +47 -0
- package/lib/core/bootstrap.js +100 -0
- package/lib/core/bytes.d.ts +10 -0
- package/lib/core/bytes.js +52 -0
- package/lib/core/card-paths.d.ts +25 -0
- package/lib/core/card-paths.js +44 -0
- package/lib/core/context.d.ts +60 -0
- package/lib/core/context.js +42 -0
- package/lib/core/cross-board.d.ts +81 -0
- package/lib/core/cross-board.js +228 -0
- package/lib/core/describe.d.ts +21 -0
- package/lib/core/describe.js +81 -0
- package/lib/core/errors.d.ts +159 -0
- package/lib/core/errors.js +160 -0
- package/lib/core/files.d.ts +79 -0
- package/lib/core/files.js +262 -0
- package/lib/core/history.d.ts +20 -0
- package/lib/core/history.js +53 -0
- package/lib/core/image-types.d.ts +16 -0
- package/lib/core/image-types.js +36 -0
- package/lib/core/model.d.ts +263 -0
- package/lib/core/model.js +34 -0
- package/lib/core/nebula-link.d.ts +14 -0
- package/lib/core/nebula-link.js +39 -0
- package/lib/core/relations.d.ts +58 -0
- package/lib/core/relations.js +112 -0
- package/lib/core/search.d.ts +38 -0
- package/lib/core/search.js +92 -0
- package/lib/core/sessions.d.ts +44 -0
- package/lib/core/sessions.js +158 -0
- package/lib/core/snapshot-schema.d.ts +295 -0
- package/lib/core/snapshot-schema.js +82 -0
- package/lib/core/snapshots.d.ts +75 -0
- package/lib/core/snapshots.js +409 -0
- package/lib/core/states.d.ts +25 -0
- package/lib/core/states.js +126 -0
- package/lib/core/text.d.ts +40 -0
- package/lib/core/text.js +113 -0
- package/lib/core/tickets.d.ts +69 -0
- package/lib/core/tickets.js +311 -0
- package/lib/core/trello.d.ts +17 -0
- package/lib/core/trello.js +269 -0
- package/lib/core/verdicts.d.ts +28 -0
- package/lib/core/verdicts.js +92 -0
- package/lib/core/views.d.ts +95 -0
- package/lib/core/views.js +186 -0
- package/lib/http/app.d.ts +46 -0
- package/lib/http/app.js +491 -0
- package/lib/mcp-server.d.ts +10 -0
- package/lib/mcp-server.js +120 -0
- package/lib/node/assets.d.ts +7 -0
- package/lib/node/assets.js +68 -0
- package/lib/node/data-folder.d.ts +21 -0
- package/lib/node/data-folder.js +34 -0
- package/lib/node/env.d.ts +38 -0
- package/lib/node/env.js +49 -0
- package/lib/node/host-check.d.ts +24 -0
- package/lib/node/host-check.js +53 -0
- package/lib/node/host-services.d.ts +9 -0
- package/lib/node/host-services.js +150 -0
- package/lib/node/local.d.ts +22 -0
- package/lib/node/local.js +61 -0
- package/lib/node/migrate.d.ts +53 -0
- package/lib/node/migrate.js +635 -0
- package/lib/node/package.d.ts +7 -0
- package/lib/node/package.js +14 -0
- package/lib/node/repo-board.d.ts +49 -0
- package/lib/node/repo-board.js +245 -0
- package/lib/node/serve.d.ts +8 -0
- package/lib/node/serve.js +106 -0
- package/lib/orbit.d.ts +47 -0
- package/lib/orbit.js +44 -0
- package/lib/quiet-sqlite-warning.d.ts +1 -0
- package/lib/quiet-sqlite-warning.js +18 -0
- package/lib/store/blob-path.d.ts +6 -0
- package/lib/store/blob-path.js +12 -0
- package/lib/store/conformance.d.ts +52 -0
- package/lib/store/conformance.js +1449 -0
- package/lib/store/search-words.d.ts +10 -0
- package/lib/store/search-words.js +19 -0
- package/lib/store/types.d.ts +452 -0
- package/lib/store/types.js +34 -0
- package/llms.txt +63 -0
- package/package.json +109 -25
- package/src/cli/orbit.js +11 -658
- package/src/mcp-server.js +16 -876
- package/SKILL-ORBIT.md +0 -151
- package/build.mjs +0 -210
- package/dist/full/js/app.62905fcb.js +0 -952
- package/dist/full/js/app.62905fcb.js.map +0 -7
- package/dist/full/styles.4e60d824.css +0 -1
- package/docs/AGENT_PROTOCOL.md +0 -424
- package/docs/MCP_SETUP.md +0 -54
- package/docs/ORBIT_DISPATCH.md +0 -280
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -13
- package/docs/setup/orbit-init-wizard.md +0 -174
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/OFL.txt +0 -93
- package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/OFL.txt +0 -93
- package/public/fonts/Inter/README.txt +0 -118
- package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/README.txt +0 -79
- package/public/fonts/OpenRing-Regular.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/README.txt +0 -100
- package/public/fonts/Orbitron/OFL.txt +0 -93
- package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/public/fonts/Orbitron/README.txt +0 -68
- package/public/fonts/Rajdhani/OFL.txt +0 -93
- package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/public/fonts/runescape-uf.ttf +0 -0
- package/public/index.html +0 -769
- package/public/js/actor-labels.js +0 -65
- package/public/js/api.js +0 -39
- package/public/js/app.js +0 -45
- package/public/js/artifact-drawer.js +0 -79
- package/public/js/board-export.js +0 -161
- package/public/js/board-menu.js +0 -374
- package/public/js/card-actions.js +0 -545
- package/public/js/config.js +0 -20
- package/public/js/create-card.js +0 -190
- package/public/js/dom.js +0 -19
- package/public/js/drawer.js +0 -273
- package/public/js/font-preference.js +0 -229
- package/public/js/format.js +0 -433
- package/public/js/help-menu.js +0 -31
- package/public/js/icons.js +0 -16
- package/public/js/kanban.js +0 -694
- package/public/js/local-backend.js +0 -1015
- package/public/js/main.js +0 -161
- package/public/js/modal.js +0 -33
- package/public/js/motion-preference.js +0 -64
- package/public/js/panel.js +0 -58
- package/public/js/router.js +0 -134
- package/public/js/search.js +0 -56
- package/public/js/settings-tabs.js +0 -7
- package/public/js/settings.js +0 -1634
- package/public/js/sse.js +0 -159
- package/public/js/starscape.js +0 -332
- package/public/js/state.js +0 -129
- package/public/js/text-editing.js +0 -128
- package/public/js/theme-preference.js +0 -95
- package/public/js/ticket-detail.js +0 -1516
- package/public/js/ticket-typeahead.js +0 -49
- package/public/js/toast.js +0 -89
- package/public/js/topbar-notes.js +0 -155
- package/public/js/transport.js +0 -22
- package/public/js/typeahead.js +0 -206
- package/public/js/unread.js +0 -121
- package/public/js/update-check.js +0 -58
- package/public/js/url-routes.js +0 -94
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg-wide.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +0 -7709
- package/src/core/agent.js +0 -387
- package/src/core/agents-md.js +0 -109
- package/src/core/artifacts.js +0 -149
- package/src/core/attachments.js +0 -238
- package/src/core/auth.js +0 -85
- package/src/core/backups.js +0 -142
- package/src/core/board.js +0 -148
- package/src/core/boards.js +0 -409
- package/src/core/bootstrap.js +0 -170
- package/src/core/css-bundle.js +0 -30
- package/src/core/db.js +0 -315
- package/src/core/delete-board.js +0 -163
- package/src/core/dispatch.js +0 -1007
- package/src/core/events.js +0 -314
- package/src/core/http.js +0 -115
- package/src/core/migrate.js +0 -198
- package/src/core/paths.js +0 -55
- package/src/core/provision-repo-board.js +0 -119
- package/src/core/queries.js +0 -246
- package/src/core/registry.js +0 -211
- package/src/core/relations.js +0 -163
- package/src/core/review-verdicts.js +0 -138
- package/src/core/router.js +0 -556
- package/src/core/search.js +0 -183
- package/src/core/seed.js +0 -234
- package/src/core/snapshots.js +0 -692
- package/src/core/states.js +0 -167
- package/src/core/system-picker.js +0 -109
- package/src/core/tickets.js +0 -400
- package/src/core/util.js +0 -106
- package/src/core/version-check.js +0 -154
- package/src/mcp/orbit-client.js +0 -287
- package/src/server.js +0 -115
|
@@ -1,1516 +0,0 @@
|
|
|
1
|
-
// Ticket detail drawer: renders the full ticket pane (header meta, AI plan,
|
|
2
|
-
// status history, comments) and wires every editable surface — inline title
|
|
3
|
-
// and description editors, meta selects, label management, parent epic, and
|
|
4
|
-
// related cards. Also owns the small render helpers (status history,
|
|
5
|
-
// comments, detail-card) that are only used inside the drawer.
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
state,
|
|
9
|
-
ticketsForProject,
|
|
10
|
-
selectableStatesForProject,
|
|
11
|
-
boardLabelCatalog,
|
|
12
|
-
currentBoard,
|
|
13
|
-
upsertTicket,
|
|
14
|
-
} from "./state.js";
|
|
15
|
-
import { drawerInner, drawer, $ } from "./dom.js";
|
|
16
|
-
import {
|
|
17
|
-
escapeHtml,
|
|
18
|
-
formatDate,
|
|
19
|
-
formatDateDetail,
|
|
20
|
-
ticketLabel,
|
|
21
|
-
canonicalTicketType,
|
|
22
|
-
typeLabel,
|
|
23
|
-
typeLabelLong,
|
|
24
|
-
priorityLabel,
|
|
25
|
-
priorityKeyFor,
|
|
26
|
-
renderPreservedText,
|
|
27
|
-
stateClassFor,
|
|
28
|
-
cleanText,
|
|
29
|
-
} from "./format.js";
|
|
30
|
-
import { renderTypeIcon, renderPriorityPill, renderBoard } from "./kanban.js";
|
|
31
|
-
import { renderDrawerShell, openDrawer, closeDrawer } from "./drawer.js";
|
|
32
|
-
import { navigate } from "./router.js";
|
|
33
|
-
import { api, withBoardQuery } from "./api.js";
|
|
34
|
-
import { features } from "./config.js";
|
|
35
|
-
import { toast } from "./toast.js";
|
|
36
|
-
// `load()` is intentionally not used for drawer-local mutations. Reloading
|
|
37
|
-
// /api/bootstrap rebuilds the whole app; these helpers patch the local ticket
|
|
38
|
-
// cache and refresh only the surfaces that actually changed.
|
|
39
|
-
import { renderProjectDetail } from "./settings.js";
|
|
40
|
-
import { unreadCount, markRead } from "./unread.js";
|
|
41
|
-
import { formatActorLabel, formatCommentAuthor } from "./actor-labels.js";
|
|
42
|
-
import { openArtifactDrawer } from "./artifact-drawer.js";
|
|
43
|
-
import { closeIconSvg } from "./icons.js";
|
|
44
|
-
import { handleTextareaIndentationKeydown } from "./text-editing.js";
|
|
45
|
-
import { renderPanel, renderTogglePanel } from "./panel.js";
|
|
46
|
-
import { attachTypeahead } from "./typeahead.js";
|
|
47
|
-
import { attachTicketTypeahead } from "./ticket-typeahead.js";
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Refresh the open drawer from the focused ticket context endpoint. When the
|
|
51
|
-
* board card itself changed, also rebuild the board from the patched local
|
|
52
|
-
* cache; otherwise leave the board DOM alone.
|
|
53
|
-
*/
|
|
54
|
-
async function refreshTicketDetail(
|
|
55
|
-
ticketId,
|
|
56
|
-
{ renderBoardAfter = false } = {},
|
|
57
|
-
) {
|
|
58
|
-
const [context, statusHistory, commentsResult] = await Promise.all([
|
|
59
|
-
api(withBoardQuery(`/api/tickets/${ticketId}/context?depth=1`)),
|
|
60
|
-
api(withBoardQuery(`/api/tickets/${ticketId}/history`)),
|
|
61
|
-
api(withBoardQuery(`/api/tickets/${ticketId}/comments`)),
|
|
62
|
-
]);
|
|
63
|
-
if (context.ticket?.id !== ticketId) return;
|
|
64
|
-
upsertTicket(context.ticket);
|
|
65
|
-
if (renderBoardAfter) renderBoard();
|
|
66
|
-
if (state.detailMode !== "ticket" || state.selectedTicketId !== ticketId)
|
|
67
|
-
return;
|
|
68
|
-
await renderDetail({
|
|
69
|
-
context,
|
|
70
|
-
statusHistory,
|
|
71
|
-
comments: commentsResult.comments || [],
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* PATCH a ticket then refresh only the board/detail surfaces that depend on
|
|
77
|
-
* the changed ticket. This avoids the previous full /api/bootstrap reload for
|
|
78
|
-
* every drawer edit.
|
|
79
|
-
*/
|
|
80
|
-
async function patchTicket(ticketId, body) {
|
|
81
|
-
await api(withBoardQuery(`/api/tickets/${ticketId}`), {
|
|
82
|
-
method: "PATCH",
|
|
83
|
-
body,
|
|
84
|
-
});
|
|
85
|
-
await refreshTicketDetail(ticketId, { renderBoardAfter: true });
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Replace a static node with an inline editor; commit on Enter / blur, cancel on Esc.
|
|
90
|
-
* For multi-line fields, Ctrl/Cmd+Enter commits and plain Enter inserts a newline.
|
|
91
|
-
*
|
|
92
|
-
* Options:
|
|
93
|
-
* - fieldName: short identifier used for aria-label / data attribute (e.g. "title").
|
|
94
|
-
* - multiline: true for <textarea> + Ctrl+Enter commit; false for <input> + Enter commit.
|
|
95
|
-
* - initialValue: text shown in the editor on open.
|
|
96
|
-
* - ariaLabel: full label for screen readers.
|
|
97
|
-
* - emptyMessage: if set, an empty trimmed value triggers a warning toast and cancels.
|
|
98
|
-
* - sourceEvent: optional pointer/click event used to seed the textarea caret near the clicked text.
|
|
99
|
-
* - rerender: async fn called on cancel / no-change to put the static node back.
|
|
100
|
-
* - commit: async fn called with the new value when the user accepts a real change.
|
|
101
|
-
* Receives the *raw* editor value; the caller is responsible for any
|
|
102
|
-
* trimming, cleanText, or PATCH wiring.
|
|
103
|
-
*/
|
|
104
|
-
function inlineTextOffsetFromPoint(node, sourceEvent, maxLength) {
|
|
105
|
-
if (
|
|
106
|
-
!node ||
|
|
107
|
-
!sourceEvent ||
|
|
108
|
-
typeof sourceEvent.clientX !== "number" ||
|
|
109
|
-
typeof sourceEvent.clientY !== "number"
|
|
110
|
-
)
|
|
111
|
-
return null;
|
|
112
|
-
|
|
113
|
-
let range = null;
|
|
114
|
-
if (document.caretPositionFromPoint) {
|
|
115
|
-
const position = document.caretPositionFromPoint(
|
|
116
|
-
sourceEvent.clientX,
|
|
117
|
-
sourceEvent.clientY,
|
|
118
|
-
);
|
|
119
|
-
if (
|
|
120
|
-
position?.offsetNode &&
|
|
121
|
-
(position.offsetNode === node || node.contains(position.offsetNode))
|
|
122
|
-
) {
|
|
123
|
-
range = document.createRange();
|
|
124
|
-
range.setStart(node, 0);
|
|
125
|
-
range.setEnd(position.offsetNode, position.offset);
|
|
126
|
-
}
|
|
127
|
-
} else if (document.caretRangeFromPoint) {
|
|
128
|
-
const caretRange = document.caretRangeFromPoint(
|
|
129
|
-
sourceEvent.clientX,
|
|
130
|
-
sourceEvent.clientY,
|
|
131
|
-
);
|
|
132
|
-
if (
|
|
133
|
-
caretRange?.startContainer &&
|
|
134
|
-
(caretRange.startContainer === node ||
|
|
135
|
-
node.contains(caretRange.startContainer))
|
|
136
|
-
) {
|
|
137
|
-
range = document.createRange();
|
|
138
|
-
range.setStart(node, 0);
|
|
139
|
-
range.setEnd(caretRange.startContainer, caretRange.startOffset);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (!range) return null;
|
|
144
|
-
return Math.max(0, Math.min(maxLength, range.toString().length));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function captureInlineEditScrollState(node) {
|
|
148
|
-
const scrollContainer = node?.closest?.(".drawer-inner");
|
|
149
|
-
if (!scrollContainer) return null;
|
|
150
|
-
return {
|
|
151
|
-
scrollContainer,
|
|
152
|
-
nodeRect: node.getBoundingClientRect(),
|
|
153
|
-
scrollTop: scrollContainer.scrollTop,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function restoreInlineEditScroll(scrollState, editor) {
|
|
158
|
-
if (!scrollState?.scrollContainer || !editor?.isConnected) return;
|
|
159
|
-
const editorRect = editor.getBoundingClientRect();
|
|
160
|
-
const nextScrollTop = Math.max(
|
|
161
|
-
0,
|
|
162
|
-
scrollState.scrollContainer.scrollTop +
|
|
163
|
-
(editorRect.top - scrollState.nodeRect.top),
|
|
164
|
-
);
|
|
165
|
-
if (Number.isFinite(nextScrollTop))
|
|
166
|
-
scrollState.scrollContainer.scrollTop = nextScrollTop;
|
|
167
|
-
else scrollState.scrollContainer.scrollTop = scrollState.scrollTop;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function focusWithoutScrolling(editor) {
|
|
171
|
-
try {
|
|
172
|
-
editor.focus({ preventScroll: true });
|
|
173
|
-
} catch {
|
|
174
|
-
editor.focus();
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function startInlineEdit(node, opts) {
|
|
179
|
-
if (!node || node.dataset.editing === "true") return;
|
|
180
|
-
const {
|
|
181
|
-
fieldName,
|
|
182
|
-
multiline = true,
|
|
183
|
-
initialValue = "",
|
|
184
|
-
ariaLabel,
|
|
185
|
-
emptyMessage,
|
|
186
|
-
sourceEvent,
|
|
187
|
-
rerender,
|
|
188
|
-
commit: commitHandler,
|
|
189
|
-
} = opts;
|
|
190
|
-
|
|
191
|
-
const scrollState = multiline ? captureInlineEditScrollState(node) : null;
|
|
192
|
-
const initialString = String(initialValue);
|
|
193
|
-
const clickedCaretOffset = multiline
|
|
194
|
-
? inlineTextOffsetFromPoint(node, sourceEvent, initialString.length)
|
|
195
|
-
: null;
|
|
196
|
-
|
|
197
|
-
node.dataset.editing = "true";
|
|
198
|
-
|
|
199
|
-
const editor = document.createElement(multiline ? "textarea" : "input");
|
|
200
|
-
editor.className = multiline ? "inline-desc-editor" : "inline-title-editor";
|
|
201
|
-
editor.value = initialString;
|
|
202
|
-
if (fieldName) editor.setAttribute("data-editing-field", fieldName);
|
|
203
|
-
if (ariaLabel) editor.setAttribute("aria-label", ariaLabel);
|
|
204
|
-
if (!multiline) {
|
|
205
|
-
editor.type = "text";
|
|
206
|
-
editor.maxLength = 500;
|
|
207
|
-
editor.autocomplete = "off";
|
|
208
|
-
editor.spellcheck = true;
|
|
209
|
-
} else {
|
|
210
|
-
editor.rows = Math.max(3, initialString.split("\n").length + 1);
|
|
211
|
-
const sourceHeight = node.getBoundingClientRect().height;
|
|
212
|
-
if (Number.isFinite(sourceHeight) && sourceHeight > 0) {
|
|
213
|
-
editor.style.minHeight = `${Math.ceil(sourceHeight)}px`;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
node.replaceWith(editor);
|
|
218
|
-
focusWithoutScrolling(editor);
|
|
219
|
-
if (!multiline) {
|
|
220
|
-
editor.select();
|
|
221
|
-
} else {
|
|
222
|
-
const selectionStart = clickedCaretOffset ?? 0;
|
|
223
|
-
editor.setSelectionRange(selectionStart, selectionStart);
|
|
224
|
-
restoreInlineEditScroll(scrollState, editor);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
let done = false;
|
|
228
|
-
|
|
229
|
-
const stopWatchingOutsideClicks = () => {
|
|
230
|
-
document.removeEventListener("pointerdown", handleOutsidePointerDown, true);
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
// If a downstream render rebuilds the title block while the editor is open,
|
|
234
|
-
// the editor is dropped along with everything else; only restore the saved
|
|
235
|
-
// node when our editor is still connected to the DOM.
|
|
236
|
-
const restoreNode = () => {
|
|
237
|
-
if (editor.isConnected) editor.replaceWith(node);
|
|
238
|
-
delete node.dataset.editing;
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
const finish = () => {
|
|
242
|
-
done = true;
|
|
243
|
-
stopWatchingOutsideClicks();
|
|
244
|
-
restoreNode();
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
function handleOutsidePointerDown(event) {
|
|
248
|
-
if (done || event.target === editor || editor.contains(event.target))
|
|
249
|
-
return;
|
|
250
|
-
// Some drawer/header surfaces are not focusable, so clicking them does not
|
|
251
|
-
// reliably blur the input in every browser. Force the blur path used on
|
|
252
|
-
// commit so the edit styling cannot get stuck.
|
|
253
|
-
editor.blur();
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
document.addEventListener("pointerdown", handleOutsidePointerDown, true);
|
|
257
|
-
|
|
258
|
-
const commit = async () => {
|
|
259
|
-
if (done) return;
|
|
260
|
-
const next = multiline ? editor.value : editor.value.trim();
|
|
261
|
-
finish();
|
|
262
|
-
if (String(next) === String(initialValue)) {
|
|
263
|
-
if (rerender) await rerender();
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
if (emptyMessage && !next.trim()) {
|
|
267
|
-
toast.warning(emptyMessage);
|
|
268
|
-
if (rerender) await rerender();
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
if (commitHandler) await commitHandler(next);
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
const cancel = async () => {
|
|
275
|
-
if (done) return;
|
|
276
|
-
finish();
|
|
277
|
-
if (rerender) await rerender();
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
editor.addEventListener("blur", commit);
|
|
281
|
-
editor.addEventListener("keydown", (event) => {
|
|
282
|
-
if (multiline && handleTextareaIndentationKeydown(event, editor)) return;
|
|
283
|
-
if (event.key === "Escape") {
|
|
284
|
-
event.preventDefault();
|
|
285
|
-
void cancel();
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
if (event.key === "Enter") {
|
|
289
|
-
if (!multiline) {
|
|
290
|
-
event.preventDefault();
|
|
291
|
-
void commit();
|
|
292
|
-
} else if (event.metaKey || event.ctrlKey) {
|
|
293
|
-
event.preventDefault();
|
|
294
|
-
void commit();
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function promptArtifactPath({ currentPath = "", label = "Artifact" } = {}) {
|
|
301
|
-
const next = window.prompt(
|
|
302
|
-
`Local markdown/text path for ${label} (example: docs/test.md or ./docs/test.md)`,
|
|
303
|
-
currentPath || "docs/",
|
|
304
|
-
);
|
|
305
|
-
if (next === null) return null;
|
|
306
|
-
return cleanText(next).trim();
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async function promptAndSaveArtifactPath({ ticketId, fieldName, currentPath, label }) {
|
|
310
|
-
if (!ticketId || !fieldName) return null;
|
|
311
|
-
const next = promptArtifactPath({ currentPath, label });
|
|
312
|
-
if (next === null) return null;
|
|
313
|
-
await patchTicket(ticketId, { [fieldName]: next });
|
|
314
|
-
await refreshTicketDetail(ticketId);
|
|
315
|
-
toast.success(next ? "Artifact path updated" : "Artifact path cleared");
|
|
316
|
-
return next;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** Wire inline editors on the ticket detail header (meta fields, labels). */
|
|
320
|
-
function wireTicketDetailEditors(ticket, context) {
|
|
321
|
-
const ticketId = ticket.id;
|
|
322
|
-
|
|
323
|
-
const editTitle = () =>
|
|
324
|
-
startInlineEdit(titleEl, {
|
|
325
|
-
fieldName: "title",
|
|
326
|
-
multiline: false,
|
|
327
|
-
initialValue: ticket.title || "",
|
|
328
|
-
ariaLabel: "Edit ticket title",
|
|
329
|
-
emptyMessage: "Title cannot be empty",
|
|
330
|
-
rerender: () => renderDetail(),
|
|
331
|
-
commit: (next) => patchTicket(ticketId, { title: next }),
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
const titleEl = drawer.querySelector('[data-edit-field="title"]');
|
|
335
|
-
if (titleEl) {
|
|
336
|
-
titleEl.addEventListener("click", editTitle);
|
|
337
|
-
titleEl.addEventListener("keydown", (event) => {
|
|
338
|
-
if (event.key === "Enter" || event.key === " ") {
|
|
339
|
-
event.preventDefault();
|
|
340
|
-
editTitle();
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const descEl = drawerInner.querySelector('[data-edit-field="description"]');
|
|
346
|
-
const editDescription = (sourceEvent) =>
|
|
347
|
-
startInlineEdit(descEl, {
|
|
348
|
-
fieldName: "description",
|
|
349
|
-
multiline: true,
|
|
350
|
-
initialValue: ticket.description || "",
|
|
351
|
-
ariaLabel: "Edit ticket description",
|
|
352
|
-
sourceEvent,
|
|
353
|
-
rerender: () => renderDetail(),
|
|
354
|
-
commit: (next) => patchTicket(ticketId, { description: cleanText(next) }),
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
if (descEl) {
|
|
358
|
-
descEl.addEventListener("click", (event) => {
|
|
359
|
-
event.preventDefault();
|
|
360
|
-
editDescription(event);
|
|
361
|
-
});
|
|
362
|
-
descEl.addEventListener("keydown", (event) => {
|
|
363
|
-
if (event.key === "Enter" || event.key === " ") {
|
|
364
|
-
event.preventDefault();
|
|
365
|
-
editDescription();
|
|
366
|
-
}
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// AI Plan / Implementation fields: each becomes its own click-to-edit
|
|
371
|
-
// surface so they save independently on blur / Ctrl+Enter, matching the
|
|
372
|
-
// description field. The "ai_plan" / "implementation_summary" /
|
|
373
|
-
// "implementation_updates" data-edit-field selectors are emitted by the
|
|
374
|
-
// detail body template below.
|
|
375
|
-
const AI_FIELDS = [
|
|
376
|
-
{ key: "ai_plan", label: "AI Plan" },
|
|
377
|
-
{ key: "implementation_summary", label: "Work Summary" },
|
|
378
|
-
{
|
|
379
|
-
key: "implementation_updates",
|
|
380
|
-
label: "Revisions",
|
|
381
|
-
}
|
|
382
|
-
];
|
|
383
|
-
for (const { key, label } of AI_FIELDS) {
|
|
384
|
-
const fieldEl = drawerInner.querySelector(`[data-edit-field="${key}"]`);
|
|
385
|
-
if (!fieldEl) continue;
|
|
386
|
-
const edit = (sourceEvent) =>
|
|
387
|
-
startInlineEdit(fieldEl, {
|
|
388
|
-
fieldName: key,
|
|
389
|
-
multiline: true,
|
|
390
|
-
initialValue: ticket[key] || "",
|
|
391
|
-
ariaLabel: `Edit ${label}`,
|
|
392
|
-
sourceEvent,
|
|
393
|
-
rerender: () => renderDetail(),
|
|
394
|
-
commit: (next) => patchTicket(ticketId, { [key]: cleanText(next) }),
|
|
395
|
-
});
|
|
396
|
-
fieldEl.addEventListener("click", (event) => {
|
|
397
|
-
event.preventDefault();
|
|
398
|
-
edit(event);
|
|
399
|
-
});
|
|
400
|
-
fieldEl.addEventListener("keydown", (event) => {
|
|
401
|
-
if (event.key === "Enter" || event.key === " ") {
|
|
402
|
-
event.preventDefault();
|
|
403
|
-
edit();
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
drawer.querySelectorAll(".meta-select[data-meta-field]").forEach((select) => {
|
|
409
|
-
select.addEventListener("change", async () => {
|
|
410
|
-
const field = select.dataset.metaField;
|
|
411
|
-
if (field === "type") {
|
|
412
|
-
await patchTicket(ticketId, { type: select.value });
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
if (field === "state_id") {
|
|
416
|
-
await patchTicket(ticketId, { state_id: select.value });
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
if (field === "parent_ticket_id") {
|
|
420
|
-
await patchTicket(ticketId, { parent_ticket_id: select.value || null });
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
if (field === "priority") {
|
|
424
|
-
await patchTicket(ticketId, { priority: Number(select.value) });
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
const applyLabelPatch = async (nextNames) => {
|
|
430
|
-
await patchTicket(ticketId, { labels: nextNames });
|
|
431
|
-
};
|
|
432
|
-
|
|
433
|
-
drawerInner.querySelectorAll("[data-remove-label]").forEach((btn) => {
|
|
434
|
-
btn.addEventListener("click", async () => {
|
|
435
|
-
const removeName = btn.getAttribute("data-remove-label");
|
|
436
|
-
const names = (ticket.labels || [])
|
|
437
|
-
.map((l) => l.name)
|
|
438
|
-
.filter((n) => n !== removeName);
|
|
439
|
-
await applyLabelPatch(names);
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
const addLabel = async () => {
|
|
444
|
-
const input = drawerInner.querySelector("[data-label-input]");
|
|
445
|
-
if (!input) return;
|
|
446
|
-
const name = input.value.trim();
|
|
447
|
-
if (!name) return;
|
|
448
|
-
const names = (ticket.labels || []).map((l) => l.name);
|
|
449
|
-
if (names.includes(name)) {
|
|
450
|
-
input.value = "";
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
names.push(name);
|
|
454
|
-
input.value = "";
|
|
455
|
-
await applyLabelPatch(names);
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
drawerInner
|
|
459
|
-
.querySelector("[data-label-add]")
|
|
460
|
-
?.addEventListener("click", () => {
|
|
461
|
-
void addLabel();
|
|
462
|
-
});
|
|
463
|
-
// Attach the typeahead first so its Enter handler runs before the add-on-Enter
|
|
464
|
-
// handler below; when a suggestion is highlighted it stops propagation so the
|
|
465
|
-
// partial typed text isn't added instead.
|
|
466
|
-
attachLabelTypeahead(drawerInner.querySelector("[data-label-input]"), ticket);
|
|
467
|
-
drawerInner
|
|
468
|
-
.querySelector("[data-label-input]")
|
|
469
|
-
?.addEventListener("keydown", (event) => {
|
|
470
|
-
if (event.key === "Enter") {
|
|
471
|
-
event.preventDefault();
|
|
472
|
-
void addLabel();
|
|
473
|
-
}
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
export async function renderDetail(options = {}) {
|
|
478
|
-
if (state.detailMode === "settings") {
|
|
479
|
-
await renderProjectDetail();
|
|
480
|
-
openDrawer();
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (!state.selectedTicketId) {
|
|
485
|
-
closeDrawer();
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const requestedTicketId = state.selectedTicketId;
|
|
490
|
-
const hasOption = (key) => Object.prototype.hasOwnProperty.call(options, key);
|
|
491
|
-
const [context, statusHistory, comments, attachmentList] = await Promise.all([
|
|
492
|
-
hasOption("context")
|
|
493
|
-
? Promise.resolve(options.context)
|
|
494
|
-
: api(
|
|
495
|
-
withBoardQuery(`/api/tickets/${requestedTicketId}/context?depth=1`),
|
|
496
|
-
),
|
|
497
|
-
hasOption("statusHistory")
|
|
498
|
-
? Promise.resolve(options.statusHistory)
|
|
499
|
-
: api(withBoardQuery(`/api/tickets/${requestedTicketId}/history`)),
|
|
500
|
-
hasOption("comments")
|
|
501
|
-
? Promise.resolve(options.comments)
|
|
502
|
-
: api(withBoardQuery(`/api/tickets/${requestedTicketId}/comments`)).then(
|
|
503
|
-
(result) => result.comments || [],
|
|
504
|
-
),
|
|
505
|
-
hasOption("attachmentList")
|
|
506
|
-
? Promise.resolve(options.attachmentList)
|
|
507
|
-
: features.attachments
|
|
508
|
-
? api(
|
|
509
|
-
withBoardQuery(`/api/tickets/${requestedTicketId}/attachments`),
|
|
510
|
-
).catch(() => ({ attachments: [] }))
|
|
511
|
-
: Promise.resolve({ attachments: [] }),
|
|
512
|
-
]);
|
|
513
|
-
if (
|
|
514
|
-
state.detailMode !== "ticket" ||
|
|
515
|
-
state.selectedTicketId !== requestedTicketId
|
|
516
|
-
)
|
|
517
|
-
return;
|
|
518
|
-
if (context.ticket?.id !== requestedTicketId) return;
|
|
519
|
-
const ticket = context.ticket;
|
|
520
|
-
// Acknowledge the read receipt: clear the unread dot on this card.
|
|
521
|
-
// Also wipe the dot from the already-rendered card in the board so the
|
|
522
|
-
// user doesn't see it lingering until the next full re-render.
|
|
523
|
-
markRead(ticket.id, ticket.updated_at);
|
|
524
|
-
document
|
|
525
|
-
.querySelector(
|
|
526
|
-
`.card[data-ticket-id="${CSS.escape(ticket.id)}"] .card-unread-dot`,
|
|
527
|
-
)
|
|
528
|
-
?.remove();
|
|
529
|
-
const states = selectableStatesForProject(ticket.state_id);
|
|
530
|
-
|
|
531
|
-
// Ordered by the canonical hierarchy so the dropdown visually reinforces scale:
|
|
532
|
-
// Epic (biggest) → Feature → Task → Bug.
|
|
533
|
-
const typeOptions = ["epic", "feature", "task", "bug"]
|
|
534
|
-
.map(
|
|
535
|
-
(value) =>
|
|
536
|
-
`<option value="${escapeHtml(value)}" ${canonicalTicketType(ticket.type) === value ? "selected" : ""}>${escapeHtml(typeLabelLong(value))}</option>`,
|
|
537
|
-
)
|
|
538
|
-
.join("");
|
|
539
|
-
|
|
540
|
-
const stateOptions = states
|
|
541
|
-
.map(
|
|
542
|
-
(s) =>
|
|
543
|
-
`<option value="${escapeHtml(s.id)}" ${s.id === ticket.state_id ? "selected" : ""}>${escapeHtml(s.name)}</option>`,
|
|
544
|
-
)
|
|
545
|
-
.join("");
|
|
546
|
-
|
|
547
|
-
const priorityOptions = [4, 3, 2, 1, 0]
|
|
548
|
-
.map(
|
|
549
|
-
(p) =>
|
|
550
|
-
`<option value="${p}" ${Number(ticket.priority) === p ? "selected" : ""}>${escapeHtml(priorityLabel(p))}</option>`,
|
|
551
|
-
)
|
|
552
|
-
.join("");
|
|
553
|
-
|
|
554
|
-
const labelPills = (ticket.labels || [])
|
|
555
|
-
.map(
|
|
556
|
-
(label) => `
|
|
557
|
-
<span class="label-pill-removable" style="--label-color: ${escapeHtml(label.color)}">
|
|
558
|
-
${escapeHtml(label.name)}
|
|
559
|
-
<button type="button" class="label-pill-remove" data-remove-label="${escapeHtml(label.name)}" title="Remove label" aria-label="Remove label">${closeIconSvg}</button>
|
|
560
|
-
</span>`,
|
|
561
|
-
)
|
|
562
|
-
.join("");
|
|
563
|
-
|
|
564
|
-
const detailCanonicalType = canonicalTicketType(ticket.type);
|
|
565
|
-
const priorityKey = priorityKeyFor(ticket.priority);
|
|
566
|
-
const currentState = states.find((s) => s.id === ticket.state_id);
|
|
567
|
-
const stateClass = stateClassFor(currentState);
|
|
568
|
-
const descriptionHtml = ticket.description
|
|
569
|
-
? renderPreservedText(ticket.description)
|
|
570
|
-
: escapeHtml("No description yet.");
|
|
571
|
-
const descriptionClass = ticket.description
|
|
572
|
-
? "description preserved-text-body editable-field detail-description-body"
|
|
573
|
-
: "description editable-field detail-description-body is-placeholder";
|
|
574
|
-
const detailSubtitleHtml = `
|
|
575
|
-
<span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
|
|
576
|
-
<div class="detail-meta-badge-row" aria-label="Ticket metadata controls">
|
|
577
|
-
<select class="detail-meta-badge detail-state-badge meta-select" data-variant="${escapeHtml(stateClass)}" data-meta-field="state_id" aria-label="Ticket state">${stateOptions}</select>
|
|
578
|
-
<select class="detail-meta-badge detail-type-badge type-pill-${escapeHtml(detailCanonicalType)} meta-select" data-meta-field="type" aria-label="Ticket type">${typeOptions}</select>
|
|
579
|
-
<select class="detail-meta-badge detail-priority-badge priority-pill meta-select" data-variant="${escapeHtml(priorityKey)}" data-meta-field="priority" aria-label="Ticket priority">${priorityOptions}</select>
|
|
580
|
-
</div>
|
|
581
|
-
`;
|
|
582
|
-
|
|
583
|
-
renderDrawerShell({
|
|
584
|
-
mode: "ticket",
|
|
585
|
-
title: ticket.title,
|
|
586
|
-
titleAttrs: {
|
|
587
|
-
class: "editable-field",
|
|
588
|
-
"data-edit-field": "title",
|
|
589
|
-
role: "button",
|
|
590
|
-
tabindex: "0",
|
|
591
|
-
title: "Click or press Enter to edit ticket title",
|
|
592
|
-
"aria-label": "Edit ticket title",
|
|
593
|
-
},
|
|
594
|
-
subtitleHtml: detailSubtitleHtml,
|
|
595
|
-
body: `
|
|
596
|
-
<div class="detail-head">
|
|
597
|
-
<p class="detail-updated" title="Last saved change">Updated ${escapeHtml(formatDateDetail(ticket.updated_at))}</p>
|
|
598
|
-
<div class="${descriptionClass}" data-edit-field="description" tabindex="0" title="Click to edit">${descriptionHtml}</div>
|
|
599
|
-
</div>
|
|
600
|
-
|
|
601
|
-
<div class="settings-stack detail-section-stack">
|
|
602
|
-
${renderPanel({
|
|
603
|
-
title: "Labels",
|
|
604
|
-
body: `
|
|
605
|
-
<div class="settings-row">
|
|
606
|
-
<div class="label-pills-row" data-other-labels>${labelPills}</div>
|
|
607
|
-
<div class="label-add-row">
|
|
608
|
-
<div class="label-add-input-wrap">
|
|
609
|
-
<input type="text" class="label-add-input meta-inline-input" data-label-input autocomplete="off" placeholder="Add label…" aria-label="Add label" />
|
|
610
|
-
</div>
|
|
611
|
-
<button type="button" class="meta-add-btn detail-action-btn" data-variant="ghost" data-label-add>Add</button>
|
|
612
|
-
</div>
|
|
613
|
-
</div>
|
|
614
|
-
`,
|
|
615
|
-
})}
|
|
616
|
-
|
|
617
|
-
${renderHierarchySection(ticket, context)}
|
|
618
|
-
|
|
619
|
-
${features.attachments ? renderAttachmentSection(ticket, attachmentList.attachments || []) : ""}
|
|
620
|
-
|
|
621
|
-
${renderParentEpicSection(ticket, context)}
|
|
622
|
-
|
|
623
|
-
${renderPanel({
|
|
624
|
-
title: "Related",
|
|
625
|
-
body: `<div class="settings-row">${renderRelated(context.relations, ticket)}</div>`,
|
|
626
|
-
})}
|
|
627
|
-
|
|
628
|
-
${
|
|
629
|
-
currentBoard()?.ai_enabled !== 0
|
|
630
|
-
? renderTogglePanel({
|
|
631
|
-
title: "AI Plan / Implementation Record",
|
|
632
|
-
open: aiPlanOpen(ticket.id),
|
|
633
|
-
attrs: `data-ai-plan-toggle data-ticket-id="${escapeHtml(ticket.id)}"`,
|
|
634
|
-
body: `
|
|
635
|
-
<div class="ai-fields-grid">
|
|
636
|
-
${renderInlinePreservedTextField({
|
|
637
|
-
fieldName: "ai_plan",
|
|
638
|
-
label: "AI Plan",
|
|
639
|
-
value: ticket.ai_plan,
|
|
640
|
-
placeholder: "What to do: ",
|
|
641
|
-
ticketId: ticket.id,
|
|
642
|
-
artifactPath: ticket.plan_artifact_path,
|
|
643
|
-
artifactFieldName: "plan_artifact_path",
|
|
644
|
-
artifactLabel: "Open full handoff",
|
|
645
|
-
})}
|
|
646
|
-
${renderInlinePreservedTextField({
|
|
647
|
-
fieldName: "implementation_summary",
|
|
648
|
-
label: "Work Summary",
|
|
649
|
-
value: ticket.implementation_summary,
|
|
650
|
-
placeholder: "What was done: ",
|
|
651
|
-
ticketId: ticket.id,
|
|
652
|
-
artifactPath: ticket.implementation_artifact_path,
|
|
653
|
-
artifactFieldName: "implementation_artifact_path",
|
|
654
|
-
artifactLabel: "Open full report",
|
|
655
|
-
})}
|
|
656
|
-
${renderInlinePreservedTextField({
|
|
657
|
-
fieldName: "implementation_updates",
|
|
658
|
-
label: "Revisions",
|
|
659
|
-
value: ticket.implementation_updates,
|
|
660
|
-
placeholder: "Changes since initial implementation: ",
|
|
661
|
-
})}
|
|
662
|
-
</div>`,
|
|
663
|
-
})
|
|
664
|
-
: ""
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
${renderPanel({
|
|
668
|
-
title: "Status History",
|
|
669
|
-
body: `<div class="settings-row">${renderStatusHistory(statusHistory)}</div>`,
|
|
670
|
-
})}
|
|
671
|
-
|
|
672
|
-
${renderPanel({
|
|
673
|
-
title: "Comments",
|
|
674
|
-
body: `
|
|
675
|
-
<div class="settings-row">
|
|
676
|
-
${comments.map(renderComment).join("") || `<p class="description">No comments yet.</p>`}
|
|
677
|
-
<form class="comment-form" id="detailCommentForm">
|
|
678
|
-
<textarea name="body" placeholder="Add a comment, decision, or instruction..." required></textarea>
|
|
679
|
-
<button type="submit" class="detail-action-btn" data-variant="ghost">Comment</button>
|
|
680
|
-
</form>
|
|
681
|
-
</div>
|
|
682
|
-
`,
|
|
683
|
-
})}
|
|
684
|
-
</div>
|
|
685
|
-
`,
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
wireTicketDetailEditors(ticket, context);
|
|
689
|
-
if (features.attachments) wireAttachmentControls(ticket);
|
|
690
|
-
|
|
691
|
-
const commentTextarea = $("#detailCommentForm textarea");
|
|
692
|
-
commentTextarea?.addEventListener("keydown", (event) => {
|
|
693
|
-
handleTextareaIndentationKeydown(event, commentTextarea);
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
$("#detailCommentForm").addEventListener("submit", async (event) => {
|
|
697
|
-
event.preventDefault();
|
|
698
|
-
const body = cleanText(
|
|
699
|
-
new FormData(event.currentTarget).get("body"),
|
|
700
|
-
).trim();
|
|
701
|
-
if (!body) return;
|
|
702
|
-
await api(withBoardQuery(`/api/tickets/${ticket.id}/comments`), {
|
|
703
|
-
method: "POST",
|
|
704
|
-
body: { body },
|
|
705
|
-
});
|
|
706
|
-
await refreshTicketDetail(ticket.id);
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
drawerInner.querySelectorAll("[data-comment-action='toggle-ai-omission']").forEach((button) => {
|
|
710
|
-
button.addEventListener("click", async () => {
|
|
711
|
-
const commentId = button.dataset.commentId;
|
|
712
|
-
if (!commentId) return;
|
|
713
|
-
const omittedForAi = button.dataset.omittedForAi !== "true";
|
|
714
|
-
await api(withBoardQuery(`/api/tickets/${ticket.id}/comments/${commentId}`), {
|
|
715
|
-
method: "PATCH",
|
|
716
|
-
body: { omitted_for_ai: omittedForAi },
|
|
717
|
-
});
|
|
718
|
-
toast.info(omittedForAi ? "Comment omitted from AI context" : "Comment included in AI context");
|
|
719
|
-
await refreshTicketDetail(ticket.id);
|
|
720
|
-
});
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
drawerInner.querySelectorAll("[data-open-ticket]").forEach((card) => {
|
|
724
|
-
card.addEventListener("click", async (event) => {
|
|
725
|
-
if (
|
|
726
|
-
event.target.closest(
|
|
727
|
-
"[data-detach-child],[data-detach-parent],[data-detach-relation]",
|
|
728
|
-
)
|
|
729
|
-
)
|
|
730
|
-
return;
|
|
731
|
-
navigate({
|
|
732
|
-
boardId: state.boardId,
|
|
733
|
-
view: "ticket",
|
|
734
|
-
ticketId: card.dataset.openTicket,
|
|
735
|
-
});
|
|
736
|
-
});
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
drawerInner.querySelectorAll("[data-detach-child]").forEach((btn) => {
|
|
740
|
-
btn.addEventListener("click", async (event) => {
|
|
741
|
-
event.stopPropagation();
|
|
742
|
-
const childId = btn.dataset.detachChild;
|
|
743
|
-
try {
|
|
744
|
-
await api(withBoardQuery(`/api/tickets/${childId}`), {
|
|
745
|
-
method: "PATCH",
|
|
746
|
-
body: { parent_ticket_id: null },
|
|
747
|
-
});
|
|
748
|
-
} catch (err) {
|
|
749
|
-
toast.error(err.message || "Failed to remove child");
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
|
|
753
|
-
toast.success("Removed from epic");
|
|
754
|
-
});
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
drawerInner.querySelectorAll("[data-detach-parent]").forEach((btn) => {
|
|
758
|
-
btn.addEventListener("click", async (event) => {
|
|
759
|
-
event.stopPropagation();
|
|
760
|
-
try {
|
|
761
|
-
await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
|
|
762
|
-
method: "PATCH",
|
|
763
|
-
body: { parent_ticket_id: null },
|
|
764
|
-
});
|
|
765
|
-
} catch (err) {
|
|
766
|
-
toast.error(err.message || "Failed to remove parent");
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
|
|
770
|
-
toast.success("Parent epic removed");
|
|
771
|
-
});
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
drawerInner.querySelectorAll("[data-detach-relation]").forEach((btn) => {
|
|
775
|
-
btn.addEventListener("click", async (event) => {
|
|
776
|
-
event.stopPropagation();
|
|
777
|
-
const relationId = btn.dataset.detachRelation;
|
|
778
|
-
try {
|
|
779
|
-
await api(withBoardQuery(`/api/relations/${relationId}`), {
|
|
780
|
-
method: "DELETE",
|
|
781
|
-
});
|
|
782
|
-
} catch (err) {
|
|
783
|
-
toast.error(err.message || "Failed to remove relation");
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
await refreshTicketDetail(ticket.id);
|
|
787
|
-
toast.success("Relation removed");
|
|
788
|
-
});
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
const aiPlanEl = drawerInner.querySelector("[data-ai-plan-toggle]");
|
|
792
|
-
if (aiPlanEl) {
|
|
793
|
-
aiPlanEl.addEventListener("toggle", () => {
|
|
794
|
-
const id = aiPlanEl.dataset.ticketId;
|
|
795
|
-
if (aiPlanEl.open) localStorage.setItem(`mab_ai_plan_open:${id}`, "1");
|
|
796
|
-
else localStorage.removeItem(`mab_ai_plan_open:${id}`);
|
|
797
|
-
});
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
drawerInner.querySelectorAll("[data-open-artifact]").forEach((btn) => {
|
|
801
|
-
btn.addEventListener("click", async (event) => {
|
|
802
|
-
event.stopPropagation();
|
|
803
|
-
const artifactFieldName = btn.dataset.artifactField;
|
|
804
|
-
const artifactTitle = btn.dataset.artifactTitle || "Linked artifact";
|
|
805
|
-
const artifactTicketId = btn.dataset.artifactTicketId || ticket.id;
|
|
806
|
-
const currentPath = String(btn.dataset.openArtifact || "").trim();
|
|
807
|
-
if (!currentPath) {
|
|
808
|
-
await promptAndSaveArtifactPath({
|
|
809
|
-
ticketId: artifactTicketId,
|
|
810
|
-
fieldName: artifactFieldName,
|
|
811
|
-
currentPath,
|
|
812
|
-
label: artifactTitle,
|
|
813
|
-
});
|
|
814
|
-
return;
|
|
815
|
-
}
|
|
816
|
-
const opened = await openArtifactDrawer({
|
|
817
|
-
ticketId: artifactTicketId,
|
|
818
|
-
path: currentPath,
|
|
819
|
-
title: artifactTitle,
|
|
820
|
-
});
|
|
821
|
-
if (opened === false && artifactFieldName) {
|
|
822
|
-
await promptAndSaveArtifactPath({
|
|
823
|
-
ticketId: artifactTicketId,
|
|
824
|
-
fieldName: artifactFieldName,
|
|
825
|
-
currentPath,
|
|
826
|
-
label: artifactTitle,
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
});
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
const parentEpicForm = drawerInner.querySelector("#parentEpicForm");
|
|
833
|
-
if (parentEpicForm) {
|
|
834
|
-
const parentInput = parentEpicForm.querySelector("[name=target]");
|
|
835
|
-
const parentClear = parentEpicForm.querySelector(
|
|
836
|
-
"[data-parent-epic-clear]",
|
|
837
|
-
);
|
|
838
|
-
attachTicketTypeahead(parentInput, () => epicCandidates(ticket));
|
|
839
|
-
const syncParentClear = () => {
|
|
840
|
-
parentEpicForm.classList.toggle(
|
|
841
|
-
"has-value",
|
|
842
|
-
parentInput.value.length > 0,
|
|
843
|
-
);
|
|
844
|
-
};
|
|
845
|
-
parentInput.addEventListener("input", syncParentClear);
|
|
846
|
-
syncParentClear();
|
|
847
|
-
parentClear.addEventListener("click", () => {
|
|
848
|
-
parentInput.value = "";
|
|
849
|
-
syncParentClear();
|
|
850
|
-
parentInput.focus();
|
|
851
|
-
});
|
|
852
|
-
parentEpicForm.addEventListener("submit", async (event) => {
|
|
853
|
-
event.preventDefault();
|
|
854
|
-
const targetLabel = String(
|
|
855
|
-
new FormData(event.currentTarget).get("target") || "",
|
|
856
|
-
).trim();
|
|
857
|
-
if (!targetLabel) return;
|
|
858
|
-
const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
|
|
859
|
-
if (!targetId) {
|
|
860
|
-
toast.warning("Pick an epic from the suggestions");
|
|
861
|
-
return;
|
|
862
|
-
}
|
|
863
|
-
const target = ticketsForProject().find((t) => t.id === targetId);
|
|
864
|
-
if (!target || canonicalTicketType(target.type) !== "epic") {
|
|
865
|
-
toast.warning("Parent must be an epic");
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
try {
|
|
869
|
-
await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
|
|
870
|
-
method: "PATCH",
|
|
871
|
-
body: { parent_ticket_id: targetId },
|
|
872
|
-
});
|
|
873
|
-
} catch (err) {
|
|
874
|
-
toast.error(err.message || "Failed to set parent epic");
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
|
|
878
|
-
toast.success("Parent epic set");
|
|
879
|
-
});
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
const relatedAddForm = drawerInner.querySelector("#relatedAddForm");
|
|
883
|
-
if (relatedAddForm) {
|
|
884
|
-
const relatedInput = relatedAddForm.querySelector("[name=target]");
|
|
885
|
-
const relatedClear = relatedAddForm.querySelector("[data-related-clear]");
|
|
886
|
-
attachTicketTypeahead(relatedInput, () =>
|
|
887
|
-
relatedTicketCandidates(context.relations, ticket),
|
|
888
|
-
);
|
|
889
|
-
const syncRelatedClear = () => {
|
|
890
|
-
relatedAddForm.classList.toggle(
|
|
891
|
-
"has-value",
|
|
892
|
-
relatedInput.value.length > 0,
|
|
893
|
-
);
|
|
894
|
-
};
|
|
895
|
-
relatedInput.addEventListener("input", syncRelatedClear);
|
|
896
|
-
syncRelatedClear();
|
|
897
|
-
relatedClear.addEventListener("click", () => {
|
|
898
|
-
relatedInput.value = "";
|
|
899
|
-
syncRelatedClear();
|
|
900
|
-
relatedInput.focus();
|
|
901
|
-
});
|
|
902
|
-
relatedAddForm.addEventListener("submit", async (event) => {
|
|
903
|
-
event.preventDefault();
|
|
904
|
-
const form = new FormData(event.currentTarget);
|
|
905
|
-
const targetLabel = String(form.get("target") || "").trim();
|
|
906
|
-
const relationType = String(form.get("type") || "relates_to");
|
|
907
|
-
if (!targetLabel) return;
|
|
908
|
-
const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
|
|
909
|
-
if (!targetId) {
|
|
910
|
-
toast.warning("Pick a ticket from the suggestions");
|
|
911
|
-
return;
|
|
912
|
-
}
|
|
913
|
-
try {
|
|
914
|
-
await api(withBoardQuery(`/api/relations`), {
|
|
915
|
-
method: "POST",
|
|
916
|
-
body: {
|
|
917
|
-
source_ticket_id: ticket.id,
|
|
918
|
-
target_ticket_id: targetId,
|
|
919
|
-
type: relationType,
|
|
920
|
-
},
|
|
921
|
-
});
|
|
922
|
-
} catch (err) {
|
|
923
|
-
toast.error(err.message || "Failed to add relation");
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
await refreshTicketDetail(ticket.id);
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
// Reverse of "Set parent epic": from an epic, pull a feature/task in as a
|
|
931
|
-
// child by patching that ticket's parent_ticket_id to this epic.
|
|
932
|
-
const epicChildForm = drawerInner.querySelector("#epicChildForm");
|
|
933
|
-
if (epicChildForm) {
|
|
934
|
-
const childInput = epicChildForm.querySelector("[name=target]");
|
|
935
|
-
const childClear = epicChildForm.querySelector("[data-epic-child-clear]");
|
|
936
|
-
const syncChildClear = () => {
|
|
937
|
-
epicChildForm.classList.toggle("has-value", childInput.value.length > 0);
|
|
938
|
-
};
|
|
939
|
-
childInput.addEventListener("input", syncChildClear);
|
|
940
|
-
syncChildClear();
|
|
941
|
-
childClear.addEventListener("click", () => {
|
|
942
|
-
childInput.value = "";
|
|
943
|
-
syncChildClear();
|
|
944
|
-
childInput.focus();
|
|
945
|
-
});
|
|
946
|
-
attachTicketTypeahead(childInput, () => childCandidates(ticket));
|
|
947
|
-
epicChildForm.addEventListener("submit", async (event) => {
|
|
948
|
-
event.preventDefault();
|
|
949
|
-
const targetLabel = String(
|
|
950
|
-
new FormData(event.currentTarget).get("target") || "",
|
|
951
|
-
).trim();
|
|
952
|
-
if (!targetLabel) return;
|
|
953
|
-
const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
|
|
954
|
-
if (!targetId) {
|
|
955
|
-
toast.warning("Pick a ticket from the suggestions");
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
const target = ticketsForProject().find((t) => t.id === targetId);
|
|
959
|
-
if (!target || canonicalTicketType(target.type) === "epic") {
|
|
960
|
-
toast.warning("Epics can't be nested inside an epic");
|
|
961
|
-
return;
|
|
962
|
-
}
|
|
963
|
-
try {
|
|
964
|
-
await api(withBoardQuery(`/api/tickets/${targetId}`), {
|
|
965
|
-
method: "PATCH",
|
|
966
|
-
body: { parent_ticket_id: ticket.id },
|
|
967
|
-
});
|
|
968
|
-
} catch (err) {
|
|
969
|
-
toast.error(err.message || "Failed to add feature");
|
|
970
|
-
return;
|
|
971
|
-
}
|
|
972
|
-
await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
|
|
973
|
-
toast.success("Feature added to epic");
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
openDrawer();
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
function aiPlanOpen(ticketId) {
|
|
981
|
-
return localStorage.getItem(`mab_ai_plan_open:${ticketId}`) === "1";
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
function renderAttachmentSection(ticket, attachments = []) {
|
|
985
|
-
const cards = attachments.length
|
|
986
|
-
? `<div class="attachment-grid">${attachments.map((attachment) => renderAttachmentCard(ticket, attachment)).join("")}</div>`
|
|
987
|
-
: `<p class="description attachment-empty-state">No images attached yet.</p>`;
|
|
988
|
-
return renderPanel({
|
|
989
|
-
title: "Images",
|
|
990
|
-
className: "attachments-panel",
|
|
991
|
-
attrs: `data-attachment-section data-ticket-id="${escapeHtml(ticket.id)}"`,
|
|
992
|
-
body: `
|
|
993
|
-
<div class="settings-row">
|
|
994
|
-
<div class="attachment-dropzone" data-attachment-dropzone tabindex="0">
|
|
995
|
-
<strong>Paste, drag, or upload images</strong>
|
|
996
|
-
<span>PNG, JPG, GIF, WebP, SVG, BMP, or AVIF up to 10 MB each.</span>
|
|
997
|
-
</div>
|
|
998
|
-
${cards}
|
|
999
|
-
<label class="attachment-upload-button detail-action-btn" role="button" data-variant="ghost" tabindex="0">
|
|
1000
|
-
Upload
|
|
1001
|
-
<input type="file" accept="image/*" multiple data-attachment-input />
|
|
1002
|
-
</label>
|
|
1003
|
-
</div>
|
|
1004
|
-
`,
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
function renderAttachmentCard(ticket, attachment) {
|
|
1009
|
-
const name = escapeHtml(attachment.original_name || "image");
|
|
1010
|
-
const meta = `${formatBytes(attachment.size_bytes)} · ${escapeHtml(attachment.mime_type || "image")}`;
|
|
1011
|
-
if (attachment.missing) {
|
|
1012
|
-
return `
|
|
1013
|
-
<article class="attachment-card is-missing">
|
|
1014
|
-
<div class="attachment-missing-thumb">Missing</div>
|
|
1015
|
-
<div class="attachment-card-meta"><strong>${name}</strong><span>${meta}</span></div>
|
|
1016
|
-
<button type="button" class="attachment-delete" data-delete-attachment="${escapeHtml(attachment.id)}" aria-label="Remove missing image">${closeIconSvg}</button>
|
|
1017
|
-
</article>
|
|
1018
|
-
`;
|
|
1019
|
-
}
|
|
1020
|
-
const src = withBoardQuery(
|
|
1021
|
-
attachment.content_url ||
|
|
1022
|
-
`/api/tickets/${ticket.id}/attachments/${attachment.id}/content`,
|
|
1023
|
-
);
|
|
1024
|
-
return `
|
|
1025
|
-
<article class="attachment-card">
|
|
1026
|
-
<button type="button" class="attachment-thumb-button" data-no-arc data-open-lightbox="${escapeHtml(src)}" data-lightbox-title="${name}">
|
|
1027
|
-
<img src="${escapeHtml(src)}" alt="${name}" loading="lazy" />
|
|
1028
|
-
</button>
|
|
1029
|
-
<div class="attachment-card-meta"><strong>${name}</strong><span>${meta}</span></div>
|
|
1030
|
-
<button type="button" class="attachment-delete" data-delete-attachment="${escapeHtml(attachment.id)}" aria-label="Remove image">${closeIconSvg}</button>
|
|
1031
|
-
</article>
|
|
1032
|
-
`;
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
function formatBytes(value) {
|
|
1036
|
-
const bytes = Number(value || 0);
|
|
1037
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
1038
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1039
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
function imageFilesFromList(files) {
|
|
1043
|
-
return [...(files || [])].filter(
|
|
1044
|
-
(file) => file && String(file.type || "").startsWith("image/"),
|
|
1045
|
-
);
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
async function uploadAttachmentFiles(ticket, files) {
|
|
1049
|
-
const imageFiles = imageFilesFromList(files);
|
|
1050
|
-
if (!imageFiles.length) {
|
|
1051
|
-
toast.warning("No image files found");
|
|
1052
|
-
return;
|
|
1053
|
-
}
|
|
1054
|
-
for (const file of imageFiles) {
|
|
1055
|
-
const response = await fetch(
|
|
1056
|
-
withBoardQuery(
|
|
1057
|
-
`/api/tickets/${ticket.id}/attachments?filename=${encodeURIComponent(file.name || "image")}`,
|
|
1058
|
-
),
|
|
1059
|
-
{
|
|
1060
|
-
method: "POST",
|
|
1061
|
-
headers: {
|
|
1062
|
-
"Content-Type": file.type || "application/octet-stream",
|
|
1063
|
-
"X-File-Name": file.name || "image",
|
|
1064
|
-
},
|
|
1065
|
-
body: file,
|
|
1066
|
-
},
|
|
1067
|
-
);
|
|
1068
|
-
if (!response.ok) {
|
|
1069
|
-
let payload = {};
|
|
1070
|
-
try {
|
|
1071
|
-
payload = await response.json();
|
|
1072
|
-
} catch {}
|
|
1073
|
-
throw new Error(payload.error || `Upload failed (${response.status})`);
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
await refreshTicketDetail(ticket.id);
|
|
1077
|
-
toast.success(
|
|
1078
|
-
imageFiles.length === 1
|
|
1079
|
-
? "Image attached"
|
|
1080
|
-
: `${imageFiles.length} images attached`,
|
|
1081
|
-
);
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
function wireAttachmentControls(ticket) {
|
|
1085
|
-
const section = drawerInner.querySelector("[data-attachment-section]");
|
|
1086
|
-
if (!section) return;
|
|
1087
|
-
const input = section.querySelector("[data-attachment-input]");
|
|
1088
|
-
input?.addEventListener("change", async () => {
|
|
1089
|
-
try {
|
|
1090
|
-
await uploadAttachmentFiles(ticket, input.files);
|
|
1091
|
-
input.value = "";
|
|
1092
|
-
} catch (err) {
|
|
1093
|
-
toast.error(err.message || "Upload failed");
|
|
1094
|
-
}
|
|
1095
|
-
});
|
|
1096
|
-
|
|
1097
|
-
const dropzone = section.querySelector("[data-attachment-dropzone]");
|
|
1098
|
-
dropzone?.addEventListener("dragover", (event) => {
|
|
1099
|
-
event.preventDefault();
|
|
1100
|
-
dropzone.classList.add("is-dragover");
|
|
1101
|
-
});
|
|
1102
|
-
dropzone?.addEventListener("dragleave", () =>
|
|
1103
|
-
dropzone.classList.remove("is-dragover"),
|
|
1104
|
-
);
|
|
1105
|
-
dropzone?.addEventListener("drop", async (event) => {
|
|
1106
|
-
event.preventDefault();
|
|
1107
|
-
dropzone.classList.remove("is-dragover");
|
|
1108
|
-
try {
|
|
1109
|
-
await uploadAttachmentFiles(ticket, event.dataTransfer?.files || []);
|
|
1110
|
-
} catch (err) {
|
|
1111
|
-
toast.error(err.message || "Upload failed");
|
|
1112
|
-
}
|
|
1113
|
-
});
|
|
1114
|
-
|
|
1115
|
-
drawerInner.onpaste = async (event) => {
|
|
1116
|
-
if (event.target.closest("input, textarea, [contenteditable='true']"))
|
|
1117
|
-
return;
|
|
1118
|
-
const files = imageFilesFromList(event.clipboardData?.files || []);
|
|
1119
|
-
if (!files.length) return;
|
|
1120
|
-
event.preventDefault();
|
|
1121
|
-
try {
|
|
1122
|
-
await uploadAttachmentFiles(ticket, files);
|
|
1123
|
-
} catch (err) {
|
|
1124
|
-
toast.error(err.message || "Paste upload failed");
|
|
1125
|
-
}
|
|
1126
|
-
};
|
|
1127
|
-
|
|
1128
|
-
section.querySelectorAll("[data-delete-attachment]").forEach((btn) => {
|
|
1129
|
-
btn.addEventListener("click", async () => {
|
|
1130
|
-
try {
|
|
1131
|
-
await api(
|
|
1132
|
-
withBoardQuery(
|
|
1133
|
-
`/api/tickets/${ticket.id}/attachments/${btn.dataset.deleteAttachment}`,
|
|
1134
|
-
),
|
|
1135
|
-
{ method: "DELETE" },
|
|
1136
|
-
);
|
|
1137
|
-
await refreshTicketDetail(ticket.id);
|
|
1138
|
-
toast.success("Image removed");
|
|
1139
|
-
} catch (err) {
|
|
1140
|
-
toast.error(err.message || "Delete failed");
|
|
1141
|
-
}
|
|
1142
|
-
});
|
|
1143
|
-
});
|
|
1144
|
-
|
|
1145
|
-
section.querySelectorAll("[data-open-lightbox]").forEach((btn) => {
|
|
1146
|
-
btn.addEventListener("click", () =>
|
|
1147
|
-
openAttachmentLightbox(
|
|
1148
|
-
btn.dataset.openLightbox,
|
|
1149
|
-
btn.dataset.lightboxTitle || "Image",
|
|
1150
|
-
),
|
|
1151
|
-
);
|
|
1152
|
-
});
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
function openAttachmentLightbox(src, title) {
|
|
1156
|
-
const existing = document.querySelector(".attachment-lightbox");
|
|
1157
|
-
existing?.remove();
|
|
1158
|
-
const overlay = document.createElement("div");
|
|
1159
|
-
overlay.className = "attachment-lightbox";
|
|
1160
|
-
overlay.innerHTML = `
|
|
1161
|
-
<div class="attachment-lightbox-panel" role="dialog" aria-modal="true" aria-label="${escapeHtml(title)}">
|
|
1162
|
-
<button type="button" class="attachment-lightbox-close" aria-label="Close image">${closeIconSvg}</button>
|
|
1163
|
-
<img src="${escapeHtml(src)}" alt="${escapeHtml(title)}" />
|
|
1164
|
-
<p>${escapeHtml(title)}</p>
|
|
1165
|
-
</div>
|
|
1166
|
-
`;
|
|
1167
|
-
const close = () => {
|
|
1168
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
1169
|
-
overlay.remove();
|
|
1170
|
-
};
|
|
1171
|
-
function onKeyDown(event) {
|
|
1172
|
-
if (event.key === "Escape") close();
|
|
1173
|
-
}
|
|
1174
|
-
overlay.addEventListener("click", (event) => {
|
|
1175
|
-
if (
|
|
1176
|
-
event.target === overlay ||
|
|
1177
|
-
event.target.closest(".attachment-lightbox-close")
|
|
1178
|
-
)
|
|
1179
|
-
close();
|
|
1180
|
-
});
|
|
1181
|
-
document.addEventListener("keydown", onKeyDown);
|
|
1182
|
-
document.body.append(overlay);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
function resolveTicketIdFromLabel(label, selfId) {
|
|
1186
|
-
const trimmed = label.trim();
|
|
1187
|
-
const match = trimmed.match(/^#?(\d+)\b/);
|
|
1188
|
-
const tickets = ticketsForProject().filter((t) => t.id !== selfId);
|
|
1189
|
-
if (match) {
|
|
1190
|
-
const n = Number(match[1]);
|
|
1191
|
-
const hit = tickets.find((t) => t.number === n);
|
|
1192
|
-
if (hit) return hit.id;
|
|
1193
|
-
}
|
|
1194
|
-
const exact = tickets.find((t) => `#${t.number} — ${t.title}` === trimmed);
|
|
1195
|
-
return exact ? exact.id : "";
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
function renderStatusHistory(history) {
|
|
1199
|
-
if (!history || history.length === 0) {
|
|
1200
|
-
return `<p class="description">No status changes yet.</p>`;
|
|
1201
|
-
}
|
|
1202
|
-
return history
|
|
1203
|
-
.map((event) => {
|
|
1204
|
-
const body = event.body;
|
|
1205
|
-
const from = body.from ? escapeHtml(body.from) : "?";
|
|
1206
|
-
const to = escapeHtml(body.to || body.state || body.next_state || "?");
|
|
1207
|
-
const byLine = escapeHtml(
|
|
1208
|
-
formatActorLabel({
|
|
1209
|
-
actor: event.actor,
|
|
1210
|
-
actorType: body.actor_type || "human",
|
|
1211
|
-
actorId: body.actor_id,
|
|
1212
|
-
}),
|
|
1213
|
-
);
|
|
1214
|
-
return `<div class="comment status-change">
|
|
1215
|
-
<div class="comment-meta">
|
|
1216
|
-
<span class="status-route">${from} → ${to}</span>
|
|
1217
|
-
<span>${byLine} · ${formatDate(event.created_at)}</span>
|
|
1218
|
-
</div>
|
|
1219
|
-
</div>`;
|
|
1220
|
-
})
|
|
1221
|
-
.join("");
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
function renderComment(comment) {
|
|
1225
|
-
const aiEnabled = currentBoard()?.ai_enabled !== 0;
|
|
1226
|
-
const omittedForAi = Boolean(comment.omitted_for_ai);
|
|
1227
|
-
const kindLabel =
|
|
1228
|
-
comment.kind === "human_comment" ? "" : escapeHtml(comment.kind.replace(/_/g, " "));
|
|
1229
|
-
return `
|
|
1230
|
-
<div class="comment ${comment.kind === "checkpoint" ? "checkpoint" : ""} ${aiEnabled && omittedForAi ? "is-ai-omitted" : ""}">
|
|
1231
|
-
<div class="comment-head">
|
|
1232
|
-
<div class="comment-head-main">
|
|
1233
|
-
<div class="comment-head-title">
|
|
1234
|
-
<strong>${escapeHtml(formatCommentAuthor(comment))}</strong>
|
|
1235
|
-
${kindLabel ? `<span class="comment-kind-pill">${kindLabel}</span>` : ""}
|
|
1236
|
-
</div>
|
|
1237
|
-
</div>
|
|
1238
|
-
</div>
|
|
1239
|
-
<div class="comment-body preserved-text-body">${renderPreservedText(comment.body)}</div>
|
|
1240
|
-
<div class="comment-footer">
|
|
1241
|
-
<span class="comment-footer-meta">${formatDate(comment.created_at)}</span>
|
|
1242
|
-
${
|
|
1243
|
-
aiEnabled
|
|
1244
|
-
? `<button
|
|
1245
|
-
type="button"
|
|
1246
|
-
class="comment-ai-toggle detail-action-btn"
|
|
1247
|
-
data-variant="ghost"
|
|
1248
|
-
data-comment-action="toggle-ai-omission"
|
|
1249
|
-
data-comment-id="${escapeHtml(comment.id)}"
|
|
1250
|
-
data-omitted-for-ai="${omittedForAi ? "true" : "false"}"
|
|
1251
|
-
>${omittedForAi ? "AI Include" : "AI Ignore"}</button>`
|
|
1252
|
-
: ""
|
|
1253
|
-
}
|
|
1254
|
-
</div>
|
|
1255
|
-
</div>
|
|
1256
|
-
`;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
/**
|
|
1260
|
-
* Click-to-edit row for AI Plan / Implementation fields and any other
|
|
1261
|
-
* long-form ticket field that should render literal read-only text and become
|
|
1262
|
-
* a textarea on click. The wiring (event handlers + commit) lives in
|
|
1263
|
-
* wireTicketDetailEditors so this helper stays a pure template.
|
|
1264
|
-
*/
|
|
1265
|
-
export function renderInlinePreservedTextField({
|
|
1266
|
-
fieldName,
|
|
1267
|
-
label,
|
|
1268
|
-
value,
|
|
1269
|
-
placeholder,
|
|
1270
|
-
ticketId,
|
|
1271
|
-
artifactPath,
|
|
1272
|
-
artifactFieldName,
|
|
1273
|
-
artifactLabel,
|
|
1274
|
-
}) {
|
|
1275
|
-
const text = value || "";
|
|
1276
|
-
const hasValue = Boolean(text.trim());
|
|
1277
|
-
const artifactPathText = String(artifactPath || "").trim();
|
|
1278
|
-
const hasArtifactControl = Boolean(artifactLabel || artifactFieldName);
|
|
1279
|
-
const inner = hasValue
|
|
1280
|
-
? renderPreservedText(text)
|
|
1281
|
-
: escapeHtml(placeholder || "");
|
|
1282
|
-
const placeholderClass = hasValue ? "" : "is-placeholder";
|
|
1283
|
-
const artifactButton = hasArtifactControl
|
|
1284
|
-
? `<button
|
|
1285
|
-
type="button"
|
|
1286
|
-
class="detail-action-btn"
|
|
1287
|
-
data-variant="ghost"
|
|
1288
|
-
data-open-artifact="${escapeHtml(artifactPathText)}"
|
|
1289
|
-
data-artifact-field="${escapeHtml(artifactFieldName || "")}"
|
|
1290
|
-
data-artifact-ticket-id="${escapeHtml(ticketId || "")}"
|
|
1291
|
-
data-artifact-title="${escapeHtml(label)}"
|
|
1292
|
-
>${escapeHtml(artifactLabel || "Open full artifact")}</button>`
|
|
1293
|
-
: "";
|
|
1294
|
-
return `
|
|
1295
|
-
<div class="inline-md-field">
|
|
1296
|
-
<div class="inline-md-field-head">
|
|
1297
|
-
<span class="inline-md-field-label">${escapeHtml(label)}</span>
|
|
1298
|
-
${artifactButton}
|
|
1299
|
-
</div>
|
|
1300
|
-
<div
|
|
1301
|
-
class="inline-md-field-body preserved-text-body editable-field ${placeholderClass}"
|
|
1302
|
-
data-edit-field="${escapeHtml(fieldName)}"
|
|
1303
|
-
tabindex="0"
|
|
1304
|
-
title="Click to edit"
|
|
1305
|
-
role="button"
|
|
1306
|
-
aria-label="Edit ${escapeHtml(label)}"
|
|
1307
|
-
>${inner}</div>
|
|
1308
|
-
</div>
|
|
1309
|
-
`;
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
// Tickets eligible to be linked as "related": everything in the project except
|
|
1313
|
-
// the ticket itself, its existing relations, and its parent/child hierarchy.
|
|
1314
|
-
function relatedTicketCandidates(relations = [], ticket) {
|
|
1315
|
-
const relatedIds = new Set(relations.map((r) => r.other_ticket.id));
|
|
1316
|
-
const hierarchyIds = new Set();
|
|
1317
|
-
if (ticket?.parent_ticket_id) hierarchyIds.add(ticket.parent_ticket_id);
|
|
1318
|
-
if (ticket?.id) {
|
|
1319
|
-
for (const t of ticketsForProject()) {
|
|
1320
|
-
if (t.parent_ticket_id === ticket.id) hierarchyIds.add(t.id);
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
return ticketsForProject().filter(
|
|
1324
|
-
(t) =>
|
|
1325
|
-
t.id !== ticket?.id && !relatedIds.has(t.id) && !hierarchyIds.has(t.id),
|
|
1326
|
-
);
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
function renderRelated(relations = [], ticket) {
|
|
1330
|
-
const list = relations.length
|
|
1331
|
-
? `<div class="detail-card-grid">${relations.map((r) => renderRelatedCard(r)).join("")}</div>`
|
|
1332
|
-
: "";
|
|
1333
|
-
|
|
1334
|
-
const form = `
|
|
1335
|
-
<form id="relatedAddForm" class="related-add-row">
|
|
1336
|
-
<div class="related-add-input-wrap">
|
|
1337
|
-
<input type="text" name="target" class="related-add-input meta-inline-input" placeholder="Add related ticket…" aria-label="Add related ticket" autocomplete="off" />
|
|
1338
|
-
<button type="button" class="related-add-clear" data-related-clear aria-label="Clear" tabindex="-1">${closeIconSvg}</button>
|
|
1339
|
-
</div>
|
|
1340
|
-
<select name="type" class="related-add-type meta-inline" aria-label="Relation type">
|
|
1341
|
-
<option value="relates_to">relates to</option>
|
|
1342
|
-
<option value="blocks">blocks</option>
|
|
1343
|
-
<option value="blocked_by">blocked by</option>
|
|
1344
|
-
</select>
|
|
1345
|
-
<button type="submit" class="meta-add-btn detail-action-btn" data-variant="ghost">Add</button>
|
|
1346
|
-
</form>
|
|
1347
|
-
`;
|
|
1348
|
-
|
|
1349
|
-
return list + form;
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
function renderHierarchySection(ticket, context) {
|
|
1353
|
-
const isEpic = canonicalTicketType(ticket.type) === "epic";
|
|
1354
|
-
if (!isEpic) return "";
|
|
1355
|
-
|
|
1356
|
-
const cards = context.child_tickets || [];
|
|
1357
|
-
const grid = cards.length
|
|
1358
|
-
? `<div class="detail-card-grid">${cards.map((c) => renderDetailCard(c, { detach: { mode: "child", id: c.id } })).join("")}</div>`
|
|
1359
|
-
: "";
|
|
1360
|
-
|
|
1361
|
-
const form = `
|
|
1362
|
-
<form id="epicChildForm" class="related-add-row epic-child-form">
|
|
1363
|
-
<div class="related-add-input-wrap">
|
|
1364
|
-
<input type="text" name="target" class="related-add-input meta-inline-input" placeholder="Add feature to epic…" aria-label="Add feature to epic" autocomplete="off" />
|
|
1365
|
-
<button type="button" class="related-add-clear" data-epic-child-clear aria-label="Clear" tabindex="-1">${closeIconSvg}</button>
|
|
1366
|
-
</div>
|
|
1367
|
-
<button type="submit" class="meta-add-btn detail-action-btn" data-variant="ghost">Add</button>
|
|
1368
|
-
</form>
|
|
1369
|
-
`;
|
|
1370
|
-
|
|
1371
|
-
return renderPanel({
|
|
1372
|
-
title: "Features inside this Epic",
|
|
1373
|
-
body: `<div class="settings-row">${grid}${form}</div>`,
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
// Epics eligible to become this ticket's parent (any epic but itself).
|
|
1378
|
-
function epicCandidates(ticket) {
|
|
1379
|
-
return ticketsForProject().filter(
|
|
1380
|
-
(t) => t.type === "epic" && t.id !== ticket.id,
|
|
1381
|
-
);
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
// Tickets eligible to be pulled into this epic as children: any non-epic ticket
|
|
1385
|
-
// not already parented to it. (A ticket with another parent is reparented.)
|
|
1386
|
-
function childCandidates(epic) {
|
|
1387
|
-
return ticketsForProject().filter(
|
|
1388
|
-
(t) =>
|
|
1389
|
-
t.id !== epic.id &&
|
|
1390
|
-
canonicalTicketType(t.type) !== "epic" &&
|
|
1391
|
-
t.parent_ticket_id !== epic.id,
|
|
1392
|
-
);
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
// Board labels not yet applied to this ticket, as {name, color} items.
|
|
1397
|
-
function labelCandidates(ticket) {
|
|
1398
|
-
const applied = new Set((ticket.labels || []).map((l) => l.name));
|
|
1399
|
-
return boardLabelCatalog().filter((l) => !applied.has(l.name));
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
// A typeahead row for a label: colour swatch + bold name. Mirrors the topbar
|
|
1403
|
-
// hit layout (bold lead + trailing badge), adapted for a label's lack of a
|
|
1404
|
-
// number / state.
|
|
1405
|
-
function renderLabelHitRow(label) {
|
|
1406
|
-
return `
|
|
1407
|
-
<span class="search-hit-main">
|
|
1408
|
-
<span class="typeahead-swatch" style="--label-color: ${escapeHtml(label.color || "var(--muted)")}"></span>
|
|
1409
|
-
<strong>${escapeHtml(label.name)}</strong>
|
|
1410
|
-
</span>
|
|
1411
|
-
`;
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
function attachLabelTypeahead(input, ticket) {
|
|
1415
|
-
if (!input) return;
|
|
1416
|
-
attachTypeahead(input, {
|
|
1417
|
-
getItems: () => labelCandidates(ticket),
|
|
1418
|
-
match: (label, query) => label.name.toLowerCase().includes(query),
|
|
1419
|
-
renderItem: renderLabelHitRow,
|
|
1420
|
-
valueOf: (label) => label.name,
|
|
1421
|
-
emptyText: "No matching labels",
|
|
1422
|
-
});
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
function renderParentEpicSection(ticket, context) {
|
|
1426
|
-
if (canonicalTicketType(ticket.type) === "epic") return "";
|
|
1427
|
-
|
|
1428
|
-
const body = context.parent_ticket
|
|
1429
|
-
? `<div class="detail-card-grid detail-card-grid--single">${renderDetailCard(context.parent_ticket, { detach: { mode: "parent" } })}</div>`
|
|
1430
|
-
: `
|
|
1431
|
-
<form id="parentEpicForm" class="related-add-row parent-epic-form">
|
|
1432
|
-
<div class="related-add-input-wrap">
|
|
1433
|
-
<input type="text" name="target" class="related-add-input meta-inline-input" placeholder="Set parent epic…" aria-label="Set parent epic" autocomplete="off" />
|
|
1434
|
-
<button type="button" class="related-add-clear" data-parent-epic-clear aria-label="Clear" tabindex="-1">${closeIconSvg}</button>
|
|
1435
|
-
</div>
|
|
1436
|
-
<button type="submit" class="meta-add-btn detail-action-btn" data-variant="ghost">Set</button>
|
|
1437
|
-
</form>
|
|
1438
|
-
`;
|
|
1439
|
-
|
|
1440
|
-
return renderPanel({
|
|
1441
|
-
title: "Epic",
|
|
1442
|
-
body: `<div class="settings-row">${body}</div>`,
|
|
1443
|
-
});
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
export function renderDetailCard(ticket, options = {}) {
|
|
1447
|
-
const canonicalType = canonicalTicketType(ticket.type);
|
|
1448
|
-
const priorityKey = priorityKeyFor(ticket.priority);
|
|
1449
|
-
const stateClass = stateClassFor(ticket);
|
|
1450
|
-
const typeText = typeLabel(ticket.type).toUpperCase();
|
|
1451
|
-
const eyebrow = options.eyebrow
|
|
1452
|
-
? `<span class="card-eyebrow">${escapeHtml(options.eyebrow)}</span>`
|
|
1453
|
-
: "";
|
|
1454
|
-
let detachBtn = "";
|
|
1455
|
-
if (options.detach) {
|
|
1456
|
-
const { mode, id } = options.detach;
|
|
1457
|
-
let attr = "";
|
|
1458
|
-
let label = "";
|
|
1459
|
-
if (mode === "child") {
|
|
1460
|
-
attr = `data-detach-child="${escapeHtml(id)}"`;
|
|
1461
|
-
label = "Remove from epic";
|
|
1462
|
-
} else if (mode === "parent") {
|
|
1463
|
-
attr = `data-detach-parent="1"`;
|
|
1464
|
-
label = "Remove parent epic";
|
|
1465
|
-
} else if (mode === "relation") {
|
|
1466
|
-
attr = `data-detach-relation="${escapeHtml(id)}"`;
|
|
1467
|
-
label = "Remove relation";
|
|
1468
|
-
}
|
|
1469
|
-
detachBtn = `<button type="button" class="card-detach-btn" ${attr} aria-label="${label}" title="${label}">${closeIconSvg}</button>`;
|
|
1470
|
-
} else if (options.archive) {
|
|
1471
|
-
const tid = escapeHtml(options.archive.id);
|
|
1472
|
-
const restoreSvg = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>`;
|
|
1473
|
-
const trashSvg = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>`;
|
|
1474
|
-
detachBtn = `
|
|
1475
|
-
<div class="card-archive-actions">
|
|
1476
|
-
<button type="button" class="card-archive-btn card-archive-btn--restore" data-archive-restore="${tid}" aria-label="Restore to board" title="Restore to board">${restoreSvg}</button>
|
|
1477
|
-
<button type="button" class="card-archive-btn card-archive-btn--delete" data-archive-delete="${tid}" aria-label="Delete permanently" title="Delete permanently">${trashSvg}</button>
|
|
1478
|
-
</div>
|
|
1479
|
-
`;
|
|
1480
|
-
}
|
|
1481
|
-
const unreadN = unreadCount(ticket);
|
|
1482
|
-
const unreadDot =
|
|
1483
|
-
unreadN === 0
|
|
1484
|
-
? ""
|
|
1485
|
-
: unreadN === 1
|
|
1486
|
-
? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
|
|
1487
|
-
: `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
|
|
1488
|
-
return `
|
|
1489
|
-
<article class="card card--detail type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}" data-variant="${escapeHtml(canonicalType)}"${options.disableOpen ? "" : ` data-open-ticket="${escapeHtml(ticket.id)}"`}>
|
|
1490
|
-
${unreadDot}
|
|
1491
|
-
${eyebrow}
|
|
1492
|
-
<h3>${escapeHtml(ticket.title)}</h3>
|
|
1493
|
-
<div class="card-meta">
|
|
1494
|
-
<div class="card-expand-trigger card-expand-trigger--static">
|
|
1495
|
-
<span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
|
|
1496
|
-
<div class="card-type-id type-pill-${escapeHtml(canonicalType)}">
|
|
1497
|
-
${renderTypeIcon(ticket.type)}
|
|
1498
|
-
<span class="card-type-label">${escapeHtml(typeText)}</span>
|
|
1499
|
-
</div>
|
|
1500
|
-
<span class="detail-card-state" data-variant="${escapeHtml(stateClass)}">${escapeHtml(ticket.state_name || "State")}</span>
|
|
1501
|
-
${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
|
|
1502
|
-
${detachBtn}
|
|
1503
|
-
</div>
|
|
1504
|
-
</div>
|
|
1505
|
-
</article>
|
|
1506
|
-
`;
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
function renderRelatedCard(relation) {
|
|
1510
|
-
const verb = String(relation.type || "").replace(/_/g, " ");
|
|
1511
|
-
const direction = relation.direction === "incoming" ? "from" : "to";
|
|
1512
|
-
return renderDetailCard(relation.other_ticket, {
|
|
1513
|
-
eyebrow: `${verb} ${direction}`,
|
|
1514
|
-
detach: { mode: "relation", id: relation.id },
|
|
1515
|
-
});
|
|
1516
|
-
}
|