diff --git a/appview/db/label.go b/appview/db/label.go index d8a82301..ed782e49 100644 --- a/appview/db/label.go +++ b/appview/db/label.go @@ -77,7 +77,7 @@ func (vt ValueType) IsBool() bool { return vt.Type == ConcreteTypeBool } -func (vt ValueType) IsEnumType() bool { +func (vt ValueType) IsEnum() bool { return len(vt.Enum) > 0 } @@ -631,8 +631,24 @@ func (s LabelState) ContainsLabel(l string) bool { return false } -func (s *LabelState) GetValSet(l string) set { - return s.inner[l] +// go maps behavior in templates make this necessary, +// indexing a map and getting `set` in return is apparently truthy +func (s LabelState) ContainsLabelAndVal(l, v string) bool { + if valset, exists := s.inner[l]; exists { + if _, exists := valset[v]; exists { + return true + } + } + + return false +} + +func (s LabelState) GetValSet(l string) set { + if valset, exists := s.inner[l]; exists { + return valset + } else { + return make(set) + } } type LabelApplicationCtx struct { @@ -719,3 +735,55 @@ func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { _ = c.ApplyLabelOp(state, o) } } + +// IsInverse checks if one label operation is the inverse of another +// returns true if one is an add and the other is a delete with the same key and value +func (op1 LabelOp) IsInverse(op2 LabelOp) bool { + if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { + return false + } + + return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || + (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) +} + +// removes pairs of label operations that are inverses of each other +// from the given slice. the function preserves the order of remaining operations. +func ReduceLabelOps(ops []LabelOp) []LabelOp { + if len(ops) <= 1 { + return ops + } + + keep := make([]bool, len(ops)) + for i := range keep { + keep[i] = true + } + + for i := range ops { + if !keep[i] { + continue + } + + for j := i + 1; j < len(ops); j++ { + if !keep[j] { + continue + } + + if ops[i].IsInverse(ops[j]) { + keep[i] = false + keep[j] = false + break // move to next i since this one is now eliminated + } + } + } + + // build result slice with only kept operations + var result []LabelOp + for i, op := range ops { + if keep[i] { + result = append(result, op) + } + } + + return result +} diff --git a/appview/db/repos.go b/appview/db/repos.go index 432b3da5..c6ad0221 100644 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -53,6 +53,7 @@ func (r *Repo) AsRecord() tangled.Repo { CreatedAt: r.Created.Format(time.RFC3339), Source: source, Spindle: spindle, + Labels: r.Labels, } } diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 8430668d..a0bbbf65 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -108,7 +108,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { defs[l.AtUri().String()] = &l } - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ + fmt.Println(rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ LoggedInUser: user, RepoInfo: f.RepoInfo(user), Issue: issue, @@ -117,7 +117,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { Reactions: reactionCountMap, UserReacted: userReactions, LabelDefs: defs, - }) + })) } func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { diff --git a/appview/labels/labels.go b/appview/labels/labels.go index 1e63f01f..0d54d05a 100644 --- a/appview/labels/labels.go +++ b/appview/labels/labels.go @@ -53,17 +53,28 @@ func New( func (l *Labels) Router(mw *middleware.Middleware) http.Handler { r := chi.NewRouter() - r.With(middleware.AuthMiddleware(l.oauth)).Put("/perform", l.PerformLabelOp) + r.Use(middleware.AuthMiddleware(l.oauth)) + r.Put("/perform", l.PerformLabelOp) return r } +// this is a tricky handler implementation: +// - the user selects the new state of all the labels in the label panel and hits save +// - this handler should calculate the diff in order to create the labelop record +// - we need the diff in order to maintain a "history" of operations performed by users func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { user := l.oauth.GetUser(r) + noticeId := "add-label-error" + + fail := func(msg string, err error) { + l.logger.Error("failed to add label", "err", err) + l.pages.Notice(w, noticeId, msg) + } + if err := r.ParseForm(); err != nil { - l.logger.Error("failed to parse form data", "error", err) - http.Error(w, "Invalid form data", http.StatusBadRequest) + fail("Invalid form.", err) return } @@ -73,34 +84,11 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { indexedAt := time.Now() repoAt := r.Form.Get("repo") subjectUri := r.Form.Get("subject") - keys := r.Form["operand-key"] - vals := r.Form["operand-val"] - - var labelOps []db.LabelOp - for i := range len(keys) { - op := r.FormValue(fmt.Sprintf("op-%d", i)) - if op == "" { - op = string(db.LabelOperationDel) - } - key := keys[i] - val := vals[i] - - labelOps = append(labelOps, db.LabelOp{ - Did: did, - Rkey: rkey, - Subject: syntax.ATURI(subjectUri), - Operation: db.LabelOperation(op), - OperandKey: key, - OperandValue: val, - PerformedAt: performedAt, - IndexedAt: indexedAt, - }) - } // find all the labels that this repo subscribes to repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) if err != nil { - http.Error(w, "Invalid form data", http.StatusBadRequest) + fail("Failed to get labels for this repository.", err) return } @@ -111,32 +99,68 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts)) if err != nil { - http.Error(w, "Invalid form data", http.StatusBadRequest) + fail("Invalid form data.", err) return } - for i := range labelOps { - def := actx.Defs[labelOps[i].OperandKey] - if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil { - l.logger.Error("form failed to validate", "err", err) - http.Error(w, "Invalid form data", http.StatusBadRequest) - return - } - - l.logger.Info("value changed to: ", "v", labelOps[i].OperandValue) - } - // calculate the start state by applying already known labels existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) if err != nil { - http.Error(w, "Invalid form data", http.StatusBadRequest) + fail("Invalid form data.", err) return } labelState := db.NewLabelState() actx.ApplyLabelOps(labelState, existingOps) - l.logger.Info("state", "state", labelState) + var labelOps []db.LabelOp + + // first delete all existing state + for key, vals := range labelState.Inner() { + for val := range vals { + labelOps = append(labelOps, db.LabelOp{ + Did: did, + Rkey: rkey, + Subject: syntax.ATURI(subjectUri), + Operation: db.LabelOperationDel, + OperandKey: key, + OperandValue: val, + PerformedAt: performedAt, + IndexedAt: indexedAt, + }) + } + } + + // add all the new state the user specified + for key, vals := range r.Form { + if _, ok := actx.Defs[key]; !ok { + continue + } + + for _, val := range vals { + labelOps = append(labelOps, db.LabelOp{ + Did: did, + Rkey: rkey, + Subject: syntax.ATURI(subjectUri), + Operation: db.LabelOperationAdd, + OperandKey: key, + OperandValue: val, + PerformedAt: performedAt, + IndexedAt: indexedAt, + }) + } + } + + // reduce the opset + labelOps = db.ReduceLabelOps(labelOps) + + for i := range labelOps { + def := actx.Defs[labelOps[i].OperandKey] + if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil { + fail(fmt.Sprintf("Invalid form data: %s", err), err) + return + } + } // next, apply all ops introduced in this request and filter out ones that are no-ops validLabelOps := labelOps[:0] @@ -157,8 +181,7 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { client, err := l.oauth.AuthorizedClient(r) if err != nil { - l.logger.Error("failed to create client", "error", err) - http.Error(w, "Invalid form data", http.StatusBadRequest) + fail("Failed to authorize user.", err) return } @@ -171,15 +194,14 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { }, }) if err != nil { - l.logger.Error("failed to write to PDS", "error", err) - http.Error(w, "failed to write to PDS", http.StatusInternalServerError) + fail("Failed to create record on PDS for user.", err) return } atUri := resp.Uri tx, err := l.db.BeginTx(r.Context(), nil) if err != nil { - l.logger.Error("failed to start tx", "error", err) + fail("Failed to update labels. Try again later.", err) return } @@ -200,11 +222,9 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { for _, o := range validLabelOps { if _, err := db.AddLabelOp(l.db, &o); err != nil { - l.logger.Error("failed to add op", "err", err) + fail("Failed to update labels. Try again later.", err) return } - - l.logger.Info("performed label op", "did", o.Did, "rkey", o.Rkey, "kind", o.Operation, "subjcet", o.Subject, "key", o.OperandKey) } err = tx.Commit() diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index 2a2bfa8a..e2973731 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -29,6 +29,9 @@ func (p *Pages) funcMap() template.FuncMap { "split": func(s string) []string { return strings.Split(s, "\n") }, + "trimPrefix": func(s, prefix string) string { + return strings.TrimPrefix(s, prefix) + }, "join": func(elems []string, sep string) string { return strings.Join(elems, sep) }, diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 72cc7370..ab8612fa 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1210,6 +1210,30 @@ func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff}) } +type LabelPanelParams struct { + LoggedInUser *oauth.User + RepoInfo repoinfo.RepoInfo + Defs map[string]*db.LabelDefinition + Subject string + State db.LabelState +} + +func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { + return p.executePlain("repo/fragments/labelPanel", w, params) +} + +type EditLabelPanelParams struct { + LoggedInUser *oauth.User + RepoInfo repoinfo.RepoInfo + Defs map[string]*db.LabelDefinition + Subject string + State db.LabelState +} + +func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { + return p.executePlain("repo/fragments/editLabelPanel", w, params) +} + type PipelinesParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo diff --git a/appview/pages/repoinfo/repoinfo.go b/appview/pages/repoinfo/repoinfo.go index 5fa4673e..bfbb4463 100644 --- a/appview/pages/repoinfo/repoinfo.go +++ b/appview/pages/repoinfo/repoinfo.go @@ -52,6 +52,7 @@ func (r RepoInfo) GetTabs() [][]string { type RepoInfo struct { Name string + Rkey string OwnerDid string OwnerHandle string Description string diff --git a/appview/pages/templates/labels/fragments/label.html b/appview/pages/templates/labels/fragments/label.html index 386c5a73..3a6932a8 100644 --- a/appview/pages/templates/labels/fragments/label.html +++ b/appview/pages/templates/labels/fragments/label.html @@ -1,9 +1,28 @@ {{ define "labels/fragments/label" }} {{ $d := .def }} {{ $v := .val }} + {{ $withPrefix := .withPrefix }} {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} - {{ $d.Name }}{{ if not $d.ValueType.IsNull }}/{{ template "labelVal" (dict "def" $d "val" $v) }}{{ end }} + + {{ $lhs := printf "%s" $d.Name }} + {{ $rhs := "" }} + + {{ if not $d.ValueType.IsNull }} + {{ if $d.ValueType.IsDidFormat }} + {{ $v = resolve $v }} + {{ end }} + + {{ if not $withPrefix }} + {{ $lhs = "" }} + {{ else }} + {{ $lhs = printf "%s/" $d.Name }} + {{ end }} + + {{ $rhs = printf "%s" $v }} + {{ end }} + + {{ printf "%s%s" $lhs $rhs }} {{ end }} @@ -13,7 +32,7 @@ {{ $v := .val }} {{ if $d.ValueType.IsDidFormat }} - {{ resolve $v }} + {{ resolve $v }} {{ else }} {{ $v }} {{ end }} diff --git a/appview/pages/templates/repo/fragments/addLabelModal.html b/appview/pages/templates/repo/fragments/addLabelModal.html deleted file mode 100644 index ab9b759e..00000000 --- a/appview/pages/templates/repo/fragments/addLabelModal.html +++ /dev/null @@ -1,127 +0,0 @@ -{{ define "repo/fragments/addLabelModal" }} - {{ $root := .root }} - {{ $subject := .subject }} - {{ $state := .state }} - {{ with $root }} -
-

Add, remove or update labels.

- - - - -
- {{ $id := 0 }} - {{ range $k, $valset := $state.Inner }} - {{ $d := index $root.LabelDefs $k }} - {{ range $v, $s := $valset }} - {{ template "labelCheckbox" (dict "def" $d "key" $k "val" $v "id" $id "isChecked" true) }} - {{ $id = add $id 1 }} - {{ end }} - {{ end }} - - {{ range $k, $d := $root.LabelDefs }} - {{ if not ($state.ContainsLabel $k) }} - {{ template "labelCheckbox" (dict "def" $d "key" $k "val" "" "id" $id "isChecked" false) }} - {{ $id = add $id 1 }} - {{ end }} - {{ else }} - - No labels defined yet. You can define custom labels in settings. - - {{ end }} -
- -
- - -
-
-
- {{ end }} -{{ end }} - -{{ define "labelCheckbox" }} - {{ $key := .key }} - {{ $val := .val }} - {{ $def := .def }} - {{ $id := .id }} - {{ $isChecked := .isChecked }} -
- - - - -
-{{ end }} - -{{ define "valueTypeInput" }} - {{ $valueType := .valueType }} - {{ $value := .value }} - {{ $key := .key }} - - {{ if $valueType.IsEnumType }} - {{ template "enumTypeInput" $ }} - {{ else if $valueType.IsBool }} - {{ template "boolTypeInput" $ }} - {{ else if $valueType.IsInt }} - {{ template "intTypeInput" $ }} - {{ else if $valueType.IsString }} - {{ template "stringTypeInput" $ }} - {{ else if $valueType.IsNull }} - {{ template "nullTypeInput" $ }} - {{ end }} -{{ end }} - -{{ define "enumTypeInput" }} - {{ $valueType := .valueType }} - {{ $value := .value }} - -{{ end }} - -{{ define "boolTypeInput" }} - {{ $value := .value }} - -{{ end }} - -{{ define "intTypeInput" }} - {{ $value := .value }} - -{{ end }} - -{{ define "stringTypeInput" }} - {{ $valueType := .valueType }} - {{ $value := .value }} - {{ if $valueType.IsDidFormat }} - {{ $value = resolve .value }} - {{ end }} - -{{ end }} - -{{ define "nullTypeInput" }} - -{{ end }} diff --git a/appview/pages/templates/repo/fragments/editLabelPanel.html b/appview/pages/templates/repo/fragments/editLabelPanel.html new file mode 100644 index 00000000..ea9e3073 --- /dev/null +++ b/appview/pages/templates/repo/fragments/editLabelPanel.html @@ -0,0 +1,208 @@ +{{ define "repo/fragments/editLabelPanel" }} +
+ + + {{ template "editBasicLabels" . }} + {{ template "editKvLabels" . }} + {{ template "editLabelPanelActions" . }} +
+
+{{ end }} + +{{ define "editBasicLabels" }} + {{ $defs := .Defs }} + {{ $subject := .Subject }} + {{ $state := .State }} + {{ $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" }} +
+ {{ template "repo/fragments/labelSectionHeaderText" "Labels" }} + +
+ {{ range $k, $d := $defs }} + {{ $isChecked := $state.ContainsLabel $k }} + {{ if $d.ValueType.IsNull }} + {{ $fieldName := $d.AtUri }} + + {{ end }} + {{ else }} +

+ No labels defined yet. You can define custom labels in settings. +

+ {{ end }} +
+
+{{ end }} + +{{ define "editKvLabels" }} + {{ $defs := .Defs }} + {{ $subject := .Subject }} + {{ $state := .State }} + {{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-1" }} + + {{ range $k, $d := $defs }} + {{ if (not $d.ValueType.IsNull) }} + {{ $fieldName := $d.AtUri }} + {{ $valset := $state.GetValSet $k }} +
+ {{ template "repo/fragments/labelSectionHeaderText" $d.Name }} + {{ if (and $d.Multiple $d.ValueType.IsEnum) }} + + {{ range $variant := $d.ValueType.Enum }} + + {{ end }} + {{ else if $d.Multiple }} + + {{ range $v, $s := $valset }} + {{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }} + {{ else }} + {{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }} + {{ end }} + {{ template "addFieldButton" $d }} + {{ else if $d.ValueType.IsEnum }} + + {{ $isUsed := $state.ContainsLabel $k }} + {{ range $variant := $d.ValueType.Enum }} + + {{ end }} + + {{ else }} + + {{ range $v, $s := $valset }} + {{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }} + {{ else }} + {{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }} + {{ end }} + {{ end }} +
+ {{ end }} + {{ end }} +{{ end }} + +{{ define "multipleInputField" }} +
+ {{ template "valueTypeInput" . }} + {{ template "removeFieldButton" }} +
+{{ end }} + +{{ define "addFieldButton" }} + + +{{ end }} + +{{ define "removeFieldButton" }} + +{{ end }} + +{{ define "valueTypeInput" }} + {{ $def := .def }} + {{ $valueType := $def.ValueType }} + {{ $value := .value }} + {{ $key := .key }} + + {{ if $valueType.IsBool }} + {{ template "boolTypeInput" $ }} + {{ else if $valueType.IsInt }} + {{ template "intTypeInput" $ }} + {{ else if $valueType.IsString }} + {{ template "stringTypeInput" $ }} + {{ else if $valueType.IsNull }} + {{ template "nullTypeInput" $ }} + {{ end }} +{{ end }} + +{{ define "boolTypeInput" }} + {{ $def := .def }} + {{ $fieldName := $def.AtUri }} + {{ $value := .value }} + {{ $labelStyle = "font-normal normal-case flex items-center gap-2" }} +
+ + + +
+{{ end }} + +{{ define "intTypeInput" }} + {{ $def := .def }} + {{ $fieldName := $def.AtUri }} + {{ $value := .value }} + +{{ end }} + +{{ define "stringTypeInput" }} + {{ $def := .def }} + {{ $fieldName := $def.AtUri }} + {{ $valueType := $def.ValueType }} + {{ $value := .value }} + {{ if $valueType.IsDidFormat }} + {{ $value = trimPrefix (resolve .value) "@" }} + {{ end }} + +{{ end }} + +{{ define "nullTypeInput" }} + {{ $def := .def }} + {{ $fieldName := $def.AtUri }} + +{{ end }} + +{{ define "editLabelPanelActions" }} +
+ + + +
+{{ end }} diff --git a/appview/pages/templates/repo/fragments/labelPanel.html b/appview/pages/templates/repo/fragments/labelPanel.html new file mode 100644 index 00000000..f475bdb8 --- /dev/null +++ b/appview/pages/templates/repo/fragments/labelPanel.html @@ -0,0 +1,43 @@ +{{ define "repo/fragments/labelPanel" }} +
+ {{ template "basicLabels" . }} + {{ template "kvLabels" . }} +
+{{ end }} + +{{ define "basicLabels" }} +
+ {{ template "repo/fragments/labelSectionHeader" (dict "Name" "Labels" "RepoInfo" .RepoInfo "Subject" .Subject) }} + + {{ $hasLabel := false }} +
+ {{ range $k, $d := .Defs }} + {{ if (and $d.ValueType.IsNull ($.State.ContainsLabel $k)) }} + {{ $hasLabel = true }} + {{ template "labels/fragments/label" (dict "def" $d "val" "") }} + {{ end }} + {{ end }} + + {{ if not $hasLabel }} +

None yet.

+ {{ end }} +
+
+{{ end }} + +{{ define "kvLabels" }} + {{ range $k, $d := .Defs }} + {{ if (not $d.ValueType.IsNull) }} +
+ {{ template "repo/fragments/labelSectionHeader" (dict "Name" $d.Name "RepoInfo" $.RepoInfo "Subject" $.Subject) }} +
+ {{ range $v, $s := $.State.GetValSet $d.AtUri.String }} + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" false) }} + {{ else }} +

None yet.

+ {{ end }} +
+
+ {{ end }} + {{ end }} +{{ end }} diff --git a/appview/pages/templates/repo/fragments/labelSectionHeader.html b/appview/pages/templates/repo/fragments/labelSectionHeader.html new file mode 100644 index 00000000..df9b4daa --- /dev/null +++ b/appview/pages/templates/repo/fragments/labelSectionHeader.html @@ -0,0 +1,16 @@ +{{ define "repo/fragments/labelSectionHeader" }} + +
+ {{ template "repo/fragments/labelSectionHeaderText" .Name }} + {{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }} + + {{ i "pencil" "size-3" }} + + {{ end }} +
+{{ end }} diff --git a/appview/pages/templates/repo/fragments/labelSectionHeaderText.html b/appview/pages/templates/repo/fragments/labelSectionHeaderText.html new file mode 100644 index 00000000..638fb11d --- /dev/null +++ b/appview/pages/templates/repo/fragments/labelSectionHeaderText.html @@ -0,0 +1,3 @@ +{{ define "repo/fragments/labelSectionHeaderText" }} + {{ . }} +{{ end }} diff --git a/appview/pages/templates/repo/issues/issue.html b/appview/pages/templates/repo/issues/issue.html index c4a08976..0b61cb78 100644 --- a/appview/pages/templates/repo/issues/issue.html +++ b/appview/pages/templates/repo/issues/issue.html @@ -17,7 +17,11 @@ {{ block "repoAfter" . }}{{ end }}
- {{ template "issueLabels" . }} + {{ template "repo/fragments/labelPanel" + (dict "RepoInfo" $.RepoInfo + "Defs" $.LabelDefs + "Subject" $.Issue.AtUri + "State" $.Issue.Labels) }} {{ template "issueParticipants" . }}
@@ -118,37 +122,6 @@ {{ end }} -{{ define "issueLabels" }} -
-
- Labels - -
-
- {{ range $k, $valset := $.Issue.Labels.Inner }} - {{ $d := index $.LabelDefs $k }} - {{ range $v, $s := $valset }} - {{ template "labels/fragments/label" (dict "def" $d "val" $v) }} - {{ end }} - {{ else }} -

No labels yet.

- {{ end }} - -
- {{ template "repo/fragments/addLabelModal" (dict "root" $ "subject" $.Issue.AtUri.String "state" $.Issue.Labels) }} -
-
-
-{{ end }} - {{ define "issueParticipants" }} {{ $all := .Issue.Participants }} {{ $ps := take $all 5 }} @@ -157,13 +130,13 @@ Participants {{ len $all }} -
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }} {{ range $i, $p := $ps }} {{ end }} diff --git a/appview/pages/templates/repo/issues/issues.html b/appview/pages/templates/repo/issues/issues.html index 794b5407..e767ced9 100644 --- a/appview/pages/templates/repo/issues/issues.html +++ b/appview/pages/templates/repo/issues/issues.html @@ -85,7 +85,7 @@ {{ range $k, $valset := .Labels.Inner }} {{ $d := index $.LabelDefs $k }} {{ range $v, $s := $valset }} - {{ template "labels/fragments/label" (dict "def" $d "val" $v) }} + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} {{ end }} {{ end }} {{ end }} diff --git a/appview/pages/templates/repo/settings/fragments/addLabelDefModal.html b/appview/pages/templates/repo/settings/fragments/addLabelDefModal.html index b6b6ff90..f5ff5c33 100644 --- a/appview/pages/templates/repo/settings/fragments/addLabelDefModal.html +++ b/appview/pages/templates/repo/settings/fragments/addLabelDefModal.html @@ -1,56 +1,86 @@ {{ define "repo/settings/fragments/addLabelDefModal" }} -
-

Labels can have a name and a value. Set the value type to "none" to create a simple label.

+
+ + -
- - + + {{ $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" }} + + + + + - -
- - - + + - +
+
+{{ end }} + +{{ define "basicLabelDef" }} + + +

These labels can have a name and a color.

+ + {{ template "nameInput" . }} + {{ template "scopeInput" . }} + {{ template "colorInput" . }} + +
+ {{ template "cancelButton" . }} + {{ template "submitButton" . }} +
+ +{{ end }} + +{{ define "kvLabelDef" }} +
+ +

+ These labels are more detailed, they can have a key and an associated + value. You may define additional constraints on label values. +

+ + {{ template "nameInput" . }} + {{ template "valueInput" . }} + {{ template "multipleInput" . }} + {{ template "scopeInput" . }} + {{ template "colorInput" . }} + +
+ {{ template "cancelButton" . }} + {{ template "submitButton" . }} +
+
+{{ end }} + +{{ define "nameInput" }}
- - + +
+{{ end }} - +{{ define "colorInput" }}
@@ -63,51 +93,69 @@ {{ end }}
+{{ end }} - -
- - - Allow multiple values - +{{ define "scopeInput" }} +
+ +
+{{ end }} -
- - +{{ define "valueInput" }} +
+ + +
+ +
+ + +

+ Enter comma-separated list of permitted values, or leave empty to allow any value. +

+
+ +
+ +
-
- - - {{ end }} +{{ define "multipleInput" }} +
+ + Allow multiple values +
+{{ end }} + +{{ define "cancelButton" }} + +{{ end }} + +{{ define "submitButton" }} + +{{ end }} + + diff --git a/appview/pages/templates/repo/settings/fragments/labelListing.html b/appview/pages/templates/repo/settings/fragments/labelListing.html index f7f03bb6..eedebcfa 100644 --- a/appview/pages/templates/repo/settings/fragments/labelListing.html +++ b/appview/pages/templates/repo/settings/fragments/labelListing.html @@ -5,15 +5,29 @@
{{ template "labels/fragments/labelDef" $label }}
- {{ $label.ValueType.Type }} type - {{ if $label.ValueType.IsEnumType }} + {{ if $label.ValueType.IsNull }} + basic + {{ else }} + {{ $label.ValueType.Type }} type + {{ end }} + + {{ if $label.ValueType.IsEnum }} {{ join $label.ValueType.Enum ", " }} {{ end }} + {{ if $label.ValueType.IsDidFormat }} DID format {{ end }} + + {{ if $label.Multiple }} + + multiple + {{ end }} + + + {{ $label.Scope }}
{{ if $root.RepoInfo.Roles.IsOwner }} diff --git a/appview/pages/templates/repo/settings/general.html b/appview/pages/templates/repo/settings/general.html index a01cf402..b5cd2344 100644 --- a/appview/pages/templates/repo/settings/general.html +++ b/appview/pages/templates/repo/settings/general.html @@ -65,7 +65,7 @@
+ 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"> {{ template "repo/settings/fragments/addLabelDefModal" . }}
diff --git a/appview/repo/repo.go b/appview/repo/repo.go index b267fed5..20bc6795 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -942,7 +942,7 @@ func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { rp.pages.HxRefresh(w) } -func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { +func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "AddLabel") l = l.With("did", user.Did) @@ -976,6 +976,10 @@ func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { } } + if concreteType == "" { + concreteType = "null" + } + format := db.ValueTypeFormatAny if valueFormat == "did" { format = db.ValueTypeFormatDid @@ -1048,6 +1052,10 @@ func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { Val: &repoRecord, }, }) + if err != nil { + fail("Failed to update labels for repo.", err) + return + } tx, err := rp.db.BeginTx(r.Context(), nil) if err != nil { @@ -1094,7 +1102,7 @@ func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { rp.pages.HxRefresh(w) } -func (rp *Repo) DeleteLabel(w http.ResponseWriter, r *http.Request) { +func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "DeleteLabel") l = l.With("did", user.Did) @@ -1337,6 +1345,102 @@ func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { rp.pages.HxRefresh(w) } +func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "LabelPanel") + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + subjectStr := r.FormValue("subject") + subject, err := syntax.ParseATURI(subjectStr) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + labelDefs, err := db.GetLabelDefinitions( + rp.db, + db.FilterIn("at_uri", f.Repo.Labels), + db.FilterEq("scope", subject.Collection().String()), + ) + if err != nil { + log.Println("failed to fetch label defs", err) + return + } + + defs := make(map[string]*db.LabelDefinition) + for _, l := range labelDefs { + defs[l.AtUri().String()] = &l + } + + states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) + if err != nil { + log.Println("failed to build label state", err) + return + } + state := states[subject] + + user := rp.oauth.GetUser(r) + rp.pages.LabelPanel(w, pages.LabelPanelParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Defs: defs, + Subject: subject.String(), + State: state, + }) +} + +func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "EditLabelPanel") + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + subjectStr := r.FormValue("subject") + subject, err := syntax.ParseATURI(subjectStr) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + labelDefs, err := db.GetLabelDefinitions( + rp.db, + db.FilterIn("at_uri", f.Repo.Labels), + db.FilterEq("scope", subject.Collection().String()), + ) + if err != nil { + log.Println("failed to fetch labels", err) + return + } + + defs := make(map[string]*db.LabelDefinition) + for _, l := range labelDefs { + defs[l.AtUri().String()] = &l + } + + states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) + if err != nil { + log.Println("failed to build label state", err) + return + } + state := states[subject] + + user := rp.oauth.GetUser(r) + fmt.Println(rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Defs: defs, + Subject: subject.String(), + State: state, + })) +} + func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "AddCollaborator") diff --git a/appview/repo/router.go b/appview/repo/router.go index 8292ebd9..2f22f24e 100644 --- a/appview/repo/router.go +++ b/appview/repo/router.go @@ -64,6 +64,12 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { r.Get("/*", rp.RepoCompare) }) + // label panel in issues/pulls/discussions/tasks + r.Route("/label", func(r chi.Router) { + r.Get("/", rp.LabelPanel) + r.Get("/edit", rp.EditLabelPanel) + }) + // settings routes, needs auth r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(rp.oauth)) @@ -76,8 +82,8 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { r.Get("/", rp.RepoSettings) r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) - r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabel) - r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabel) + r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) + r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef) r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label/subscribe", rp.SubscribeLabel) r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label/subscribe", rp.UnsubscribeLabel) r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) diff --git a/appview/reporesolver/resolver.go b/appview/reporesolver/resolver.go index 35622526..901c96a8 100644 --- a/appview/reporesolver/resolver.go +++ b/appview/reporesolver/resolver.go @@ -184,6 +184,7 @@ func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { OwnerDid: f.OwnerDid(), OwnerHandle: f.OwnerHandle(), Name: f.Name, + Rkey: f.Repo.Rkey, RepoAt: repoAt, Description: f.Description, IsStarred: isStarred, diff --git a/appview/validator/label.go b/appview/validator/label.go index e4732ca1..2d42be69 100644 --- a/appview/validator/label.go +++ b/appview/validator/label.go @@ -36,12 +36,28 @@ func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { } if !label.ValueType.IsConcreteType() { - return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType) + return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type) } - if label.ValueType.IsNull() && label.ValueType.IsEnumType() { + // null type checks: cannot be enums, multiple or explicit format + if label.ValueType.IsNull() && label.ValueType.IsEnum() { return fmt.Errorf("null type cannot be used in conjunction with enum type") } + if label.ValueType.IsNull() && label.Multiple { + return fmt.Errorf("null type labels cannot be multiple") + } + if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() { + return fmt.Errorf("format cannot be used in conjunction with null type") + } + + // format checks: cannot be used with enum, or integers + if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() { + return fmt.Errorf("enum types cannot be used in conjunction with format specification") + } + + if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() { + return fmt.Errorf("format specifications are only permitted on string types") + } // validate scope (nsid format) if label.Scope == "" { @@ -116,6 +132,12 @@ func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.La func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { valueType := labelDef.ValueType + // this is permitted, it "unsets" a label + if labelOp.OperandValue == "" { + labelOp.Operation = db.LabelOperationDel + return nil + } + switch valueType.Type { case db.ConcreteTypeNull: // For null type, value should be empty @@ -125,7 +147,7 @@ func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp * case db.ConcreteTypeString: // For string type, validate enum constraints if present - if valueType.IsEnumType() { + if valueType.IsEnum() { if !slices.Contains(valueType.Enum, labelOp.OperandValue) { return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) } @@ -153,7 +175,7 @@ func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp * return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) } - if valueType.IsEnumType() { + if valueType.IsEnum() { if !slices.Contains(valueType.Enum, labelOp.OperandValue) { return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) } @@ -165,7 +187,7 @@ func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp * } // validate enum constraints if present (though uncommon for booleans) - if valueType.IsEnumType() { + if valueType.IsEnum() { if !slices.Contains(valueType.Enum, labelOp.OperandValue) { return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) }