π (issues): Redesign issue page with a GitHub-style assignee picker
Changes
5 files changed, +438 -88
ADD
src/main/java/de/workaround/web/Assets.java
+22 -0
@@ -0,0 +1,22 @@
1
+package de.workaround.web;
2
+
3
+import jakarta.enterprise.context.ApplicationScoped;
4
+import jakarta.inject.Named;
5
+
6
+/**
7
+ * Cache-busting token for static assets (CSS/JS). Fixed once at application startup, so the value
8
+ * changes on every deploy β and on every dev live-reload restart β but stays stable while the app
9
+ * runs. Appended as {@code ?v=...} to asset links in the layout so browsers fetch a changed file
10
+ * instead of serving a stale cached copy, without disabling caching between deploys.
11
+ */
12
+@Named("assets")
13
+@ApplicationScoped
14
+public class Assets
15
+{
16
+ private final String version = Long.toString(System.currentTimeMillis(), 36);
17
+
18
+ public String version()
19
+ {
20
+ return version;
21
+ }
22
+}
MODIFY
src/main/resources/META-INF/resources/shark.css
+289 -36
@@ -915,25 +915,6 @@
915
915
font-size: 13px;
916
916
}
917
917
918
-/* single management bar under the issue body: edit, status transitions, delete pushed right */
919
-.issue-manage {
920
- display: flex;
921
- align-items: center;
922
- flex-wrap: wrap;
923
- gap: var(--s3);
924
- margin-top: var(--s5);
925
- padding-top: var(--s4);
926
- border-top: 1px solid var(--border-soft);
927
-}
928
-
929
-.issue-manage .issue-actions {
930
- margin: 0;
931
-}
932
-
933
-.issue-manage .issue-remove {
934
- margin-left: auto;
935
-}
936
-
937
918
/* the issue description sits in its own surface card (same look as the README card) so the
938
919
content stands out from the page background */
939
920
.issue-desc {
@@ -958,8 +939,51 @@
958
939
font: 500 12.5px/1 var(--mono);
959
940
}
960
941
961
-/* issue detail meta line: status badge plus who opened and who is assigned, each with an avatar */
962
-.issue-meta {
942
+/* Issue detail: main column + a narrow metadata sidebar (status, assignee), the layout
943
+ pattern people expect from an issue tracker. Collapses to one column on narrow screens. */
944
+.issue-back {
945
+ margin: 0 0 var(--s4);
946
+}
947
+
948
+.issue-detail {
949
+ display: grid;
950
+ grid-template-columns: minmax(0, 1fr) 244px;
951
+ gap: var(--s6);
952
+ align-items: start;
953
+}
954
+
955
+.issue-content {
956
+ min-width: 0;
957
+}
958
+
959
+/* title sits opposite the edit/delete cluster; both share one baseline row */
960
+.issue-header {
961
+ display: flex;
962
+ align-items: flex-start;
963
+ gap: var(--s4);
964
+}
965
+
966
+.issue-heading {
967
+ margin: 0;
968
+ flex: 1;
969
+ min-width: 0;
970
+ line-height: 1.2;
971
+ overflow-wrap: anywhere;
972
+}
973
+
974
+.issue-heading .issue-no {
975
+ margin-left: 6px;
976
+ vertical-align: middle;
977
+}
978
+
979
+.issue-header-actions {
980
+ display: flex;
981
+ align-items: center;
982
+ gap: var(--s2);
983
+ flex: none;
984
+}
985
+
986
+.issue-byline {
963
987
display: flex;
964
988
align-items: center;
965
989
flex-wrap: wrap;
@@ -975,6 +999,245 @@
975
999
gap: 6px;
976
1000
}
977
1001
1002
+.issue-person strong {
1003
+ color: var(--ink);
1004
+ font-weight: 600;
1005
+}
1006
+
1007
+.issue-nodesc {
1008
+ margin: var(--s4) 0 var(--s5);
1009
+}
1010
+
1011
+/* metadata sidebar: quiet labelled blocks separated by hairlines */
1012
+.issue-side {
1013
+ display: flex;
1014
+ flex-direction: column;
1015
+}
1016
+
1017
+.issue-side-block {
1018
+ padding: var(--s4) 0;
1019
+ border-bottom: 1px solid var(--border-soft);
1020
+}
1021
+
1022
+.issue-side-block:first-child {
1023
+ padding-top: 0;
1024
+}
1025
+
1026
+.issue-side-block:last-child {
1027
+ border-bottom: none;
1028
+}
1029
+
1030
+.issue-side-label {
1031
+ margin: 0 0 var(--s3);
1032
+ font: 600 11px/1 var(--font);
1033
+ text-transform: uppercase;
1034
+ letter-spacing: .07em;
1035
+ color: var(--muted);
1036
+}
1037
+
1038
+/* status as a segmented control: the current state reads as pressed, the others as choices */
1039
+.status-picker {
1040
+ display: inline-flex;
1041
+ padding: 3px;
1042
+ gap: 2px;
1043
+ background: var(--canvas);
1044
+ border: 1px solid var(--border);
1045
+ border-radius: var(--radius-sm);
1046
+}
1047
+
1048
+.status-opt {
1049
+ flex: 1;
1050
+ padding: 6px 10px;
1051
+ border: 1px solid transparent;
1052
+ border-radius: 6px;
1053
+ background: transparent;
1054
+ color: var(--muted);
1055
+ font: 600 12px/1 var(--font);
1056
+ white-space: nowrap;
1057
+ cursor: pointer;
1058
+ transition: background .12s, color .12s, box-shadow .12s;
1059
+}
1060
+
1061
+.status-opt:hover {
1062
+ color: var(--accent-deep);
1063
+ background: var(--accent-soft);
1064
+}
1065
+
1066
+.status-opt.is-current {
1067
+ background: var(--surface);
1068
+ color: var(--ink);
1069
+ border-color: var(--border);
1070
+ box-shadow: 0 1px 2px rgba(20, 32, 30, .06);
1071
+ cursor: default;
1072
+}
1073
+
1074
+.status-opt.is-current:hover {
1075
+ background: var(--surface);
1076
+}
1077
+
1078
+.status-opt:focus-visible {
1079
+ outline: 2px solid var(--accent);
1080
+ outline-offset: 1px;
1081
+}
1082
+
1083
+/* current assignee (avatar + handle, or an empty-state line) β used read-only for non-owners */
1084
+.assignee-current {
1085
+ display: flex;
1086
+ align-items: center;
1087
+ gap: var(--s2);
1088
+ font-size: 14px;
1089
+}
1090
+
1091
+.assignee-name {
1092
+ font-weight: 600;
1093
+ color: var(--ink);
1094
+}
1095
+
1096
+/* Assignee popover (GitHub-style): the row is the trigger; clicking it opens a floating menu of
1097
+ people to pick from. JS-free via <details> β the menu is absolutely positioned so it overlays
1098
+ the page instead of pushing content down. */
1099
+.assignee-pop {
1100
+ position: relative;
1101
+}
1102
+
1103
+.assignee-trigger {
1104
+ display: flex;
1105
+ align-items: center;
1106
+ gap: var(--s2);
1107
+ font-size: 14px;
1108
+ list-style: none;
1109
+ cursor: pointer;
1110
+ padding: 6px 8px;
1111
+ margin: 0 -8px;
1112
+ border-radius: var(--radius-sm);
1113
+ transition: background .12s;
1114
+}
1115
+
1116
+.assignee-trigger::-webkit-details-marker {
1117
+ display: none;
1118
+}
1119
+
1120
+.assignee-trigger:hover {
1121
+ background: var(--accent-soft);
1122
+}
1123
+
1124
+.assignee-trigger:focus-visible {
1125
+ outline: 2px solid var(--accent);
1126
+ outline-offset: 1px;
1127
+}
1128
+
1129
+.assignee-caret {
1130
+ margin-left: auto;
1131
+ color: var(--faint);
1132
+ font-size: 12px;
1133
+ transition: transform .12s, color .12s;
1134
+}
1135
+
1136
+.assignee-trigger:hover .assignee-caret {
1137
+ color: var(--accent-deep);
1138
+}
1139
+
1140
+.assignee-pop[open] .assignee-caret {
1141
+ transform: rotate(180deg);
1142
+ color: var(--accent-deep);
1143
+}
1144
+
1145
+.assignee-menu {
1146
+ position: absolute;
1147
+ z-index: 30;
1148
+ top: calc(100% + 6px);
1149
+ left: 0;
1150
+ right: 0;
1151
+ min-width: 240px;
1152
+ padding: var(--s2);
1153
+ background: var(--surface);
1154
+ border: 1px solid var(--border);
1155
+ border-radius: var(--radius);
1156
+ box-shadow: 0 8px 28px rgba(20, 32, 30, .14);
1157
+}
1158
+
1159
+.assignee-menu-head {
1160
+ margin: 4px 8px 8px;
1161
+ font: 600 12px/1 var(--font);
1162
+ color: var(--muted);
1163
+}
1164
+
1165
+.assignee-search {
1166
+ margin-bottom: var(--s2);
1167
+}
1168
+
1169
+.assignee-search input {
1170
+ width: 100%;
1171
+ padding: 7px 10px;
1172
+ border: 1px solid var(--border);
1173
+ border-radius: var(--radius-sm);
1174
+ background: var(--surface);
1175
+ color: var(--ink);
1176
+ font: 14px/1.2 var(--font);
1177
+}
1178
+
1179
+.assignee-search input:focus-visible {
1180
+ outline: none;
1181
+ border-color: var(--accent);
1182
+ box-shadow: 0 0 0 3px var(--accent-soft);
1183
+}
1184
+
1185
+.assignee-options {
1186
+ list-style: none;
1187
+ margin: 0;
1188
+ padding: 0;
1189
+ border-top: 1px solid var(--border-soft);
1190
+ padding-top: var(--s1);
1191
+ max-height: 260px;
1192
+ overflow-y: auto;
1193
+}
1194
+
1195
+/* each person is a full-width click target that assigns on submit */
1196
+.assignee-option {
1197
+ display: flex;
1198
+ align-items: center;
1199
+ gap: var(--s2);
1200
+ width: 100%;
1201
+ padding: 6px 8px;
1202
+ border: none;
1203
+ background: none;
1204
+ border-radius: var(--radius-sm);
1205
+ color: var(--ink);
1206
+ font: 14px/1.2 var(--font);
1207
+ text-align: left;
1208
+ cursor: pointer;
1209
+}
1210
+
1211
+.assignee-option:hover {
1212
+ background: var(--accent-soft);
1213
+}
1214
+
1215
+.assignee-option.is-selected {
1216
+ font-weight: 600;
1217
+}
1218
+
1219
+.assignee-check {
1220
+ width: 14px;
1221
+ flex: none;
1222
+ color: var(--success-deep);
1223
+ font-size: 12px;
1224
+}
1225
+
1226
+/* clear sits below the list, set apart by a hairline and a quiet danger tone */
1227
+.assignee-clear {
1228
+ border-top: 1px solid var(--border-soft);
1229
+ margin-top: var(--s1);
1230
+ padding-top: var(--s1);
1231
+}
1232
+
1233
+.assignee-clear-btn {
1234
+ color: var(--danger);
1235
+}
1236
+
1237
+.assignee-clear-btn:hover {
1238
+ background: var(--danger-bg);
1239
+}
1240
+
978
1241
/* assignee avatar pinned to the right of an issue list row */
979
1242
.frow-assignee {
980
1243
margin-left: auto;
@@ -984,21 +1247,11 @@
984
1247
flex: none;
985
1248
}
986
1249
987
-/* assignee autocomplete control inside the management bar */
988
-.issue-assign {
989
- display: flex;
990
- align-items: center;
991
- gap: var(--s2);
992
-}
993
-
994
-.issue-assign .lbl {
995
- color: var(--muted);
996
- font-size: 13px;
997
-}
998
-
999
-.issue-assign input {
1000
- width: 150px;
1001
- max-width: 40vw;
1250
+@media (max-width: 900px) {
1251
+ .issue-detail {
1252
+ grid-template-columns: 1fr;
1253
+ gap: var(--s5);
1254
+ }
1002
1255
}
1003
1256
1004
1257
/* merge requests */
MODIFY
src/main/resources/templates/IssueResource/issue.html
+100 -43
@@ -3,50 +3,107 @@
3
3
<div class="repo-layout">
4
4
{#include RepositoryResource/sidebar nav=nav active='issues' /}
5
5
<section class="repo-main">
6
- <p><a href="/repos/{repo.ownerHandle}/{repo.name}/issues">β Issues</a></p>
7
- <h2>{issue.title} <span class="issue-no">#{issue.number}</span></h2>
8
- <div class="issue-meta">
9
- <span class="badge status-{issue.status}">{issue.status.label}</span>
10
- <span class="issue-person">opened by {#avatar user=issue.author /} <span>{issue.author.username}</span></span>
11
- {#if issue.assignee}
12
- <span class="issue-person">assigned to {#avatar user=issue.assignee /} <span>{issue.assignee.username}</span></span>
13
- {#else}
14
- <span class="issue-person muted">unassigned</span>
15
- {/if}
6
+ <p class="issue-back"><a href="/repos/{repo.ownerHandle}/{repo.name}/issues">β Issues</a></p>
7
+ <div class="issue-detail">
8
+ <div class="issue-content">
9
+ <header class="issue-header">
10
+ <h2 class="issue-heading">{issue.title} <span class="issue-no">#{issue.number}</span></h2>
11
+ {#if owner}
12
+ <div class="issue-header-actions">
13
+ <a class="btn btn-secondary btn-sm" href="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/edit">Edit</a>
14
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/delete"
15
+ onsubmit="return confirm('Delete this issue? This cannot be undone.')">
16
+ <button type="submit" class="btn btn-danger btn-sm">Delete</button>
17
+ </form>
18
+ </div>
19
+ {/if}
20
+ </header>
21
+ <div class="issue-byline">
22
+ <span class="badge status-{issue.status}">{issue.status.label}</span>
23
+ <span class="issue-person">{#avatar user=issue.author /} <span><strong>{issue.author.username}</strong> opened this issue</span></span>
24
+ </div>
25
+ {#if descriptionHtml}
26
+ <div class="panel issue-desc">
27
+ <div class="readme-body">{descriptionHtml.raw}</div>
28
+ </div>
29
+ {#else}
30
+ <p class="issue-nodesc muted">No description provided.</p>
31
+ {/if}
32
+ </div>
33
+
34
+ <aside class="issue-side">
35
+ <section class="issue-side-block">
36
+ <h3 class="issue-side-label">Status</h3>
37
+ {#if owner}
38
+ <form class="status-picker" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/status">
39
+ {#for status in statuses}
40
+ <button type="submit" name="status" value="{status}"
41
+ class="status-opt status-opt-{status}{#if status == issue.status} is-current{/if}"
42
+ {#if status == issue.status}aria-current="true" disabled{/if}>{status.label}</button>
43
+ {/for}
44
+ </form>
45
+ {#else}
46
+ <span class="badge status-{issue.status}">{issue.status.label}</span>
47
+ {/if}
48
+ </section>
49
+
50
+ <section class="issue-side-block">
51
+ <h3 class="issue-side-label">Assignee</h3>
52
+ {#if owner}
53
+ {! GitHub-style: the row is the trigger; clicking it opens a floating pick-a-person menu !}
54
+ <details class="assignee-pop">
55
+ <summary class="assignee-trigger">
56
+ {#if issue.assignee}
57
+ {#avatar user=issue.assignee /} <span class="assignee-name">{issue.assignee.username}</span>
58
+ {#else}
59
+ <span class="muted">No one assigned</span>
60
+ {/if}
61
+ <span class="assignee-caret" aria-hidden="true">βΎ</span>
62
+ </summary>
63
+ <div class="assignee-menu">
64
+ <p class="assignee-menu-head">Assign this issue</p>
65
+ {! free text handles any user; Enter submits. datalist offers the same names as the list !}
66
+ <form class="assignee-search" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/assign">
67
+ <input type="text" name="assignee" list="assignees" placeholder="Type a usernameβ¦" autocomplete="off">
68
+ <datalist id="assignees">
69
+ {#for candidate in assignees}
70
+ <option value="{candidate.username}">
71
+ {/for}
72
+ </datalist>
73
+ </form>
74
+ <ul class="assignee-options">
75
+ {#for candidate in assignees}
76
+ <li>
77
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/assign">
78
+ <button type="submit" name="assignee" value="{candidate.username}"
79
+ class="assignee-option{#if issue.assignee && candidate.username == issue.assignee.username} is-selected{/if}">
80
+ <span class="assignee-check" aria-hidden="true">{#if issue.assignee && candidate.username == issue.assignee.username}β{/if}</span>
81
+ {#avatar user=candidate /} <span>{candidate.username}</span>
82
+ </button>
83
+ </form>
84
+ </li>
85
+ {/for}
86
+ </ul>
87
+ {#if issue.assignee}
88
+ {! separate form with no assignee field: posts blank, which unassigns the issue !}
89
+ <form class="assignee-clear" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/assign">
90
+ <button type="submit" class="assignee-option assignee-clear-btn">Clear assignee</button>
91
+ </form>
92
+ {/if}
93
+ </div>
94
+ </details>
95
+ {#else}
96
+ <div class="assignee-current">
97
+ {#if issue.assignee}
98
+ {#avatar user=issue.assignee /} <span class="assignee-name">{issue.assignee.username}</span>
99
+ {#else}
100
+ <span class="muted">No one assigned</span>
101
+ {/if}
102
+ </div>
103
+ {/if}
104
+ </section>
105
+ </aside>
16
106
</div>
17
- {#if descriptionHtml}
18
- <div class="panel issue-desc">
19
- <div class="readme-body">{descriptionHtml.raw}</div>
20
- </div>
21
- {#else}
22
- <p class="muted">No description provided.</p>
23
- {/if}
24
- {#if owner}
25
- <div class="issue-manage">
26
- <a class="btn btn-secondary btn-sm" href="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/edit">Edit issue</a>
27
- <form class="issue-actions" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/status">
28
- <span class="lbl">Move to</span>
29
- {#for status in statuses}
30
- <button type="submit" class="btn btn-secondary btn-sm" name="status" value="{status}"{#if status == issue.status} disabled{/if}>{status.label}</button>
31
- {/for}
32
- </form>
33
- <form class="issue-assign" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/assign">
34
- <span class="lbl">Assignee</span>
35
- <input type="text" name="assignee" list="assignees" placeholder="username" autocomplete="off"
36
- value="{issue.assignee.username ?: ''}">
37
- <datalist id="assignees">
38
- {#for candidate in assignees}
39
- <option value="{candidate.username}">
40
- {/for}
41
- </datalist>
42
- <button type="submit" class="btn btn-secondary btn-sm">Assign</button>
43
- </form>
44
- <form class="issue-remove" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/delete"
45
- onsubmit="return confirm('Delete this issue? This cannot be undone.')">
46
- <button type="submit" class="btn btn-danger btn-sm">Delete issue</button>
47
- </form>
48
- </div>
49
- {/if}
50
107
</section>
51
108
</div>
52
109
{/include}
MODIFY
src/main/resources/templates/layout.html
+2 -2
@@ -5,8 +5,8 @@
5
5
<meta name="viewport" content="width=device-width, initial-scale=1">
6
6
<title>{#insert title}git-shark{/}</title>
7
7
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
8
- <link rel="stylesheet" href="/shark.css">
9
- <script defer src="/shark-hotkeys.js"></script>
8
+ <link rel="stylesheet" href="/shark.css?v={cdi:assets.version}">
9
+ <script defer src="/shark-hotkeys.js?v={cdi:assets.version}"></script>
10
10
</head>
11
11
<body class="{cdi:currentUser.contentWidthClass}">
12
12
<header class="site">
MODIFY
src/test/java/de/workaround/web/IssueUiTest.java
+25 -7
@@ -87,9 +87,12 @@
87
87
collaboratorService.add(owner, repo, collab.username);
88
88
Issue issue = issueService.create(owner, repo, "Assign me", null);
89
89
90
- // the assign input is backed by a datalist offering the owner and every collaborator
90
+ // the assignee is a floating popover (click the row to open) with a click-to-assign people list
91
+ // of the owner + collaborators, plus a free-text field backed by a datalist
91
92
given().when().get("/repos/" + owner.username + "/assignable/issues/" + issue.number)
92
93
.then().statusCode(200)
94
+ .body(containsString("<details class=\"assignee-pop\""))
95
+ .body(containsString("class=\"assignee-option"))
93
96
.body(containsString("<datalist id=\"assignees\""))
94
97
.body(containsString("list=\"assignees\""))
95
98
.body(containsString("value=\"iss-assign\""))
@@ -105,10 +108,11 @@
105
108
Issue issue = issueService.create(owner, repo, "Has an owner", null);
106
109
issueService.assign(owner, issue, owner.username);
107
110
108
- // the assignee is named and rendered through the avatar tag (initials fallback here, no uploaded image)
111
+ // the assignee is named in the metadata sidebar and rendered through the avatar tag
112
+ // (initials fallback here, since the test user has no uploaded image)
109
113
given().when().get("/repos/" + owner.username + "/avassign/issues/" + issue.number)
110
114
.then().statusCode(200)
111
- .body(containsString("assigned to"))
115
+ .body(containsString("class=\"assignee-name\""))
112
116
.body(containsString("class=\"av-fallback\""));
113
117
}
114
118
@@ -130,7 +134,7 @@
130
134
.header("Location", org.hamcrest.Matchers.endsWith(url));
131
135
given().when().get(url)
132
136
.then().statusCode(200)
133
- .body(containsString("assigned to"))
137
+ .body(containsString("class=\"assignee-name\""))
134
138
.body(containsString(helper.username));
135
139
136
140
// posting a blank username clears the assignment again
@@ -138,7 +142,7 @@
138
142
.contentType("application/x-www-form-urlencoded").formParam("assignee", "")
139
143
.when().post(url + "/assign")
140
144
.then().statusCode(303);
141
- given().when().get(url).then().statusCode(200).body(containsString("unassigned"));
145
+ given().when().get(url).then().statusCode(200).body(containsString("No one assigned"));
142
146
}
143
147
144
148
@Test
@@ -185,6 +189,20 @@
185
189
}
186
190
187
191
@Test
192
+ @TestSecurity(user = "iss-assets")
193
+ void stylesheetLinkIsCacheBusted()
194
+ {
195
+ // every page shares layout.html; the shark.css link must carry a ?v= token so browsers pick up
196
+ // CSS changes after a deploy instead of serving a stale cached copy
197
+ User owner = persistUser("iss-assets");
198
+ service.create(owner, "assetsrepo", Repository.Visibility.PUBLIC, null);
199
+
200
+ given().when().get("/repos/" + owner.username + "/assetsrepo/issues")
201
+ .then().statusCode(200)
202
+ .body(containsString("/shark.css?v="));
203
+ }
204
+
205
+ @Test
188
206
@TestSecurity(user = "iss-md")
189
207
void issueDescriptionRendersMarkdown()
190
208
{
@@ -337,11 +355,11 @@
337
355
String base = "/repos/" + owner.username + "/editable/issues";
338
356
String editUrl = base + "/" + issue.number + "/edit";
339
357
340
- // the detail page links to the edit form from a single management bar
358
+ // the detail page links to the edit form from the header action cluster
341
359
given().when().get(base + "/" + issue.number)
342
360
.then().statusCode(200)
343
361
.body(containsString(editUrl))
344
- .body(containsString("class=\"issue-manage\""));
362
+ .body(containsString("class=\"issue-header-actions\""));
345
363
346
364
// the edit form is pre-filled with the current title and description
347
365
given().when().get(editUrl)