tickets
All repositories: gitoria
20.7 KB
// components/ticket.hl — `/projects/:projectSlug/:ticketNumber`: one ticket, its// append-only event history, a comment form and the state change. (Old global URLs// `/tickets/<n>` are 301-redirected here by project.hl.) Live: an event on THIS ticket (from any browser or// the API) is appended here and the state badge follows, without a reload.// Ticket #6 (mission 014): the summary, comments and state notes are MARKDOWN, rendered by the// child component Markdown (components/markdown.hl) from markdown.hl's blocks — safe: no HTML// string, links checked. Ticket #8: the ticket's AUTHOR (a ticket from before the login: the// creator) gets "Edit" — subject + summary; the edit is a history event that keeps the previous// values ("edited the ticket", the previous summary folded under it), pushed live like a comment.// Ticket #4 (mission 017): RELATIONS — "Part of <parent>", the children with their states and// "all children confirmed" / "n of m children confirmed", "Blocked by" and "Blocks" (each with// states); logged-in users get the forms "Parent" (set / remove) and "Blocked by" (add / remove per// row) — the store decides who may (the author of either ticket, or the creator). Live: the// `ticketsRelated` push carries the relation view of every ticket involved in a write.// Ticket #12 (mission 024): the Markdown fields (edit summary, comment, state note) are// `<md-editor>`s around their textareas (shared/md-editor.js): edited visually, the textarea keeps// the plain Markdown and fires `input` as before, so the members and faces are unchanged. The state// note became a textarea (rows 1) because the editor writes line breaks. Ctrl/Cmd+Enter in the// editor submits the form (the textarea's `keyed` handler only runs without the editor).// Ticket #20 (roles): a member of the project may comment; `use` also moves the ticket between open and// review; `edit` / `admin` edit it, assign it (a member; "Assigned to …" is shown) and set any state; a// visitor without a role only reads. The server decides in every case; the page only shows what it may.// A ticket opened through a FORMER slug of its project shows a note with the current address.parent './main.hl'import { ticketRowAt, ticketRecord, assignChoices, eventRows, pageEventsOf, pageEventOf, stateChoices, memberRows, addComment, changeState, assignTicket, editTicket, mayEditId, isMember, relationViewOf, relatedViews, setParent, changeBlocker, noRef } from '../store.hl'import { markdownBlocks } from '../markdown.hl'import { siteName } from '../project.hl'import { canWriteSession, writerOfSession, userOfSession } from '../users.hl'import Markdown from './markdown.hl'projectSlug = nullticketNumber = nullrow = ticketRowAt(projectSlug, ticketNumber)present = row != nullgone = row == nullticketId = row != null ? row.id : ''ref = row != null ? row.ref : ''movedNote = row != null && projectSlug != row.projectcurrentHref = row != null ? row.href : ''assignee = row != null ? row.assignee : ''hasAssignee = row != null && row.hasAssigneecreatedBy = row != null ? row.createdBy : ''hasOld = row != null && row.hasOldoldRef = row != null ? row.oldRef : ''subject = row != null ? row.subject : 'No such ticket'summary = row != null ? row.summary : ''hasSummary = row != null && row.summary != ''summaryMd = row != null ? markdownBlocks(row.summary) : []project = row != null ? row.project : ''projectHref = row != null ? row.projectHref : '/'state = row != null ? row.state : ''stateSlug = row != null ? row.stateSlug : ''created = row != null ? row.created : ''updated = row != null ? row.updated : ''events = row != null ? pageEventsOf(eventRows(row.id)) : []__title = siteName + ' | ' + (row != null ? row.project + ' ' + row.ref + ' ' + row.subject : 'no such ticket')// the forms: only for a logged-in user with a display name (ticket #7); the author is that// user (the faces read the session). Confirm / reject: the server refuses a non-creator.session = nullme = userOfSession(session)canWrite = row != null && me != null && isMember(row.projectId, me)mustLogIn = me == nullnotMember = me != null && !canWriteoptions = row != null ? stateChoices(ticketRecord(row.id), me) : []hasOptions = options.length > 0memberOptions = row != null ? assignChoices(row.projectId) : []commentText = ''newState = options.length > 0 ? options[0].value : ''stateNote = ''message = ''// editing (ticket #8): only the author (the server decides; the face checks again)canEdit = row != null && mayEditId(row.id, me)assignDraft = row != null ? row.assigneeId : ''assignMessage = ''editing = falseshowEdit = canEditshowEdit2 = canEditeditSubject = ''editSummary = ''editMessage = ''// relations (ticket #4): the view of store.hl relationViewOf, flat members for the Viewrel = row != null ? relationViewOf(row) : nullhasRelations = rel != null && rel.hasRelationshasParent = rel != null && rel.hasParentparentRef = rel != null ? rel.parent : noRefhasChildren = rel != null && rel.hasChildrenchildren = rel != null ? rel.children : []childNote = rel != null ? rel.childNote : ''childClass = rel != null ? rel.childClass : ''hasBlockedBy = rel != null && rel.hasBlockedByblockedBy = rel != null ? rel.blockedBy : []hasBlocks = rel != null && rel.hasBlocksblocks = rel != null ? rel.blocks : []parentDraft = ''blockerDraft = ''linkMessage = ''View {ticketView {if (gone) { p { id = "gone" class = "empty" "There is no such ticket." } }if (present) {header {p { a { id = "project" href = projectHref project } " " ticketRef { id = "ref" ref }if (hasOld) { formerRef { id = "oldref" "formerly " oldRef } }}h1 { id = "subject" subject }ticketState { id = "state" class = stateSlug state }p { class = "people" "opened by " strong { id = "createdby" createdBy }if (hasAssignee) { " · assigned to " strong { id = "assignee" assignee } }}if (movedNote) { p { id = "movednote" class = "hint" "This project has a new address: " a { id = "movedlink" href = currentHref currentHref } } }p { class = "dates" "opened " time { created } " · updated " time { id = "updated" updated } }if (showEdit) { button { id = "editbutton" type = "button" class = "quiet" "Edit" on click(e) { emit startEdit(e) } } }}if (editing) {form { id = "editform"on submit(e) {e.preventDefault()emit saveEdit(e)}label { "Subject"input { id = "editsubject" name = "subject" required = "required" maxlength = "200" value = editSubject on input(e) { emit setEditSubject(e.target.value) } }}label { "Summary"mdEditor { textarea { id = "editsummary" name = "summary" rows = "8" value = editSummary on input(e) { emit setEditSummary(e.target.value) } } }}formButtons {button { id = "editsave" type = "submit" "Save" }button { id = "editcancel" type = "button" class = "quiet" "Cancel" on click(e) { emit cancelEdit(e) } }}p { id = "editmessage" class = "message" editMessage }}}if (hasSummary) { ticketSummary { id = "summary" Markdown { blocks = summaryMd } } }if (hasRelations) { ticketRelations { id = "relations"if (hasParent) { p { id = "parent" "Part of "a { href = parentRef.href parentRef.project " " parentRef.ref }" " span { class = "refsubject" parentRef.subject }" " ticketState { class = parentRef.stateSlug parentRef.state }} }if (hasChildren) { div { id = "childrenbox"h2 { "Children" }p { id = "childnote" class = childClass childNote }ul { id = "children" class = "refs"for (c of children) { li {ticketState { class = c.stateSlug c.state }a { href = c.href c.project " " c.ref }span { class = "refsubject" c.subject }} }}} }if (hasBlockedBy) { div { id = "blockedbybox"h2 { "Blocked by" }ul { id = "blockedby" class = "refs"for (c of blockedBy) { li {ticketState { class = c.stateSlug c.state }a { href = c.href c.project " " c.ref }span { class = "refsubject" c.subject }if (showEdit2) { button { type = "button" class = "quiet unblock" value = c.key "Remove" on click(e) { emit removeBlocker(e.target.value) } } }} }}} }if (hasBlocks) { div { id = "blocksbox"h2 { "Blocks" }ul { id = "blocks" class = "refs"for (c of blocks) { li {ticketState { class = c.stateSlug c.state }a { href = c.href c.project " " c.ref }span { class = "refsubject" c.subject }} }}} }} }section {h2 { "History" }ol { id = "events"for (ev of events) {li { class = ev.kindeventHead {strong { ev.author }" " ev.labelif (ev.isState) { " " ticketState { class = ev.toSlug ev.to } }" " time { ev.when }}if (ev.isEdit) { eventText { class = "edit"if (ev.subjectChanged) { p { class = "was" "Subject before: " s { ev.oldSubject } } }if (ev.summaryChanged) { details { class = "was"summary { "Summary before" }Markdown { blocks = ev.oldSummaryMd }} }} }if (ev.hasText) { eventText { Markdown { blocks = ev.md } } }}}}}if (mustLogIn) {p { id = "loginhint" class = "hint" "Reading is open to everyone. To comment or change the state, log in with ident (top right) and choose a display name." }}if (notMember) {p { id = "memberhint" class = "hint" "You are not a member of this project — ask an admin to invite you to comment." }}if (canWrite) { section {h2 { "Respond" }form { id = "commentform"on submit(e) {e.preventDefault()emit sendComment(e)}label { "Comment"mdEditor { textarea { id = "commenttext" name = "comment" rows = "3" required = "required" placeholder = "Write a comment" value = commentTexton input(e) { emit setComment(e.target.value) }on keydown(e) { emit keyed(e) }} }}button { id = "commentsend" type = "submit" "Comment" }}if (hasOptions) { form { id = "stateform"on submit(e) {e.preventDefault()emit sendState(e)}label { "State"select { id = "newstate" name = "state" value = newStateon change(e) { emit setNewState(e.target.value) }for (o of options) { option { value = o.value o.label } }}}label { "Note (optional)"mdEditor { textarea { id = "statenote" name = "note" rows = "1" value = stateNote on input(e) { emit setStateNote(e.target.value) } } }}button { id = "statesend" type = "submit" "Change state" }} }p { id = "message" class = "message" message }} }if (showEdit2) { section {h2 { "Manage" }form { id = "assignform"on submit(e) {e.preventDefault()emit sendAssign(e)}label { "Assigned to"select { id = "assignselect" name = "assignee" value = assignDraft on change(e) { emit setAssignDraft(e.target.value) }option { value = "" "nobody" }for (o of memberOptions) { option { value = o.value o.label } }}}button { id = "assignsave" type = "submit" "Assign" }}p { id = "assignmessage" class = "message" assignMessage }form { id = "parentform"on submit(e) {e.preventDefault()emit sendParent(e)}label { "Parent ticket (project#number)"input { id = "parentkey" name = "parent" placeholder = "e.g. ident.worldapi.org#1" autocomplete = "off" value = parentDraft on input(e) { emit setParentDraft(e.target.value) } }}formButtons {button { id = "parentsave" type = "submit" "Set parent" }if (hasParent) { button { id = "parentremove" type = "button" class = "quiet" "Remove parent" on click(e) { emit removeParent(e) } } }}}form { id = "blockerform"on submit(e) {e.preventDefault()emit sendBlocker(e)}label { "Blocked by (project#number)"input { id = "blockerkey" name = "blocker" placeholder = "e.g. hybriel#12" autocomplete = "off" value = blockerDraft on input(e) { emit setBlockerDraft(e.target.value) } }}button { id = "blockeradd" type = "submit" "Add blocker" }}p { id = "linkmessage" class = "message" linkMessage }} }}}}on setComment(v) { commentText = v }on setEditSubject(v) { editSubject = v }on setEditSummary(v) { editSummary = v }on startEdit(e) {editSubject = subjecteditSummary = summaryeditMessage = ''editing = trueshowEdit = false}on cancelEdit(e) {editing = falseshowEdit = canEditeditMessage = ''}on saveEdit(e) {let r = emit server ticketEdit(ticketId, editSubject, editSummary)if (r != null && r.error != null) {editMessage = r.errorreturn null}editing = falseshowEdit = canEditeditMessage = ''}on setNewState(v) { newState = v }on setAssignDraft(v) { assignDraft = v }on sendAssign(e) {let r = emit server ticketAssign(ticketId, assignDraft)if (r != null && r.error != null) { assignMessage = r.error } else { assignMessage = '' }}on setParentDraft(v) { parentDraft = v }on setBlockerDraft(v) { blockerDraft = v }// relations (ticket #4): the answer is only ok / error — the page follows the pushon sendParent(e) {if (parentDraft.trim() == '') {linkMessage = 'name the parent as project#number'return null}let r = emit server ticketSetParent(ticketId, parentDraft)if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' parentDraft = '' }}on removeParent(e) {let r = emit server ticketSetParent(ticketId, '')if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' }}on sendBlocker(e) {if (blockerDraft.trim() == '') {linkMessage = 'name the blocking ticket as project#number'return null}let r = emit server ticketAddBlocker(ticketId, blockerDraft)if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' blockerDraft = '' }}on removeBlocker(key) {let r = emit server ticketRemoveBlocker(ticketId, key)if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' }}on setStateNote(v) { stateNote = v }on keyed(e) {if (e.key == 'Enter' && (e.ctrlKey || e.metaKey)) {e.preventDefault()e.target.form.requestSubmit()}}on sendComment(e) {if (commentText.trim() == '') { return null }let r = emit server commentOn(ticketId, commentText)if (r != null && r.error != null) { message = r.error } else { message = '' commentText = '' }}on sendState(e) {let r = emit server setTicketState(ticketId, newState, stateNote)if (r != null && r.error != null) { message = r.error } else { message = '' stateNote = '' }}// ---- what the server pushes: an event on some ticket -----------------------------------on client ticketEvent(evTicket, ev, changed, count) {if ('' + evTicket == '' + ticketId) {let next = []for (x of events) { next.push(x) }next.push(ev)events = nextstate = changed.statestateSlug = changed.stateSlugupdated = changed.updatedassignee = changed.assigneehasAssignee = changed.hasAssigneeassignDraft = changed.assigneeIdlet rights = emit server ticketRights(ticketId)takeRights(rights)if (ev.isEdit == true) {subject = changed.subjectsummary = changed.summaryhasSummary = changed.summary != ''summaryMd = ev.summaryMd}}}// relations changed (a write on this ticket or on a related one): take over this ticket's viewon client ticketsRelated(views) {for (v of views) {if ('' + v.id == '' + ticketId) {hasRelations = v.hasRelationshasParent = v.hasParentparentRef = v.parenthasChildren = v.hasChildrenchildren = v.childrenchildNote = v.childNotechildClass = v.childClasshasBlockedBy = v.hasBlockedByblockedBy = v.blockedByhasBlocks = v.hasBlocksblocks = v.blocks}}}// login / logout in the shell (this session's tabs): the forms come and go without a reload// what this session's user may do on this ticket now (the face answers from their role)takeRights = (r) => {if (r == null || r.error != null) { return null }canWrite = r.comment == truemustLogIn = falsenotMember = !canWritecanEdit = r.edit == trueshowEdit = canEdit && !editingshowEdit2 = canEditoptions = r.optionshasOptions = r.options.length > 0memberOptions = r.membersnewState = r.options.length > 0 ? r.options[0].value : ''}on client signedIn(tag, info) {let r = emit server ticketRights(ticketId)takeRights(r)}on client signedOut(tag) {canWrite = falsemustLogIn = truenotMember = falsecanEdit = falseshowEdit = falseshowEdit2 = falsehasOptions = falseediting = false}// ---- the faces: the writer is the session's user (#31: a forged trailing argument is not// a real session → refused) ---------------------------------------------------------------on server commentOn(ticketId, text, session) {let w = writerOfSession(session)if (w.error != null) { return { error = w.error } }let r = addComment('' + ticketId, w.user, text)if (r.error != null) { return { error = r.error } }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)return { ok = true }}on server setTicketState(ticketId, state, note, session) {let w = writerOfSession(session)if (w.error != null) { return { error = w.error } }let r = changeState('' + ticketId, state, w.user, note)if (r.error != null) { return { error = r.error } }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)if (r.assignEvent != null) { emit client ticketEvent(r.ticket.id, pageEventOf(r.assignEvent), r.ticket) }emit client ticketsRelated(relatedViews(r.ticket.id, []))return { ok = true }}// ticket #20: assign the ticket to a member ('' = nobody); the store wants the role edit or adminon server ticketAssign(ticketId, userId, session) {let w = writerOfSession(session)if (w.error != null) { return { error = w.error } }if (hlTypeName(userId) != 'String') { return { error = 'the assignee must be text' } }let r = assignTicket('' + ticketId, w.user, userId)if (r.error != null) { return { error = r.error } }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)return { ok = true }}// ticket #8: the author edits subject + summary (the store refuses anyone else)on server ticketEdit(ticketId, subject, summary, session) {let w = writerOfSession(session)if (w.error != null) { return { error = w.error } }if (hlTypeName(subject) != 'String' || hlTypeName(summary) != 'String') { return { error = 'subject and summary must be text' } }let r = editTicket('' + ticketId, w.user, subject, summary)if (r.error != null) { return { error = r.error } }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)emit client ticketsRelated(relatedViews(r.ticket.id, []))return { ok = true }}// ticket #4: relations — the store refuses a user who may not (author of either ticket / creator)on server ticketSetParent(ticketId, key, session) {let w = writerOfSession(session)if (w.error != null) { return { error = w.error } }if (hlTypeName(key) != 'String') { return { error = 'the parent must be text' } }let r = setParent('' + ticketId, w.user, key)if (r.error != null) { return { error = r.error } }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)emit client ticketsRelated(r.related)return { ok = true }}on server ticketAddBlocker(ticketId, key, session) {let w = writerOfSession(session)if (w.error != null) { return { error = w.error } }if (hlTypeName(key) != 'String') { return { error = 'the blocker must be text' } }let r = changeBlocker('' + ticketId, w.user, key, true, 'add')if (r.error != null) { return { error = r.error } }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)emit client ticketsRelated(r.related)return { ok = true }}on server ticketRemoveBlocker(ticketId, key, session) {let w = writerOfSession(session)if (w.error != null) { return { error = w.error } }if (hlTypeName(key) != 'String') { return { error = 'the blocker must be text' } }let r = changeBlocker('' + ticketId, w.user, key, false, 'remove')if (r.error != null) { return { error = r.error } }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)emit client ticketsRelated(r.related)return { ok = true }}// what the session's user may do on this ticket: comment (a member), edit / assign (edit, admin), the states// they may move it to, and the members it can be assigned toon server ticketRights(ticketId, session) {let u = userOfSession(session)if (u == null) { return { error = 'log in first' } }let t = ticketRecord('' + ticketId)if (t == null) { return { error = 'no such ticket' } }return { comment = isMember(t.project, u) edit = mayEditId(t.id, u) options = stateChoices(t, u) members = assignChoices(t.project) }}
Branches
- mainmain branch
Latest commits
- d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mre
- bce182e3tickets: Hybriel master 7eea0d32 (#126 memory, #48 lambda copies its argument); migrate.hl lambdas take &logmre
- 4137be0fantcolony#40: mission references point to the moved missionsmre
- 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
- 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
- e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
- 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
- f12fa1bcState of 2026-09-27, before the move to gitoriamre