gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit7538b0347538b034mission 011 (ticket #25 web part): short page URLs /<slug> and /<slug>/<number>; /projects/<slug>, /projects/<slug>/<n>, /projects/<slug>/tickets/<n> and /tickets/<ref> answer 301 (current slug); slug pages moved behind hl:web's own routes after construction (they answered /__hl/* and /components/*.hl); reserved slugs refused; API paths unchanged. gate 293/0, connect 60/0, live-data copy 187/0, API old vs new: only page links differmre7538b034/components/ticket_list.hl

9.3 KB

  1. // components/ticket_list.hl — `/`, `/project/:projectName` (also `/:projectName`, ticket #25),
  2. // `/state/:stateSlug` and both:
  3. // the tickets, newest update first, filtered by project and state, and the form that
  4. // opens a new ticket. Live: a ticket created or changed anywhere (web or API) lands in,
  5. // moves within or leaves this list without a reload. Ticket #12: the summary is an <md-editor>
  6. // around the textarea (shared/md-editor.js; the textarea keeps the Markdown).
  7. // Ticket #20: a project's page (`/<slug>`, also `/project/<slug>`; `/projects/<slug>` answers 301) shows its title, description
  8. // (Markdown), its tickets and — for an admin — the link to its settings page. The new-ticket form needs the
  9. // role edit or admin: its project choice holds only the projects the user may open tickets in.
  10. parent './main.hl'
  11. import { ticketRows, createTicket } from '../lib/tickets.hl'
  12. import { projectLinks, stateLinks, stateOfSlug } from '../lib/tickets-helpers.hl'
  13. import { projectByAnySlug, editableProjects, isAdminOf } from '../lib/projects.hl'
  14. import { markdownBlocks } from '../lib/markdown.hl'
  15. import { siteName } from '../project.hl'
  16. import { canWriteSession, writerOfSession, userOfSession } from '../lib/users.hl'
  17. import { connectionRows, disconnect } from '../lib/connections.hl'
  18. import Markdown from './markdown.hl'
  19. projectName = null
  20. stateSlug = null
  21. filterState = stateOfSlug(stateSlug)
  22. rows = ticketRows(projectName, filterState)
  23. projects = projectLinks(projectName, filterState)
  24. stateFilters = stateLinks(projectName, filterState)
  25. session = null
  26. project = projectName != null ? projectByAnySlug(projectName) : null
  27. noSuchProject = projectName != null && project == null
  28. projectId = project != null ? project.id : ''
  29. hasDescription = project != null && project.description != null && project.description != ''
  30. descriptionMd = hasDescription ? markdownBlocks(project.description) : []
  31. isAdmin = project != null && isAdminOf(project.id, userOfSession(session))
  32. // ticket #21: the apps connected to this project (connections.hl), shown to everybody; an admin can disconnect
  33. connections = project != null ? connectionRows(project.id) : []
  34. hasConnections = connections.length > 0
  35. connectionMessage = ''
  36. settingsHref = project != null ? '/projects/' + project.slug + '/settings' : ''
  37. isEmpty = rows.length == 0
  38. heading = (projectName != null ? (project != null ? project.title : projectName) : 'All projects') + (filterState != null ? ' · ' + filterState : '')
  39. __title = siteName + ' | ' + heading
  40. // the new-ticket form
  41. myProjects = editableProjects(userOfSession(session))
  42. newProject = projectName != null && project != null ? project.slug : (myProjects.length > 0 ? myProjects[0].slug : '')
  43. newSubject = ''
  44. newSummary = ''
  45. // only for a member with the role edit or admin (ticket #20): the author is that user
  46. canWrite = myProjects.length > 0
  47. canCreateProject = canWriteSession(session)
  48. mustLogIn = userOfSession(session) == null
  49. noRole = userOfSession(session) != null && myProjects.length == 0
  50. notice = ''
  51. noticeHref = ''
  52. hasNotice = false
  53. View {
  54. ticketBoard {
  55. h1 { id = "heading" heading }
  56. ticketFilters {
  57. nav { id = "projectfilter"
  58. for (f of projects) { a { href = f.href class = f.cls f.label } }
  59. }
  60. nav { id = "statefilter"
  61. for (f of stateFilters) { a { href = f.href class = f.cls f.label } }
  62. }
  63. }
  64. if (noSuchProject) { p { id = "noproject" class = "empty" "There is no such project." } }
  65. if (hasDescription) { projectDescription { id = "description" Markdown { blocks = descriptionMd } } }
  66. if (hasConnections) { ul { id = "connections" class = "connections"
  67. for (c of connections) {
  68. li { class = "connection"
  69. "Connected to " strong { c.text }
  70. if (isAdmin) { button { type = "button" class = "quiet danger disconnect" "Disconnect" on click(e) { emit dropConnection(c.id) } } }
  71. }
  72. }
  73. } }
  74. p { id = "connectionmessage" class = "message" connectionMessage }
  75. projectActions {
  76. if (canCreateProject) { a { id = "newprojectlink" class = "button" href = "/new-project" "New project" } }
  77. if (isAdmin) { a { id = "settingslink" class = "button" href = settingsHref "Project settings" } }
  78. }
  79. if (mustLogIn) { p { id = "loginhint" class = "hint" "Reading is open to everyone. To open a ticket, log in with ident (top right)." } }
  80. if (noRole) { p { id = "rolehint" class = "hint" "To open a ticket you need the role edit or admin in a project — ask an admin of the project to invite you." } }
  81. if (canWrite) { details { id = "newticket"
  82. summary { "New ticket" }
  83. form { id = "newticketform"
  84. on submit(e) {
  85. e.preventDefault()
  86. emit create(e)
  87. }
  88. label { "Project"
  89. select { id = "newproject" name = "project" value = newProject on change(e) { emit setProject(e.target.value) }
  90. for (p of myProjects) { option { value = p.slug p.title } }
  91. }
  92. }
  93. label { "Subject"
  94. input { id = "newsubject" name = "subject" required = "required" maxlength = "200" value = newSubject on input(e) { emit setSubject(e.target.value) } }
  95. }
  96. label { "Summary"
  97. mdEditor { textarea { id = "newsummary" name = "summary" rows = "4" value = newSummary on input(e) { emit setSummary(e.target.value) } } }
  98. }
  99. button { id = "newsubmit" type = "submit" "Open ticket" }
  100. }
  101. } }
  102. if (hasNotice) { p { id = "notice" class = "notice" notice " " a { id = "noticelink" href = noticeHref "open it" } } }
  103. if (isEmpty) { p { id = "empty" class = "empty" "No tickets here." } }
  104. ol { id = "tickets" class = "tickets"
  105. for (row of rows) {
  106. li {
  107. ticketState { class = row.stateSlug row.state }
  108. a { class = "subject" href = row.href ticketRef { row.ref } row.subject }
  109. ticketMeta {
  110. a { href = row.projectHref row.projectTitle }
  111. if (row.hasAssignee) { assignee { row.assignee } }
  112. time { row.updated }
  113. }
  114. }
  115. }
  116. }
  117. }
  118. }
  119. on setProject(v) { newProject = v }
  120. on setSubject(v) { newSubject = v }
  121. on setSummary(v) { newSummary = v }
  122. on create(e) {
  123. if (newProject.trim() == '' || newSubject.trim() == '') { return null }
  124. r = emit server openTicket(newProject, newSubject, newSummary)
  125. if (r != null && r.error == null) {
  126. notice = 'Opened ' + r.project + ' ' + r.ref + '.'
  127. noticeHref = r.href
  128. hasNotice = true
  129. newSubject = ''
  130. newSummary = ''
  131. } else {
  132. notice = r != null ? r.error : 'could not open the ticket'
  133. noticeHref = ''
  134. hasNotice = true
  135. }
  136. }
  137. // does a row belong in THIS list (its filters)?
  138. belongs = (row) => {
  139. return (projectName == null || row.projectId == projectId) && (filterState == null || row.state == filterState)
  140. }
  141. // the row goes to the top (newest update first) if it belongs, and out if not
  142. place = (row) => {
  143. next = []
  144. if (belongs(row)) { next.push(row) }
  145. for (r of rows) { if (r.id != row.id) { next.push(r) } }
  146. rows = next
  147. isEmpty = next.length == 0
  148. }
  149. on client ticketCreated(row) { place(row) }
  150. on dropConnection(id) {
  151. r = emit server disconnectApp(projectId, id)
  152. if (r == null || r.error != null) {
  153. connectionMessage = r != null ? r.error : 'could not disconnect'
  154. return null
  155. }
  156. connectionMessage = ''
  157. connections = r.connections
  158. hasConnections = r.connections.length > 0
  159. }
  160. on client connectionsChanged(slug) {
  161. r = emit server connectionsOfProject(projectId)
  162. if (r != null && r.connections != null) {
  163. connections = r.connections
  164. hasConnections = r.connections.length > 0
  165. }
  166. }
  167. on client signedIn(tag, info) {
  168. r = emit server whatICanOpen(projectName != null ? projectName : '')
  169. if (r != null && r.projects != null) {
  170. myProjects = r.projects
  171. canWrite = r.projects.length > 0
  172. noRole = r.projects.length == 0 && info.named
  173. canCreateProject = info.named
  174. mustLogIn = false
  175. isAdmin = r.admin == true
  176. if (newProject == '' && r.projects.length > 0) { newProject = r.projects[0].slug }
  177. }
  178. }
  179. on client signedOut(tag) {
  180. canWrite = false
  181. canCreateProject = false
  182. isAdmin = false
  183. noRole = false
  184. mustLogIn = true
  185. myProjects = []
  186. }
  187. on client ticketEvent(ticketId, ev, row) { place(row) }
  188. // THE FACE: a ticket opened from the web by the session's user (#31: only a real session).
  189. // The same push the API sends.
  190. on server openTicket(project, subject, summary, session) {
  191. w = writerOfSession(session)
  192. if (w.error != null) { return { error = w.error } }
  193. r = createTicket(project, subject, summary, w.user, null, null)
  194. if (r.error != null) { return { error = r.error } }
  195. emit client ticketCreated(r.ticket)
  196. return r.ticket
  197. }
  198. // what this session's user may do here after a login without a reload: the projects they may open tickets in,
  199. // and whether they are an admin of THIS page's project (`slug` null = the all-projects page)
  200. on server whatICanOpen(slug, session) {
  201. u = userOfSession(session)
  202. if (u == null) { return { error = 'log in first' } }
  203. p = slug != null && slug != '' ? projectByAnySlug(slug) : null
  204. return { projects = editableProjects(u) admin = p != null && isAdminOf(p.id, u) }
  205. }
  206. // ticket #21: an admin ends a connection — the key is dead at once (connections.hl)
  207. on server disconnectApp(projectId, connectionId, session) {
  208. w = writerOfSession(session)
  209. if (w.error != null) { return { error = w.error } }
  210. r = disconnect('' + projectId, w.user, '' + connectionId)
  211. if (r.error != null) { return { error = r.error } }
  212. emit client connectionsChanged('')
  213. return { connections = r.connections }
  214. }
  215. on server connectionsOfProject(projectId, session) {
  216. return { connections = connectionRows('' + projectId) }
  217. }

Branches

Latest commits

  • 7538b034mission 011 (ticket #25 web part): short page URLs /<slug> and /<slug>/<number>; /projects/<slug>, /projects/<slug>/<n>, /projects/<slug>/tickets/<n> and /tickets/<ref> answer 301 (current slug); slug pages moved behind hl:web's own routes after construction (they answered /__hl/* and /components/*.hl); reserved slugs refused; API paths unchanged. gate 293/0, connect 60/0, live-data copy 187/0, API old vs new: only page links differmre
  • 0369106emission 010 (code order) 4/4: README file map + import order + 'Same output' test, STATUS (entry, lessons, how to verify), LOG, report; tests/realdata-baseline.mjs + realdata-compare*.py (a cleanup answers the same on live data), tests/letcount.pymre
  • a75e0279mission 010 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (456 lets → plain declarations; Hybriel refuses a plain declaration inside a loop on its 2nd pass). gate 249/0, connect 60/0, real-data reads identical, a 50-step write sequence (API + faces) identical to the old codemre
  • e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmre
  • 97e269b5mission 010 (code order) 1/4: .hl files out of the root — lib/ (store, users, connections, invites, migrate, markdown, mdview, import = ticketfile, util = localtime, jsoncheck, api-helpers = api), tools/import.hl, components/styles.hl; import paths only. gate 249/0, connect 60/0, real-data reads identicalmre
  • 38f9d10ftickets: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gate 249/0, connect 60/0mre
  • 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