this repo has no description

wip

Signed-off-by: oppiliappan <me@oppi.li>

+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
··· 53 53 CreatedAt: r.Created.Format(time.RFC3339), 54 54 Source: source, 55 55 Spindle: spindle, 56 + Labels: r.Labels, 56 57 } 57 58 } 58 59
+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
··· 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
··· 29 29 "split": func(s string) []string { 30 30 return strings.Split(s, "\n") 31 31 }, 32 + "trimPrefix": func(s, prefix string) string { 33 + return strings.TrimPrefix(s, prefix) 34 + }, 32 35 "join": func(elems []string, sep string) string { 33 36 return strings.Join(elems, sep) 34 37 },
+24
appview/pages/pages.go
··· 1210 1210 return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.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
··· 52 52 53 53 type RepoInfo struct { 54 54 Name string 55 + Rkey string 55 56 OwnerDid string 56 57 OwnerHandle string 57 58 Description string
+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
··· 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
··· 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
··· 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
··· 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
··· 1 + {{ define "repo/fragments/labelSectionHeaderText" }} 2 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">{{ . }}</span> 3 + {{ end }}
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 184 184 OwnerDid: f.OwnerDid(), 185 185 OwnerHandle: f.OwnerHandle(), 186 186 Name: f.Name, 187 + Rkey: f.Repo.Rkey, 187 188 RepoAt: repoAt, 188 189 Description: f.Description, 189 190 IsStarred: isStarred,
+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 }