+71
-3
appview/db/label.go
+71
-3
appview/db/label.go
···
77
77
return vt.Type == ConcreteTypeBool
78
78
}
79
79
80
-
func (vt ValueType) IsEnumType() bool {
80
+
func (vt ValueType) IsEnum() bool {
81
81
return len(vt.Enum) > 0
82
82
}
83
83
···
631
631
return false
632
632
}
633
633
634
-
func (s *LabelState) GetValSet(l string) set {
635
-
return s.inner[l]
634
+
// go maps behavior in templates make this necessary,
635
+
// indexing a map and getting `set` in return is apparently truthy
636
+
func (s LabelState) ContainsLabelAndVal(l, v string) bool {
637
+
if valset, exists := s.inner[l]; exists {
638
+
if _, exists := valset[v]; exists {
639
+
return true
640
+
}
641
+
}
642
+
643
+
return false
644
+
}
645
+
646
+
func (s LabelState) GetValSet(l string) set {
647
+
if valset, exists := s.inner[l]; exists {
648
+
return valset
649
+
} else {
650
+
return make(set)
651
+
}
636
652
}
637
653
638
654
type LabelApplicationCtx struct {
···
719
735
_ = c.ApplyLabelOp(state, o)
720
736
}
721
737
}
738
+
739
+
// IsInverse checks if one label operation is the inverse of another
740
+
// returns true if one is an add and the other is a delete with the same key and value
741
+
func (op1 LabelOp) IsInverse(op2 LabelOp) bool {
742
+
if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue {
743
+
return false
744
+
}
745
+
746
+
return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) ||
747
+
(op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd)
748
+
}
749
+
750
+
// removes pairs of label operations that are inverses of each other
751
+
// from the given slice. the function preserves the order of remaining operations.
752
+
func ReduceLabelOps(ops []LabelOp) []LabelOp {
753
+
if len(ops) <= 1 {
754
+
return ops
755
+
}
756
+
757
+
keep := make([]bool, len(ops))
758
+
for i := range keep {
759
+
keep[i] = true
760
+
}
761
+
762
+
for i := range ops {
763
+
if !keep[i] {
764
+
continue
765
+
}
766
+
767
+
for j := i + 1; j < len(ops); j++ {
768
+
if !keep[j] {
769
+
continue
770
+
}
771
+
772
+
if ops[i].IsInverse(ops[j]) {
773
+
keep[i] = false
774
+
keep[j] = false
775
+
break // move to next i since this one is now eliminated
776
+
}
777
+
}
778
+
}
779
+
780
+
// build result slice with only kept operations
781
+
var result []LabelOp
782
+
for i, op := range ops {
783
+
if keep[i] {
784
+
result = append(result, op)
785
+
}
786
+
}
787
+
788
+
return result
789
+
}
+1
appview/db/repos.go
+1
appview/db/repos.go
+2
-2
appview/issues/issues.go
+2
-2
appview/issues/issues.go
···
108
108
defs[l.AtUri().String()] = &l
109
109
}
110
110
111
-
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
111
+
fmt.Println(rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
112
112
LoggedInUser: user,
113
113
RepoInfo: f.RepoInfo(user),
114
114
Issue: issue,
···
117
117
Reactions: reactionCountMap,
118
118
UserReacted: userReactions,
119
119
LabelDefs: defs,
120
-
})
120
+
}))
121
121
}
122
122
123
123
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
+69
-49
appview/labels/labels.go
+69
-49
appview/labels/labels.go
···
53
53
func (l *Labels) Router(mw *middleware.Middleware) http.Handler {
54
54
r := chi.NewRouter()
55
55
56
-
r.With(middleware.AuthMiddleware(l.oauth)).Put("/perform", l.PerformLabelOp)
56
+
r.Use(middleware.AuthMiddleware(l.oauth))
57
+
r.Put("/perform", l.PerformLabelOp)
57
58
58
59
return r
59
60
}
60
61
62
+
// this is a tricky handler implementation:
63
+
// - the user selects the new state of all the labels in the label panel and hits save
64
+
// - this handler should calculate the diff in order to create the labelop record
65
+
// - we need the diff in order to maintain a "history" of operations performed by users
61
66
func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) {
62
67
user := l.oauth.GetUser(r)
63
68
69
+
noticeId := "add-label-error"
70
+
71
+
fail := func(msg string, err error) {
72
+
l.logger.Error("failed to add label", "err", err)
73
+
l.pages.Notice(w, noticeId, msg)
74
+
}
75
+
64
76
if err := r.ParseForm(); err != nil {
65
-
l.logger.Error("failed to parse form data", "error", err)
66
-
http.Error(w, "Invalid form data", http.StatusBadRequest)
77
+
fail("Invalid form.", err)
67
78
return
68
79
}
69
80
···
73
84
indexedAt := time.Now()
74
85
repoAt := r.Form.Get("repo")
75
86
subjectUri := r.Form.Get("subject")
76
-
keys := r.Form["operand-key"]
77
-
vals := r.Form["operand-val"]
78
-
79
-
var labelOps []db.LabelOp
80
-
for i := range len(keys) {
81
-
op := r.FormValue(fmt.Sprintf("op-%d", i))
82
-
if op == "" {
83
-
op = string(db.LabelOperationDel)
84
-
}
85
-
key := keys[i]
86
-
val := vals[i]
87
-
88
-
labelOps = append(labelOps, db.LabelOp{
89
-
Did: did,
90
-
Rkey: rkey,
91
-
Subject: syntax.ATURI(subjectUri),
92
-
Operation: db.LabelOperation(op),
93
-
OperandKey: key,
94
-
OperandValue: val,
95
-
PerformedAt: performedAt,
96
-
IndexedAt: indexedAt,
97
-
})
98
-
}
99
87
100
88
// find all the labels that this repo subscribes to
101
89
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
102
90
if err != nil {
103
-
http.Error(w, "Invalid form data", http.StatusBadRequest)
91
+
fail("Failed to get labels for this repository.", err)
104
92
return
105
93
}
106
94
···
111
99
112
100
actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts))
113
101
if err != nil {
114
-
http.Error(w, "Invalid form data", http.StatusBadRequest)
102
+
fail("Invalid form data.", err)
115
103
return
116
104
}
117
105
118
-
for i := range labelOps {
119
-
def := actx.Defs[labelOps[i].OperandKey]
120
-
if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil {
121
-
l.logger.Error("form failed to validate", "err", err)
122
-
http.Error(w, "Invalid form data", http.StatusBadRequest)
123
-
return
124
-
}
125
-
126
-
l.logger.Info("value changed to: ", "v", labelOps[i].OperandValue)
127
-
}
128
-
129
106
// calculate the start state by applying already known labels
130
107
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
131
108
if err != nil {
132
-
http.Error(w, "Invalid form data", http.StatusBadRequest)
109
+
fail("Invalid form data.", err)
133
110
return
134
111
}
135
112
136
113
labelState := db.NewLabelState()
137
114
actx.ApplyLabelOps(labelState, existingOps)
138
115
139
-
l.logger.Info("state", "state", labelState)
116
+
var labelOps []db.LabelOp
117
+
118
+
// first delete all existing state
119
+
for key, vals := range labelState.Inner() {
120
+
for val := range vals {
121
+
labelOps = append(labelOps, db.LabelOp{
122
+
Did: did,
123
+
Rkey: rkey,
124
+
Subject: syntax.ATURI(subjectUri),
125
+
Operation: db.LabelOperationDel,
126
+
OperandKey: key,
127
+
OperandValue: val,
128
+
PerformedAt: performedAt,
129
+
IndexedAt: indexedAt,
130
+
})
131
+
}
132
+
}
133
+
134
+
// add all the new state the user specified
135
+
for key, vals := range r.Form {
136
+
if _, ok := actx.Defs[key]; !ok {
137
+
continue
138
+
}
139
+
140
+
for _, val := range vals {
141
+
labelOps = append(labelOps, db.LabelOp{
142
+
Did: did,
143
+
Rkey: rkey,
144
+
Subject: syntax.ATURI(subjectUri),
145
+
Operation: db.LabelOperationAdd,
146
+
OperandKey: key,
147
+
OperandValue: val,
148
+
PerformedAt: performedAt,
149
+
IndexedAt: indexedAt,
150
+
})
151
+
}
152
+
}
153
+
154
+
// reduce the opset
155
+
labelOps = db.ReduceLabelOps(labelOps)
156
+
157
+
for i := range labelOps {
158
+
def := actx.Defs[labelOps[i].OperandKey]
159
+
if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil {
160
+
fail(fmt.Sprintf("Invalid form data: %s", err), err)
161
+
return
162
+
}
163
+
}
140
164
141
165
// next, apply all ops introduced in this request and filter out ones that are no-ops
142
166
validLabelOps := labelOps[:0]
···
157
181
158
182
client, err := l.oauth.AuthorizedClient(r)
159
183
if err != nil {
160
-
l.logger.Error("failed to create client", "error", err)
161
-
http.Error(w, "Invalid form data", http.StatusBadRequest)
184
+
fail("Failed to authorize user.", err)
162
185
return
163
186
}
164
187
···
171
194
},
172
195
})
173
196
if err != nil {
174
-
l.logger.Error("failed to write to PDS", "error", err)
175
-
http.Error(w, "failed to write to PDS", http.StatusInternalServerError)
197
+
fail("Failed to create record on PDS for user.", err)
176
198
return
177
199
}
178
200
atUri := resp.Uri
179
201
180
202
tx, err := l.db.BeginTx(r.Context(), nil)
181
203
if err != nil {
182
-
l.logger.Error("failed to start tx", "error", err)
204
+
fail("Failed to update labels. Try again later.", err)
183
205
return
184
206
}
185
207
···
200
222
201
223
for _, o := range validLabelOps {
202
224
if _, err := db.AddLabelOp(l.db, &o); err != nil {
203
-
l.logger.Error("failed to add op", "err", err)
225
+
fail("Failed to update labels. Try again later.", err)
204
226
return
205
227
}
206
-
207
-
l.logger.Info("performed label op", "did", o.Did, "rkey", o.Rkey, "kind", o.Operation, "subjcet", o.Subject, "key", o.OperandKey)
208
228
}
209
229
210
230
err = tx.Commit()
+3
appview/pages/funcmap.go
+3
appview/pages/funcmap.go
+24
appview/pages/pages.go
+24
appview/pages/pages.go
···
1210
1210
return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
1211
1211
}
1212
1212
1213
+
type LabelPanelParams struct {
1214
+
LoggedInUser *oauth.User
1215
+
RepoInfo repoinfo.RepoInfo
1216
+
Defs map[string]*db.LabelDefinition
1217
+
Subject string
1218
+
State db.LabelState
1219
+
}
1220
+
1221
+
func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
1222
+
return p.executePlain("repo/fragments/labelPanel", w, params)
1223
+
}
1224
+
1225
+
type EditLabelPanelParams struct {
1226
+
LoggedInUser *oauth.User
1227
+
RepoInfo repoinfo.RepoInfo
1228
+
Defs map[string]*db.LabelDefinition
1229
+
Subject string
1230
+
State db.LabelState
1231
+
}
1232
+
1233
+
func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
1234
+
return p.executePlain("repo/fragments/editLabelPanel", w, params)
1235
+
}
1236
+
1213
1237
type PipelinesParams struct {
1214
1238
LoggedInUser *oauth.User
1215
1239
RepoInfo repoinfo.RepoInfo
+1
appview/pages/repoinfo/repoinfo.go
+1
appview/pages/repoinfo/repoinfo.go
+21
-2
appview/pages/templates/labels/fragments/label.html
+21
-2
appview/pages/templates/labels/fragments/label.html
···
1
1
{{ define "labels/fragments/label" }}
2
2
{{ $d := .def }}
3
3
{{ $v := .val }}
4
+
{{ $withPrefix := .withPrefix }}
4
5
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
5
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
6
-
{{ $d.Name }}{{ if not $d.ValueType.IsNull }}/{{ template "labelVal" (dict "def" $d "val" $v) }}{{ end }}
7
+
8
+
{{ $lhs := printf "%s" $d.Name }}
9
+
{{ $rhs := "" }}
10
+
11
+
{{ if not $d.ValueType.IsNull }}
12
+
{{ if $d.ValueType.IsDidFormat }}
13
+
{{ $v = resolve $v }}
14
+
{{ end }}
15
+
16
+
{{ if not $withPrefix }}
17
+
{{ $lhs = "" }}
18
+
{{ else }}
19
+
{{ $lhs = printf "%s/" $d.Name }}
20
+
{{ end }}
21
+
22
+
{{ $rhs = printf "%s" $v }}
23
+
{{ end }}
24
+
25
+
{{ printf "%s%s" $lhs $rhs }}
7
26
</span>
8
27
{{ end }}
9
28
···
13
32
{{ $v := .val }}
14
33
15
34
{{ if $d.ValueType.IsDidFormat }}
16
-
{{ resolve $v }}
35
+
{{ resolve $v }}
17
36
{{ else }}
18
37
{{ $v }}
19
38
{{ end }}
-127
appview/pages/templates/repo/fragments/addLabelModal.html
-127
appview/pages/templates/repo/fragments/addLabelModal.html
···
1
-
{{ define "repo/fragments/addLabelModal" }}
2
-
{{ $root := .root }}
3
-
{{ $subject := .subject }}
4
-
{{ $state := .state }}
5
-
{{ with $root }}
6
-
<form
7
-
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
8
-
hx-on::after-request="this.reset()"
9
-
hx-indicator="#spinner"
10
-
hx-swap="none"
11
-
class="flex flex-col gap-4"
12
-
>
13
-
<p class="text-gray-500 dark:text-gray-400">Add, remove or update labels.</p>
14
-
15
-
<input class="hidden" name="repo" value="{{ .RepoInfo.RepoAt.String }}">
16
-
<input class="hidden" name="subject" value="{{ $subject }}">
17
-
18
-
<div class="flex flex-col gap-2">
19
-
{{ $id := 0 }}
20
-
{{ range $k, $valset := $state.Inner }}
21
-
{{ $d := index $root.LabelDefs $k }}
22
-
{{ range $v, $s := $valset }}
23
-
{{ template "labelCheckbox" (dict "def" $d "key" $k "val" $v "id" $id "isChecked" true) }}
24
-
{{ $id = add $id 1 }}
25
-
{{ end }}
26
-
{{ end }}
27
-
28
-
{{ range $k, $d := $root.LabelDefs }}
29
-
{{ if not ($state.ContainsLabel $k) }}
30
-
{{ template "labelCheckbox" (dict "def" $d "key" $k "val" "" "id" $id "isChecked" false) }}
31
-
{{ $id = add $id 1 }}
32
-
{{ end }}
33
-
{{ else }}
34
-
<span>
35
-
No labels defined yet. You can define custom labels in <a class="underline" href="/{{ .RepoInfo.FullName }}/settings">settings</a>.
36
-
</span>
37
-
{{ end }}
38
-
</div>
39
-
40
-
<div class="flex gap-2 pt-2">
41
-
<button
42
-
type="button"
43
-
popovertarget="add-label-modal"
44
-
popovertargetaction="hide"
45
-
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
46
-
>
47
-
{{ i "x" "size-4" }} cancel
48
-
</button>
49
-
<button type="submit" class="btn w-1/2 flex items-center">
50
-
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span>
51
-
<span id="spinner" class="group">
52
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
53
-
</span>
54
-
</button>
55
-
</div>
56
-
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
57
-
</form>
58
-
{{ end }}
59
-
{{ end }}
60
-
61
-
{{ define "labelCheckbox" }}
62
-
{{ $key := .key }}
63
-
{{ $val := .val }}
64
-
{{ $def := .def }}
65
-
{{ $id := .id }}
66
-
{{ $isChecked := .isChecked }}
67
-
<div class="grid grid-cols-[auto_1fr_50%] gap-2 items-center cursor-pointer">
68
-
<input type="checkbox" id="op-{{$id}}" name="op-{{$id}}" value="add" {{if $isChecked}}checked{{end}} class="peer">
69
-
<label for="op-{{$id}}" class="flex items-center gap-2 text-base">{{ template "labels/fragments/labelDef" $def }}</label>
70
-
<div class="w-full hidden peer-checked:block">{{ template "valueTypeInput" (dict "valueType" $def.ValueType "value" $val "key" $key) }}</div>
71
-
<input type="hidden" name="operand-key" value="{{ $key }}">
72
-
</div>
73
-
{{ end }}
74
-
75
-
{{ define "valueTypeInput" }}
76
-
{{ $valueType := .valueType }}
77
-
{{ $value := .value }}
78
-
{{ $key := .key }}
79
-
80
-
{{ if $valueType.IsEnumType }}
81
-
{{ template "enumTypeInput" $ }}
82
-
{{ else if $valueType.IsBool }}
83
-
{{ template "boolTypeInput" $ }}
84
-
{{ else if $valueType.IsInt }}
85
-
{{ template "intTypeInput" $ }}
86
-
{{ else if $valueType.IsString }}
87
-
{{ template "stringTypeInput" $ }}
88
-
{{ else if $valueType.IsNull }}
89
-
{{ template "nullTypeInput" $ }}
90
-
{{ end }}
91
-
{{ end }}
92
-
93
-
{{ define "enumTypeInput" }}
94
-
{{ $valueType := .valueType }}
95
-
{{ $value := .value }}
96
-
<select name="operand-val" class="w-full p-1 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
97
-
{{ range $valueType.Enum }}
98
-
<option value="{{.}}" {{ if eq $value . }} selected {{ end }}>{{.}}</option>
99
-
{{ end }}
100
-
</select>
101
-
{{ end }}
102
-
103
-
{{ define "boolTypeInput" }}
104
-
{{ $value := .value }}
105
-
<select name="operand-val" class="w-full p-1 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
106
-
<option value="true" {{ if $value }} selected {{ end }}>true</option>
107
-
<option value="false" {{ if not $value }} selected {{ end }}>false</option>
108
-
</select>
109
-
{{ end }}
110
-
111
-
{{ define "intTypeInput" }}
112
-
{{ $value := .value }}
113
-
<input class="p-1 w-full" type="number" name="operand-val" value="{{$value}}" max="100">
114
-
{{ end }}
115
-
116
-
{{ define "stringTypeInput" }}
117
-
{{ $valueType := .valueType }}
118
-
{{ $value := .value }}
119
-
{{ if $valueType.IsDidFormat }}
120
-
{{ $value = resolve .value }}
121
-
{{ end }}
122
-
<input class="p-1 w-full" type="text" name="operand-val" value="{{$value}}">
123
-
{{ end }}
124
-
125
-
{{ define "nullTypeInput" }}
126
-
<input class="p-1" type="hidden" name="operand-val" value="null">
127
-
{{ end }}
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
···
1
+
{{ define "repo/fragments/editLabelPanel" }}
2
+
<form
3
+
id="edit-label-panel"
4
+
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
5
+
hx-on::after-request="this.reset()"
6
+
hx-indicator="#spinner"
7
+
hx-disabled-elt="#save-btn,#cancel-btn"
8
+
hx-swap="none"
9
+
class="flex flex-col gap-6"
10
+
>
11
+
<input type="hidden" name="repo" value="{{ .RepoInfo.RepoAt }}">
12
+
<input type="hidden" name="subject" value="{{ .Subject }}">
13
+
{{ template "editBasicLabels" . }}
14
+
{{ template "editKvLabels" . }}
15
+
{{ template "editLabelPanelActions" . }}
16
+
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
17
+
</form>
18
+
{{ end }}
19
+
20
+
{{ define "editBasicLabels" }}
21
+
{{ $defs := .Defs }}
22
+
{{ $subject := .Subject }}
23
+
{{ $state := .State }}
24
+
{{ $labelStyle := "flex items-center gap-2 rounded py-1 px-2 border border-gray-200 dark:border-gray-700 text-sm bg-white dark:bg-gray-800 text-black dark:text-white" }}
25
+
<div>
26
+
{{ template "repo/fragments/labelSectionHeaderText" "Labels" }}
27
+
28
+
<div class="flex gap-1 items-center flex-wrap">
29
+
{{ range $k, $d := $defs }}
30
+
{{ $isChecked := $state.ContainsLabel $k }}
31
+
{{ if $d.ValueType.IsNull }}
32
+
{{ $fieldName := $d.AtUri }}
33
+
<label class="{{$labelStyle}}">
34
+
<input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}>
35
+
{{ template "labels/fragments/labelDef" $d }}
36
+
</label>
37
+
{{ end }}
38
+
{{ else }}
39
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">
40
+
No labels defined yet. You can define custom labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>.
41
+
</p>
42
+
{{ end }}
43
+
</div>
44
+
</div>
45
+
{{ end }}
46
+
47
+
{{ define "editKvLabels" }}
48
+
{{ $defs := .Defs }}
49
+
{{ $subject := .Subject }}
50
+
{{ $state := .State }}
51
+
{{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-1" }}
52
+
53
+
{{ range $k, $d := $defs }}
54
+
{{ if (not $d.ValueType.IsNull) }}
55
+
{{ $fieldName := $d.AtUri }}
56
+
{{ $valset := $state.GetValSet $k }}
57
+
<div id="label-{{$d.Id}}" class="flex flex-col gap-1">
58
+
{{ template "repo/fragments/labelSectionHeaderText" $d.Name }}
59
+
{{ if (and $d.Multiple $d.ValueType.IsEnum) }}
60
+
<!-- checkbox -->
61
+
{{ range $variant := $d.ValueType.Enum }}
62
+
<label class="{{$labelStyle}}">
63
+
<input type="checkbox" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
64
+
{{ $variant }}
65
+
</label>
66
+
{{ end }}
67
+
{{ else if $d.Multiple }}
68
+
<!-- dynamically growing input fields -->
69
+
{{ range $v, $s := $valset }}
70
+
{{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }}
71
+
{{ else }}
72
+
{{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }}
73
+
{{ end }}
74
+
{{ template "addFieldButton" $d }}
75
+
{{ else if $d.ValueType.IsEnum }}
76
+
<!-- radio buttons -->
77
+
{{ $isUsed := $state.ContainsLabel $k }}
78
+
{{ range $variant := $d.ValueType.Enum }}
79
+
<label class="{{$labelStyle}}">
80
+
<input type="radio" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
81
+
{{ $variant }}
82
+
</label>
83
+
{{ end }}
84
+
<label class="{{$labelStyle}}">
85
+
<input type="radio" name="{{ $fieldName }}" value="" {{ if not $isUsed }}checked{{ end }}>
86
+
None
87
+
</label>
88
+
{{ else }}
89
+
<!-- single input field based on value type -->
90
+
{{ range $v, $s := $valset }}
91
+
{{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }}
92
+
{{ else }}
93
+
{{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }}
94
+
{{ end }}
95
+
{{ end }}
96
+
</div>
97
+
{{ end }}
98
+
{{ end }}
99
+
{{ end }}
100
+
101
+
{{ define "multipleInputField" }}
102
+
<div class="flex gap-1 items-stretch">
103
+
{{ template "valueTypeInput" . }}
104
+
{{ template "removeFieldButton" }}
105
+
</div>
106
+
{{ end }}
107
+
108
+
{{ define "addFieldButton" }}
109
+
<div style="display:none" id="tpl-{{ .Id }}">
110
+
{{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }}
111
+
</div>
112
+
<button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2">
113
+
{{ i "plus" "size-4" }} add
114
+
</button>
115
+
{{ end }}
116
+
117
+
{{ define "removeFieldButton" }}
118
+
<button type="button" onClick="this.parentElement.remove()" class="btn flex items-center gap-2 text-red-400 dark:text-red-500">
119
+
{{ i "trash-2" "size-4" }}
120
+
</button>
121
+
{{ end }}
122
+
123
+
{{ define "valueTypeInput" }}
124
+
{{ $def := .def }}
125
+
{{ $valueType := $def.ValueType }}
126
+
{{ $value := .value }}
127
+
{{ $key := .key }}
128
+
129
+
{{ if $valueType.IsBool }}
130
+
{{ template "boolTypeInput" $ }}
131
+
{{ else if $valueType.IsInt }}
132
+
{{ template "intTypeInput" $ }}
133
+
{{ else if $valueType.IsString }}
134
+
{{ template "stringTypeInput" $ }}
135
+
{{ else if $valueType.IsNull }}
136
+
{{ template "nullTypeInput" $ }}
137
+
{{ end }}
138
+
{{ end }}
139
+
140
+
{{ define "boolTypeInput" }}
141
+
{{ $def := .def }}
142
+
{{ $fieldName := $def.AtUri }}
143
+
{{ $value := .value }}
144
+
{{ $labelStyle = "font-normal normal-case flex items-center gap-2" }}
145
+
<div class="flex flex-col gap-1">
146
+
<label class="{{$labelStyle}}">
147
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
148
+
None
149
+
</label>
150
+
<label class="{{$labelStyle}}">
151
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
152
+
None
153
+
</label>
154
+
<label class="{{$labelStyle}}">
155
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
156
+
None
157
+
</label>
158
+
</div>
159
+
{{ end }}
160
+
161
+
{{ define "intTypeInput" }}
162
+
{{ $def := .def }}
163
+
{{ $fieldName := $def.AtUri }}
164
+
{{ $value := .value }}
165
+
<input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}">
166
+
{{ end }}
167
+
168
+
{{ define "stringTypeInput" }}
169
+
{{ $def := .def }}
170
+
{{ $fieldName := $def.AtUri }}
171
+
{{ $valueType := $def.ValueType }}
172
+
{{ $value := .value }}
173
+
{{ if $valueType.IsDidFormat }}
174
+
{{ $value = trimPrefix (resolve .value) "@" }}
175
+
{{ end }}
176
+
<input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}">
177
+
{{ end }}
178
+
179
+
{{ define "nullTypeInput" }}
180
+
{{ $def := .def }}
181
+
{{ $fieldName := $def.AtUri }}
182
+
<input class="p-1" type="hidden" name="{{$fieldName}}" value="null">
183
+
{{ end }}
184
+
185
+
{{ define "editLabelPanelActions" }}
186
+
<div class="flex gap-2 pt-2">
187
+
<button
188
+
id="cancel-btn"
189
+
type="button"
190
+
hx-get="/{{ .RepoInfo.FullName }}/label"
191
+
hx-vals='{"subject": "{{.Subject}}"}'
192
+
hx-swap="outerHTML"
193
+
hx-target="#edit-label-panel"
194
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 group">
195
+
{{ i "x" "size-4" }} cancel
196
+
</button>
197
+
198
+
<button
199
+
id="save-btn"
200
+
type="submit"
201
+
class="btn w-1/2 flex items-center">
202
+
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span>
203
+
<span id="spinner" class="group">
204
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
205
+
</span>
206
+
</button>
207
+
</div>
208
+
{{ end }}
+43
appview/pages/templates/repo/fragments/labelPanel.html
+43
appview/pages/templates/repo/fragments/labelPanel.html
···
1
+
{{ define "repo/fragments/labelPanel" }}
2
+
<div id="label-panel" class="flex flex-col gap-6">
3
+
{{ template "basicLabels" . }}
4
+
{{ template "kvLabels" . }}
5
+
</div>
6
+
{{ end }}
7
+
8
+
{{ define "basicLabels" }}
9
+
<div>
10
+
{{ template "repo/fragments/labelSectionHeader" (dict "Name" "Labels" "RepoInfo" .RepoInfo "Subject" .Subject) }}
11
+
12
+
{{ $hasLabel := false }}
13
+
<div class="flex gap-1 items-center flex-wrap">
14
+
{{ range $k, $d := .Defs }}
15
+
{{ if (and $d.ValueType.IsNull ($.State.ContainsLabel $k)) }}
16
+
{{ $hasLabel = true }}
17
+
{{ template "labels/fragments/label" (dict "def" $d "val" "") }}
18
+
{{ end }}
19
+
{{ end }}
20
+
21
+
{{ if not $hasLabel }}
22
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p>
23
+
{{ end }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
27
+
28
+
{{ define "kvLabels" }}
29
+
{{ range $k, $d := .Defs }}
30
+
{{ if (not $d.ValueType.IsNull) }}
31
+
<div id="label-{{$d.Id}}">
32
+
{{ template "repo/fragments/labelSectionHeader" (dict "Name" $d.Name "RepoInfo" $.RepoInfo "Subject" $.Subject) }}
33
+
<div class="flex gap-1 items-center flex-wrap">
34
+
{{ range $v, $s := $.State.GetValSet $d.AtUri.String }}
35
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" false) }}
36
+
{{ else }}
37
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p>
38
+
{{ end }}
39
+
</div>
40
+
</div>
41
+
{{ end }}
42
+
{{ end }}
43
+
{{ end }}
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
···
1
+
{{ define "repo/fragments/labelSectionHeader" }}
2
+
3
+
<div class="flex justify-between items-center gap-2">
4
+
{{ template "repo/fragments/labelSectionHeaderText" .Name }}
5
+
{{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }}
6
+
<a
7
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
8
+
hx-get="/{{ .RepoInfo.FullName }}/label/edit"
9
+
hx-vals='{"subject": "{{.Subject}}"}'
10
+
hx-swap="outerHTML"
11
+
hx-target="#label-panel">
12
+
{{ i "pencil" "size-3" }}
13
+
</a>
14
+
{{ end }}
15
+
</div>
16
+
{{ end }}
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
+7
-34
appview/pages/templates/repo/issues/issue.html
+7
-34
appview/pages/templates/repo/issues/issue.html
···
17
17
{{ block "repoAfter" . }}{{ end }}
18
18
</div>
19
19
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
20
-
{{ template "issueLabels" . }}
20
+
{{ template "repo/fragments/labelPanel"
21
+
(dict "RepoInfo" $.RepoInfo
22
+
"Defs" $.LabelDefs
23
+
"Subject" $.Issue.AtUri
24
+
"State" $.Issue.Labels) }}
21
25
{{ template "issueParticipants" . }}
22
26
</div>
23
27
</div>
···
118
122
</div>
119
123
{{ end }}
120
124
121
-
{{ define "issueLabels" }}
122
-
<div>
123
-
<div class="text-sm py-1 flex items-center gap-2 font-bold text-gray-500 dark:text-gray-400 capitalize">
124
-
Labels
125
-
<button
126
-
class="inline-flex text-gray-500 dark:text-gray-400 {{ if not (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }}hidden{{ end }}"
127
-
popovertarget="add-label-modal"
128
-
popovertargetaction="toggle">
129
-
{{ i "plus" "size-4" }}
130
-
</button>
131
-
</div>
132
-
<div class="flex gap-1 items-center flex-wrap">
133
-
{{ range $k, $valset := $.Issue.Labels.Inner }}
134
-
{{ $d := index $.LabelDefs $k }}
135
-
{{ range $v, $s := $valset }}
136
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v) }}
137
-
{{ end }}
138
-
{{ else }}
139
-
<p class="text-gray-500 dark:text-gray-400 ">No labels yet.</p>
140
-
{{ end }}
141
-
142
-
<div
143
-
id="add-label-modal"
144
-
popover
145
-
class="bg-white w-full sm:w-[30rem] dark:bg-gray-800 p-6 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
146
-
{{ template "repo/fragments/addLabelModal" (dict "root" $ "subject" $.Issue.AtUri.String "state" $.Issue.Labels) }}
147
-
</div>
148
-
</div>
149
-
</div>
150
-
{{ end }}
151
-
152
125
{{ define "issueParticipants" }}
153
126
{{ $all := .Issue.Participants }}
154
127
{{ $ps := take $all 5 }}
···
157
130
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
158
131
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
159
132
</div>
160
-
<div class="flex items-center -space-x-2 mt-2">
133
+
<div class="flex items-center -space-x-3 mt-2">
161
134
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
162
135
{{ range $i, $p := $ps }}
163
136
<img
164
137
src="{{ tinyAvatar . }}"
165
138
alt=""
166
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-300 dark:border-gray-700 z-{{sub 5 $i}}0"
139
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
167
140
/>
168
141
{{ end }}
169
142
+1
-1
appview/pages/templates/repo/issues/issues.html
+1
-1
appview/pages/templates/repo/issues/issues.html
···
85
85
{{ range $k, $valset := .Labels.Inner }}
86
86
{{ $d := index $.LabelDefs $k }}
87
87
{{ range $v, $s := $valset }}
88
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v) }}
88
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
89
89
{{ end }}
90
90
{{ end }}
91
91
{{ end }}
+131
-83
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
+131
-83
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
···
1
1
{{ define "repo/settings/fragments/addLabelDefModal" }}
2
-
<form
3
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
4
-
hx-indicator="#spinner"
5
-
hx-swap="none"
6
-
hx-on::after-request="if(event.detail.successful) this.reset()"
7
-
class="flex flex-col gap-4"
8
-
>
9
-
<p class="text-gray-500 dark:text-gray-400">Labels can have a name and a value. Set the value type to "none" to create a simple label.</p>
2
+
<div class="grid grid-cols-2">
3
+
<input type="radio" name="tab" id="basic-tab" value="basic" class="hidden peer/basic" checked>
4
+
<input type="radio" name="tab" id="kv-tab" value="kv" class="hidden peer/kv">
5
+
6
+
<!-- Labels as direct siblings -->
7
+
{{ $base := "py-2 text-sm font-normal normal-case block hover:no-underline text-center cursor-pointer bg-gray-100 dark:bg-gray-800 shadow-inner border border-gray-200 dark:border-gray-700" }}
8
+
<label for="basic-tab" class="{{$base}} peer-checked/basic:bg-white peer-checked/basic:dark:bg-gray-700 peer-checked/basic:shadow-sm rounded-l">
9
+
Basic Labels
10
+
</label>
11
+
<label for="kv-tab" class="{{$base}} peer-checked/kv:!bg-white peer-checked/kv:dark:bg-gray-700 peer-checked/kv:shadow-sm rounded-r">
12
+
Key-value Labels
13
+
</label>
10
14
11
-
<div class="w-full">
12
-
<label for="name">Name</label>
13
-
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
15
+
<!-- Basic Labels Content - direct sibling -->
16
+
<div class="mt-4 hidden peer-checked/basic:block col-span-full">
17
+
{{ template "basicLabelDef" . }}
14
18
</div>
15
19
16
-
<!-- Value Type -->
17
-
<div class="w-full">
18
-
<label for="valueType">Value Type</label>
19
-
<select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
20
-
<option value="null" selected>None</option>
21
-
<option value="string">String</option>
22
-
<option value="integer">Integer</option>
23
-
<option value="boolean">Boolean</option>
24
-
</select>
25
-
<details id="constrain-values" class="group hidden">
26
-
<summary class="list-none cursor-pointer flex items-center gap-2 py-2">
27
-
<span class="group-open:hidden inline text-gray-500 dark:text-gray-400">{{ i "square-plus" "w-4 h-4" }}</span>
28
-
<span class="hidden group-open:inline text-gray-500 dark:text-gray-400">{{ i "square-minus" "w-4 h-4" }}</span>
29
-
<span>Constrain values</span>
30
-
</summary>
31
-
<label for="enumValues">Permitted values</label>
32
-
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
33
-
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Enter comma-separated list of permitted values.</p>
20
+
<!-- Key-value Labels Content - direct sibling -->
21
+
<div class="mt-4 hidden peer-checked/kv:block col-span-full">
22
+
{{ template "kvLabelDef" . }}
23
+
</div>
24
+
25
+
<div id="add-label-error" class="text-red-500 dark:text-red-400 col-span-full"></div>
26
+
</div>
27
+
{{ end }}
28
+
29
+
{{ define "basicLabelDef" }}
30
+
<form
31
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
32
+
hx-indicator="#spinner"
33
+
hx-swap="none"
34
+
hx-on::after-request="if(event.detail.successful) this.reset()"
35
+
class="flex flex-col space-y-4">
36
+
37
+
<p class="text-gray-500 dark:text-gray-400">These labels can have a name and a color.</p>
38
+
39
+
{{ template "nameInput" . }}
40
+
{{ template "scopeInput" . }}
41
+
{{ template "colorInput" . }}
42
+
43
+
<div class="flex gap-2 pt-2">
44
+
{{ template "cancelButton" . }}
45
+
{{ template "submitButton" . }}
46
+
</div>
47
+
</form>
48
+
{{ end }}
49
+
50
+
{{ define "kvLabelDef" }}
51
+
<form
52
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
53
+
hx-indicator="#spinner"
54
+
hx-swap="none"
55
+
hx-on::after-request="if(event.detail.successful) this.reset()"
56
+
class="flex flex-col space-y-4">
57
+
58
+
<p class="text-gray-500 dark:text-gray-400">
59
+
These labels are more detailed, they can have a key and an associated
60
+
value. You may define additional constraints on label values.
61
+
</p>
62
+
63
+
{{ template "nameInput" . }}
64
+
{{ template "valueInput" . }}
65
+
{{ template "multipleInput" . }}
66
+
{{ template "scopeInput" . }}
67
+
{{ template "colorInput" . }}
34
68
35
-
<label for="valueFormat">String format</label>
36
-
<select id="valueFormat" name="valueFormat" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
37
-
<option value="any" selected>Any</option>
38
-
<option value="did">DID</option>
39
-
</select>
40
-
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Choose a string format.</p>
41
-
</details>
42
-
</div>
69
+
<div class="flex gap-2 pt-2">
70
+
{{ template "cancelButton" . }}
71
+
{{ template "submitButton" . }}
72
+
</div>
73
+
</form>
74
+
{{ end }}
43
75
44
-
<!-- Scope -->
76
+
{{ define "nameInput" }}
45
77
<div class="w-full">
46
-
<label for="scope">Scope</label>
47
-
<select id="scope" name="scope" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
48
-
<option value="sh.tangled.repo.issue">Issues</option>
49
-
<option value="sh.tangled.repo.pull">Pull Requests</option>
50
-
</select>
78
+
<label for="name">Name</label>
79
+
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
51
80
</div>
81
+
{{ end }}
52
82
53
-
<!-- Color -->
83
+
{{ define "colorInput" }}
54
84
<div class="w-full">
55
85
<label for="color">Color</label>
56
86
<div class="grid grid-cols-4 grid-rows-2 place-items-center">
···
63
93
{{ end }}
64
94
</div>
65
95
</div>
96
+
{{ end }}
66
97
67
-
<!-- Multiple -->
68
-
<div class="w-full flex flex-wrap gap-2">
69
-
<input type="checkbox" id="multiple" name="multiple" value="true" />
70
-
<span>
71
-
Allow multiple values
72
-
</span>
98
+
{{ define "scopeInput" }}
99
+
<div class="w-full">
100
+
<label for="scope">Scope</label>
101
+
<select id="scope" name="scope" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
102
+
<option value="sh.tangled.repo.issue">Issues</option>
103
+
<option value="sh.tangled.repo.pull">Pull Requests</option>
104
+
</select>
73
105
</div>
106
+
{{ end }}
74
107
75
-
<div class="flex gap-2 pt-2">
76
-
<button
77
-
type="button"
78
-
popovertarget="add-labeldef-modal"
79
-
popovertargetaction="hide"
80
-
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
81
-
>
82
-
{{ i "x" "size-4" }} cancel
83
-
</button>
84
-
<button type="submit" class="btn w-1/2 flex items-center">
85
-
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
86
-
<span id="spinner" class="group">
87
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
88
-
</span>
89
-
</button>
108
+
{{ define "valueInput" }}
109
+
<div class="w-full">
110
+
<label for="valueType">Value Type</label>
111
+
<select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
112
+
<option value="string">String</option>
113
+
<option value="integer">Integer</option>
114
+
</select>
115
+
</div>
116
+
117
+
<div class="w-full">
118
+
<label for="enumValues">Permitted values</label>
119
+
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
120
+
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">
121
+
Enter comma-separated list of permitted values, or leave empty to allow any value.
122
+
</p>
123
+
</div>
124
+
125
+
<div class="w-full">
126
+
<label for="valueFormat">String format</label>
127
+
<select id="valueFormat" name="valueFormat" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
128
+
<option value="any" selected>Any</option>
129
+
<option value="did">DID</option>
130
+
</select>
90
131
</div>
91
-
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
92
-
</form>
132
+
{{ end }}
93
133
94
-
<script>
95
-
document.getElementById('value-type').addEventListener('change', function() {
96
-
const constrainValues = document.getElementById('constrain-values');
97
-
const selectedValue = this.value;
134
+
{{ define "multipleInput" }}
135
+
<div class="w-full flex flex-wrap gap-2">
136
+
<input type="checkbox" id="multiple" name="multiple" value="true" />
137
+
<span>Allow multiple values</span>
138
+
</div>
139
+
{{ end }}
98
140
99
-
if (selectedValue === 'string') {
100
-
constrainValues.classList.remove('hidden');
101
-
} else {
102
-
constrainValues.classList.add('hidden');
103
-
constrainValues.removeAttribute('open');
104
-
document.getElementById('enumValues').value = '';
105
-
}
106
-
});
141
+
{{ define "cancelButton" }}
142
+
<button
143
+
type="button"
144
+
popovertarget="add-labeldef-modal"
145
+
popovertargetaction="hide"
146
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
147
+
>
148
+
{{ i "x" "size-4" }} cancel
149
+
</button>
150
+
{{ end }}
107
151
108
-
function toggleDarkMode() {
109
-
document.documentElement.classList.toggle('dark');
110
-
}
111
-
</script>
152
+
{{ define "submitButton" }}
153
+
<button type="submit" class="btn-create w-1/2 flex items-center">
154
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
155
+
<span id="spinner" class="group">
156
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
157
+
</span>
158
+
</button>
112
159
{{ end }}
113
160
161
+
+16
-2
appview/pages/templates/repo/settings/fragments/labelListing.html
+16
-2
appview/pages/templates/repo/settings/fragments/labelListing.html
···
5
5
<div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
6
6
{{ template "labels/fragments/labelDef" $label }}
7
7
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
8
-
{{ $label.ValueType.Type }} type
9
-
{{ if $label.ValueType.IsEnumType }}
8
+
{{ if $label.ValueType.IsNull }}
9
+
basic
10
+
{{ else }}
11
+
{{ $label.ValueType.Type }} type
12
+
{{ end }}
13
+
14
+
{{ if $label.ValueType.IsEnum }}
10
15
<span class="before:content-['·'] before:select-none"></span>
11
16
{{ join $label.ValueType.Enum ", " }}
12
17
{{ end }}
18
+
13
19
{{ if $label.ValueType.IsDidFormat }}
14
20
<span class="before:content-['·'] before:select-none"></span>
15
21
DID format
16
22
{{ end }}
23
+
24
+
{{ if $label.Multiple }}
25
+
<span class="before:content-['·'] before:select-none"></span>
26
+
multiple
27
+
{{ end }}
28
+
29
+
<span class="before:content-['·'] before:select-none"></span>
30
+
{{ $label.Scope }}
17
31
</div>
18
32
</div>
19
33
{{ if $root.RepoInfo.Roles.IsOwner }}
+1
-1
appview/pages/templates/repo/settings/general.html
+1
-1
appview/pages/templates/repo/settings/general.html
···
65
65
<div
66
66
id="add-labeldef-modal"
67
67
popover
68
-
class="bg-white w-full sm:w-[30rem] dark:bg-gray-800 p-6 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
68
+
class="bg-white w-full sm:w-[30rem] dark:bg-gray-800 p-6 max-h-dvh overflow-y-auto rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
69
69
{{ template "repo/settings/fragments/addLabelDefModal" . }}
70
70
</div>
71
71
</div>
+106
-2
appview/repo/repo.go
+106
-2
appview/repo/repo.go
···
942
942
rp.pages.HxRefresh(w)
943
943
}
944
944
945
-
func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) {
945
+
func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) {
946
946
user := rp.oauth.GetUser(r)
947
947
l := rp.logger.With("handler", "AddLabel")
948
948
l = l.With("did", user.Did)
···
974
974
if part = strings.TrimSpace(part); part != "" {
975
975
variants = append(variants, part)
976
976
}
977
+
}
978
+
979
+
if concreteType == "" {
980
+
concreteType = "null"
977
981
}
978
982
979
983
format := db.ValueTypeFormatAny
···
1048
1052
Val: &repoRecord,
1049
1053
},
1050
1054
})
1055
+
if err != nil {
1056
+
fail("Failed to update labels for repo.", err)
1057
+
return
1058
+
}
1051
1059
1052
1060
tx, err := rp.db.BeginTx(r.Context(), nil)
1053
1061
if err != nil {
···
1094
1102
rp.pages.HxRefresh(w)
1095
1103
}
1096
1104
1097
-
func (rp *Repo) DeleteLabel(w http.ResponseWriter, r *http.Request) {
1105
+
func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) {
1098
1106
user := rp.oauth.GetUser(r)
1099
1107
l := rp.logger.With("handler", "DeleteLabel")
1100
1108
l = l.With("did", user.Did)
···
1335
1343
1336
1344
// everything succeeded
1337
1345
rp.pages.HxRefresh(w)
1346
+
}
1347
+
1348
+
func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) {
1349
+
l := rp.logger.With("handler", "LabelPanel")
1350
+
1351
+
f, err := rp.repoResolver.Resolve(r)
1352
+
if err != nil {
1353
+
l.Error("failed to get repo and knot", "err", err)
1354
+
return
1355
+
}
1356
+
1357
+
subjectStr := r.FormValue("subject")
1358
+
subject, err := syntax.ParseATURI(subjectStr)
1359
+
if err != nil {
1360
+
l.Error("failed to get repo and knot", "err", err)
1361
+
return
1362
+
}
1363
+
1364
+
labelDefs, err := db.GetLabelDefinitions(
1365
+
rp.db,
1366
+
db.FilterIn("at_uri", f.Repo.Labels),
1367
+
db.FilterEq("scope", subject.Collection().String()),
1368
+
)
1369
+
if err != nil {
1370
+
log.Println("failed to fetch label defs", err)
1371
+
return
1372
+
}
1373
+
1374
+
defs := make(map[string]*db.LabelDefinition)
1375
+
for _, l := range labelDefs {
1376
+
defs[l.AtUri().String()] = &l
1377
+
}
1378
+
1379
+
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1380
+
if err != nil {
1381
+
log.Println("failed to build label state", err)
1382
+
return
1383
+
}
1384
+
state := states[subject]
1385
+
1386
+
user := rp.oauth.GetUser(r)
1387
+
rp.pages.LabelPanel(w, pages.LabelPanelParams{
1388
+
LoggedInUser: user,
1389
+
RepoInfo: f.RepoInfo(user),
1390
+
Defs: defs,
1391
+
Subject: subject.String(),
1392
+
State: state,
1393
+
})
1394
+
}
1395
+
1396
+
func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) {
1397
+
l := rp.logger.With("handler", "EditLabelPanel")
1398
+
1399
+
f, err := rp.repoResolver.Resolve(r)
1400
+
if err != nil {
1401
+
l.Error("failed to get repo and knot", "err", err)
1402
+
return
1403
+
}
1404
+
1405
+
subjectStr := r.FormValue("subject")
1406
+
subject, err := syntax.ParseATURI(subjectStr)
1407
+
if err != nil {
1408
+
l.Error("failed to get repo and knot", "err", err)
1409
+
return
1410
+
}
1411
+
1412
+
labelDefs, err := db.GetLabelDefinitions(
1413
+
rp.db,
1414
+
db.FilterIn("at_uri", f.Repo.Labels),
1415
+
db.FilterEq("scope", subject.Collection().String()),
1416
+
)
1417
+
if err != nil {
1418
+
log.Println("failed to fetch labels", err)
1419
+
return
1420
+
}
1421
+
1422
+
defs := make(map[string]*db.LabelDefinition)
1423
+
for _, l := range labelDefs {
1424
+
defs[l.AtUri().String()] = &l
1425
+
}
1426
+
1427
+
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1428
+
if err != nil {
1429
+
log.Println("failed to build label state", err)
1430
+
return
1431
+
}
1432
+
state := states[subject]
1433
+
1434
+
user := rp.oauth.GetUser(r)
1435
+
fmt.Println(rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
1436
+
LoggedInUser: user,
1437
+
RepoInfo: f.RepoInfo(user),
1438
+
Defs: defs,
1439
+
Subject: subject.String(),
1440
+
State: state,
1441
+
}))
1338
1442
}
1339
1443
1340
1444
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
+8
-2
appview/repo/router.go
+8
-2
appview/repo/router.go
···
64
64
r.Get("/*", rp.RepoCompare)
65
65
})
66
66
67
+
// label panel in issues/pulls/discussions/tasks
68
+
r.Route("/label", func(r chi.Router) {
69
+
r.Get("/", rp.LabelPanel)
70
+
r.Get("/edit", rp.EditLabelPanel)
71
+
})
72
+
67
73
// settings routes, needs auth
68
74
r.Group(func(r chi.Router) {
69
75
r.Use(middleware.AuthMiddleware(rp.oauth))
···
76
82
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
77
83
r.Get("/", rp.RepoSettings)
78
84
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
79
-
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabel)
80
-
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabel)
85
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
86
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
81
87
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label/subscribe", rp.SubscribeLabel)
82
88
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label/subscribe", rp.UnsubscribeLabel)
83
89
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
+1
appview/reporesolver/resolver.go
+1
appview/reporesolver/resolver.go
+27
-5
appview/validator/label.go
+27
-5
appview/validator/label.go
···
36
36
}
37
37
38
38
if !label.ValueType.IsConcreteType() {
39
-
return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType)
39
+
return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type)
40
40
}
41
41
42
-
if label.ValueType.IsNull() && label.ValueType.IsEnumType() {
42
+
// null type checks: cannot be enums, multiple or explicit format
43
+
if label.ValueType.IsNull() && label.ValueType.IsEnum() {
43
44
return fmt.Errorf("null type cannot be used in conjunction with enum type")
45
+
}
46
+
if label.ValueType.IsNull() && label.Multiple {
47
+
return fmt.Errorf("null type labels cannot be multiple")
48
+
}
49
+
if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() {
50
+
return fmt.Errorf("format cannot be used in conjunction with null type")
51
+
}
52
+
53
+
// format checks: cannot be used with enum, or integers
54
+
if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() {
55
+
return fmt.Errorf("enum types cannot be used in conjunction with format specification")
56
+
}
57
+
58
+
if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() {
59
+
return fmt.Errorf("format specifications are only permitted on string types")
44
60
}
45
61
46
62
// validate scope (nsid format)
···
116
132
func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error {
117
133
valueType := labelDef.ValueType
118
134
135
+
// this is permitted, it "unsets" a label
136
+
if labelOp.OperandValue == "" {
137
+
labelOp.Operation = db.LabelOperationDel
138
+
return nil
139
+
}
140
+
119
141
switch valueType.Type {
120
142
case db.ConcreteTypeNull:
121
143
// For null type, value should be empty
···
125
147
126
148
case db.ConcreteTypeString:
127
149
// For string type, validate enum constraints if present
128
-
if valueType.IsEnumType() {
150
+
if valueType.IsEnum() {
129
151
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
130
152
return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
131
153
}
···
153
175
return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue)
154
176
}
155
177
156
-
if valueType.IsEnumType() {
178
+
if valueType.IsEnum() {
157
179
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
158
180
return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
159
181
}
···
165
187
}
166
188
167
189
// validate enum constraints if present (though uncommon for booleans)
168
-
if valueType.IsEnumType() {
190
+
if valueType.IsEnum() {
169
191
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
170
192
return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
171
193
}