forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

wip

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

oppi.li 0b84fe95 b3324dd8

verified
+71 -3
appview/db/label.go
···
return vt.Type == ConcreteTypeBool
}
-
func (vt ValueType) IsEnumType() bool {
+
func (vt ValueType) IsEnum() bool {
return len(vt.Enum) > 0
}
···
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 {
···
_ = 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
+
}
+1
appview/db/repos.go
···
CreatedAt: r.Created.Format(time.RFC3339),
Source: source,
Spindle: spindle,
+
Labels: r.Labels,
}
}
+2 -2
appview/issues/issues.go
···
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,
···
Reactions: reactionCountMap,
UserReacted: userReactions,
LabelDefs: defs,
-
})
+
}))
}
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
+69 -49
appview/labels/labels.go
···
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
}
···
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
}
···
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]
···
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
}
···
},
})
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
}
···
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()
+3
appview/pages/funcmap.go
···
"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)
},
+24
appview/pages/pages.go
···
return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.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
+1
appview/pages/repoinfo/repoinfo.go
···
type RepoInfo struct {
Name string
+
Rkey string
OwnerDid string
OwnerHandle string
Description string
+21 -2
appview/pages/templates/labels/fragments/label.html
···
{{ define "labels/fragments/label" }}
{{ $d := .def }}
{{ $v := .val }}
+
{{ $withPrefix := .withPrefix }}
<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">
{{ 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 }}
</span>
{{ end }}
···
{{ $v := .val }}
{{ if $d.ValueType.IsDidFormat }}
-
{{ resolve $v }}
+
{{ resolve $v }}
{{ else }}
{{ $v }}
{{ end }}
-127
appview/pages/templates/repo/fragments/addLabelModal.html
···
-
{{ define "repo/fragments/addLabelModal" }}
-
{{ $root := .root }}
-
{{ $subject := .subject }}
-
{{ $state := .state }}
-
{{ with $root }}
-
<form
-
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
-
hx-on::after-request="this.reset()"
-
hx-indicator="#spinner"
-
hx-swap="none"
-
class="flex flex-col gap-4"
-
>
-
<p class="text-gray-500 dark:text-gray-400">Add, remove or update labels.</p>
-
-
<input class="hidden" name="repo" value="{{ .RepoInfo.RepoAt.String }}">
-
<input class="hidden" name="subject" value="{{ $subject }}">
-
-
<div class="flex flex-col gap-2">
-
{{ $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 }}
-
<span>
-
No labels defined yet. You can define custom labels in <a class="underline" href="/{{ .RepoInfo.FullName }}/settings">settings</a>.
-
</span>
-
{{ end }}
-
</div>
-
-
<div class="flex gap-2 pt-2">
-
<button
-
type="button"
-
popovertarget="add-label-modal"
-
popovertargetaction="hide"
-
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"
-
>
-
{{ i "x" "size-4" }} cancel
-
</button>
-
<button type="submit" class="btn w-1/2 flex items-center">
-
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span>
-
<span id="spinner" class="group">
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
</div>
-
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
-
</form>
-
{{ end }}
-
{{ end }}
-
-
{{ define "labelCheckbox" }}
-
{{ $key := .key }}
-
{{ $val := .val }}
-
{{ $def := .def }}
-
{{ $id := .id }}
-
{{ $isChecked := .isChecked }}
-
<div class="grid grid-cols-[auto_1fr_50%] gap-2 items-center cursor-pointer">
-
<input type="checkbox" id="op-{{$id}}" name="op-{{$id}}" value="add" {{if $isChecked}}checked{{end}} class="peer">
-
<label for="op-{{$id}}" class="flex items-center gap-2 text-base">{{ template "labels/fragments/labelDef" $def }}</label>
-
<div class="w-full hidden peer-checked:block">{{ template "valueTypeInput" (dict "valueType" $def.ValueType "value" $val "key" $key) }}</div>
-
<input type="hidden" name="operand-key" value="{{ $key }}">
-
</div>
-
{{ 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 }}
-
<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">
-
{{ range $valueType.Enum }}
-
<option value="{{.}}" {{ if eq $value . }} selected {{ end }}>{{.}}</option>
-
{{ end }}
-
</select>
-
{{ end }}
-
-
{{ define "boolTypeInput" }}
-
{{ $value := .value }}
-
<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">
-
<option value="true" {{ if $value }} selected {{ end }}>true</option>
-
<option value="false" {{ if not $value }} selected {{ end }}>false</option>
-
</select>
-
{{ end }}
-
-
{{ define "intTypeInput" }}
-
{{ $value := .value }}
-
<input class="p-1 w-full" type="number" name="operand-val" value="{{$value}}" max="100">
-
{{ end }}
-
-
{{ define "stringTypeInput" }}
-
{{ $valueType := .valueType }}
-
{{ $value := .value }}
-
{{ if $valueType.IsDidFormat }}
-
{{ $value = resolve .value }}
-
{{ end }}
-
<input class="p-1 w-full" type="text" name="operand-val" value="{{$value}}">
-
{{ end }}
-
-
{{ define "nullTypeInput" }}
-
<input class="p-1" type="hidden" name="operand-val" value="null">
-
{{ end }}
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
···
+
{{ define "repo/fragments/editLabelPanel" }}
+
<form
+
id="edit-label-panel"
+
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
+
hx-on::after-request="this.reset()"
+
hx-indicator="#spinner"
+
hx-disabled-elt="#save-btn,#cancel-btn"
+
hx-swap="none"
+
class="flex flex-col gap-6"
+
>
+
<input type="hidden" name="repo" value="{{ .RepoInfo.RepoAt }}">
+
<input type="hidden" name="subject" value="{{ .Subject }}">
+
{{ template "editBasicLabels" . }}
+
{{ template "editKvLabels" . }}
+
{{ template "editLabelPanelActions" . }}
+
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
+
</form>
+
{{ 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" }}
+
<div>
+
{{ template "repo/fragments/labelSectionHeaderText" "Labels" }}
+
+
<div class="flex gap-1 items-center flex-wrap">
+
{{ range $k, $d := $defs }}
+
{{ $isChecked := $state.ContainsLabel $k }}
+
{{ if $d.ValueType.IsNull }}
+
{{ $fieldName := $d.AtUri }}
+
<label class="{{$labelStyle}}">
+
<input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}>
+
{{ template "labels/fragments/labelDef" $d }}
+
</label>
+
{{ end }}
+
{{ else }}
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">
+
No labels defined yet. You can define custom labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>.
+
</p>
+
{{ end }}
+
</div>
+
</div>
+
{{ 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 }}
+
<div id="label-{{$d.Id}}" class="flex flex-col gap-1">
+
{{ template "repo/fragments/labelSectionHeaderText" $d.Name }}
+
{{ if (and $d.Multiple $d.ValueType.IsEnum) }}
+
<!-- checkbox -->
+
{{ range $variant := $d.ValueType.Enum }}
+
<label class="{{$labelStyle}}">
+
<input type="checkbox" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
+
{{ $variant }}
+
</label>
+
{{ end }}
+
{{ else if $d.Multiple }}
+
<!-- dynamically growing input fields -->
+
{{ 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 }}
+
<!-- radio buttons -->
+
{{ $isUsed := $state.ContainsLabel $k }}
+
{{ range $variant := $d.ValueType.Enum }}
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
+
{{ $variant }}
+
</label>
+
{{ end }}
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="" {{ if not $isUsed }}checked{{ end }}>
+
None
+
</label>
+
{{ else }}
+
<!-- single input field based on value type -->
+
{{ range $v, $s := $valset }}
+
{{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }}
+
{{ else }}
+
{{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }}
+
{{ end }}
+
{{ end }}
+
</div>
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "multipleInputField" }}
+
<div class="flex gap-1 items-stretch">
+
{{ template "valueTypeInput" . }}
+
{{ template "removeFieldButton" }}
+
</div>
+
{{ end }}
+
+
{{ define "addFieldButton" }}
+
<div style="display:none" id="tpl-{{ .Id }}">
+
{{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }}
+
</div>
+
<button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2">
+
{{ i "plus" "size-4" }} add
+
</button>
+
{{ end }}
+
+
{{ define "removeFieldButton" }}
+
<button type="button" onClick="this.parentElement.remove()" class="btn flex items-center gap-2 text-red-400 dark:text-red-500">
+
{{ i "trash-2" "size-4" }}
+
</button>
+
{{ 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" }}
+
<div class="flex flex-col gap-1">
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
+
None
+
</label>
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
+
None
+
</label>
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
+
None
+
</label>
+
</div>
+
{{ end }}
+
+
{{ define "intTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
{{ $value := .value }}
+
<input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}">
+
{{ end }}
+
+
{{ define "stringTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
{{ $valueType := $def.ValueType }}
+
{{ $value := .value }}
+
{{ if $valueType.IsDidFormat }}
+
{{ $value = trimPrefix (resolve .value) "@" }}
+
{{ end }}
+
<input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}">
+
{{ end }}
+
+
{{ define "nullTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
<input class="p-1" type="hidden" name="{{$fieldName}}" value="null">
+
{{ end }}
+
+
{{ define "editLabelPanelActions" }}
+
<div class="flex gap-2 pt-2">
+
<button
+
id="cancel-btn"
+
type="button"
+
hx-get="/{{ .RepoInfo.FullName }}/label"
+
hx-vals='{"subject": "{{.Subject}}"}'
+
hx-swap="outerHTML"
+
hx-target="#edit-label-panel"
+
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">
+
{{ i "x" "size-4" }} cancel
+
</button>
+
+
<button
+
id="save-btn"
+
type="submit"
+
class="btn w-1/2 flex items-center">
+
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span>
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
{{ end }}
+43
appview/pages/templates/repo/fragments/labelPanel.html
···
+
{{ define "repo/fragments/labelPanel" }}
+
<div id="label-panel" class="flex flex-col gap-6">
+
{{ template "basicLabels" . }}
+
{{ template "kvLabels" . }}
+
</div>
+
{{ end }}
+
+
{{ define "basicLabels" }}
+
<div>
+
{{ template "repo/fragments/labelSectionHeader" (dict "Name" "Labels" "RepoInfo" .RepoInfo "Subject" .Subject) }}
+
+
{{ $hasLabel := false }}
+
<div class="flex gap-1 items-center flex-wrap">
+
{{ 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 }}
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "kvLabels" }}
+
{{ range $k, $d := .Defs }}
+
{{ if (not $d.ValueType.IsNull) }}
+
<div id="label-{{$d.Id}}">
+
{{ template "repo/fragments/labelSectionHeader" (dict "Name" $d.Name "RepoInfo" $.RepoInfo "Subject" $.Subject) }}
+
<div class="flex gap-1 items-center flex-wrap">
+
{{ range $v, $s := $.State.GetValSet $d.AtUri.String }}
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" false) }}
+
{{ else }}
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
+
{{ end }}
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
···
+
{{ define "repo/fragments/labelSectionHeader" }}
+
+
<div class="flex justify-between items-center gap-2">
+
{{ template "repo/fragments/labelSectionHeaderText" .Name }}
+
{{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/label/edit"
+
hx-vals='{"subject": "{{.Subject}}"}'
+
hx-swap="outerHTML"
+
hx-target="#label-panel">
+
{{ i "pencil" "size-3" }}
+
</a>
+
{{ end }}
+
</div>
+
{{ end }}
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
···
+
{{ define "repo/fragments/labelSectionHeaderText" }}
+
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">{{ . }}</span>
+
{{ end }}
+7 -34
appview/pages/templates/repo/issues/issue.html
···
{{ block "repoAfter" . }}{{ end }}
</div>
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
-
{{ template "issueLabels" . }}
+
{{ template "repo/fragments/labelPanel"
+
(dict "RepoInfo" $.RepoInfo
+
"Defs" $.LabelDefs
+
"Subject" $.Issue.AtUri
+
"State" $.Issue.Labels) }}
{{ template "issueParticipants" . }}
</div>
</div>
···
</div>
{{ end }}
-
{{ define "issueLabels" }}
-
<div>
-
<div class="text-sm py-1 flex items-center gap-2 font-bold text-gray-500 dark:text-gray-400 capitalize">
-
Labels
-
<button
-
class="inline-flex text-gray-500 dark:text-gray-400 {{ if not (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }}hidden{{ end }}"
-
popovertarget="add-label-modal"
-
popovertargetaction="toggle">
-
{{ i "plus" "size-4" }}
-
</button>
-
</div>
-
<div class="flex gap-1 items-center flex-wrap">
-
{{ 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 }}
-
<p class="text-gray-500 dark:text-gray-400 ">No labels yet.</p>
-
{{ end }}
-
-
<div
-
id="add-label-modal"
-
popover
-
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">
-
{{ template "repo/fragments/addLabelModal" (dict "root" $ "subject" $.Issue.AtUri.String "state" $.Issue.Labels) }}
-
</div>
-
</div>
-
</div>
-
{{ end }}
-
{{ define "issueParticipants" }}
{{ $all := .Issue.Participants }}
{{ $ps := take $all 5 }}
···
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
</div>
-
<div class="flex items-center -space-x-2 mt-2">
+
<div class="flex items-center -space-x-3 mt-2">
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
{{ range $i, $p := $ps }}
<img
src="{{ tinyAvatar . }}"
alt=""
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-300 dark:border-gray-700 z-{{sub 5 $i}}0"
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
/>
{{ end }}
+1 -1
appview/pages/templates/repo/issues/issues.html
···
{{ 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 }}
+131 -83
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
···
{{ define "repo/settings/fragments/addLabelDefModal" }}
-
<form
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
-
hx-indicator="#spinner"
-
hx-swap="none"
-
hx-on::after-request="if(event.detail.successful) this.reset()"
-
class="flex flex-col gap-4"
-
>
-
<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>
+
<div class="grid grid-cols-2">
+
<input type="radio" name="tab" id="basic-tab" value="basic" class="hidden peer/basic" checked>
+
<input type="radio" name="tab" id="kv-tab" value="kv" class="hidden peer/kv">
+
+
<!-- Labels as direct siblings -->
+
{{ $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" }}
+
<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">
+
Basic Labels
+
</label>
+
<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">
+
Key-value Labels
+
</label>
-
<div class="w-full">
-
<label for="name">Name</label>
-
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
+
<!-- Basic Labels Content - direct sibling -->
+
<div class="mt-4 hidden peer-checked/basic:block col-span-full">
+
{{ template "basicLabelDef" . }}
</div>
-
<!-- Value Type -->
-
<div class="w-full">
-
<label for="valueType">Value Type</label>
-
<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">
-
<option value="null" selected>None</option>
-
<option value="string">String</option>
-
<option value="integer">Integer</option>
-
<option value="boolean">Boolean</option>
-
</select>
-
<details id="constrain-values" class="group hidden">
-
<summary class="list-none cursor-pointer flex items-center gap-2 py-2">
-
<span class="group-open:hidden inline text-gray-500 dark:text-gray-400">{{ i "square-plus" "w-4 h-4" }}</span>
-
<span class="hidden group-open:inline text-gray-500 dark:text-gray-400">{{ i "square-minus" "w-4 h-4" }}</span>
-
<span>Constrain values</span>
-
</summary>
-
<label for="enumValues">Permitted values</label>
-
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
-
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Enter comma-separated list of permitted values.</p>
+
<!-- Key-value Labels Content - direct sibling -->
+
<div class="mt-4 hidden peer-checked/kv:block col-span-full">
+
{{ template "kvLabelDef" . }}
+
</div>
+
+
<div id="add-label-error" class="text-red-500 dark:text-red-400 col-span-full"></div>
+
</div>
+
{{ end }}
+
+
{{ define "basicLabelDef" }}
+
<form
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
class="flex flex-col space-y-4">
+
+
<p class="text-gray-500 dark:text-gray-400">These labels can have a name and a color.</p>
+
+
{{ template "nameInput" . }}
+
{{ template "scopeInput" . }}
+
{{ template "colorInput" . }}
+
+
<div class="flex gap-2 pt-2">
+
{{ template "cancelButton" . }}
+
{{ template "submitButton" . }}
+
</div>
+
</form>
+
{{ end }}
+
+
{{ define "kvLabelDef" }}
+
<form
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
class="flex flex-col space-y-4">
+
+
<p class="text-gray-500 dark:text-gray-400">
+
These labels are more detailed, they can have a key and an associated
+
value. You may define additional constraints on label values.
+
</p>
+
+
{{ template "nameInput" . }}
+
{{ template "valueInput" . }}
+
{{ template "multipleInput" . }}
+
{{ template "scopeInput" . }}
+
{{ template "colorInput" . }}
-
<label for="valueFormat">String format</label>
-
<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">
-
<option value="any" selected>Any</option>
-
<option value="did">DID</option>
-
</select>
-
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Choose a string format.</p>
-
</details>
-
</div>
+
<div class="flex gap-2 pt-2">
+
{{ template "cancelButton" . }}
+
{{ template "submitButton" . }}
+
</div>
+
</form>
+
{{ end }}
-
<!-- Scope -->
+
{{ define "nameInput" }}
<div class="w-full">
-
<label for="scope">Scope</label>
-
<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">
-
<option value="sh.tangled.repo.issue">Issues</option>
-
<option value="sh.tangled.repo.pull">Pull Requests</option>
-
</select>
+
<label for="name">Name</label>
+
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
</div>
+
{{ end }}
-
<!-- Color -->
+
{{ define "colorInput" }}
<div class="w-full">
<label for="color">Color</label>
<div class="grid grid-cols-4 grid-rows-2 place-items-center">
···
{{ end }}
</div>
</div>
+
{{ end }}
-
<!-- Multiple -->
-
<div class="w-full flex flex-wrap gap-2">
-
<input type="checkbox" id="multiple" name="multiple" value="true" />
-
<span>
-
Allow multiple values
-
</span>
+
{{ define "scopeInput" }}
+
<div class="w-full">
+
<label for="scope">Scope</label>
+
<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">
+
<option value="sh.tangled.repo.issue">Issues</option>
+
<option value="sh.tangled.repo.pull">Pull Requests</option>
+
</select>
</div>
+
{{ end }}
-
<div class="flex gap-2 pt-2">
-
<button
-
type="button"
-
popovertarget="add-labeldef-modal"
-
popovertargetaction="hide"
-
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"
-
>
-
{{ i "x" "size-4" }} cancel
-
</button>
-
<button type="submit" class="btn w-1/2 flex items-center">
-
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
-
<span id="spinner" class="group">
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
+
{{ define "valueInput" }}
+
<div class="w-full">
+
<label for="valueType">Value Type</label>
+
<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">
+
<option value="string">String</option>
+
<option value="integer">Integer</option>
+
</select>
+
</div>
+
+
<div class="w-full">
+
<label for="enumValues">Permitted values</label>
+
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
+
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">
+
Enter comma-separated list of permitted values, or leave empty to allow any value.
+
</p>
+
</div>
+
+
<div class="w-full">
+
<label for="valueFormat">String format</label>
+
<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">
+
<option value="any" selected>Any</option>
+
<option value="did">DID</option>
+
</select>
</div>
-
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
-
</form>
+
{{ end }}
-
<script>
-
document.getElementById('value-type').addEventListener('change', function() {
-
const constrainValues = document.getElementById('constrain-values');
-
const selectedValue = this.value;
+
{{ define "multipleInput" }}
+
<div class="w-full flex flex-wrap gap-2">
+
<input type="checkbox" id="multiple" name="multiple" value="true" />
+
<span>Allow multiple values</span>
+
</div>
+
{{ end }}
-
if (selectedValue === 'string') {
-
constrainValues.classList.remove('hidden');
-
} else {
-
constrainValues.classList.add('hidden');
-
constrainValues.removeAttribute('open');
-
document.getElementById('enumValues').value = '';
-
}
-
});
+
{{ define "cancelButton" }}
+
<button
+
type="button"
+
popovertarget="add-labeldef-modal"
+
popovertargetaction="hide"
+
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"
+
>
+
{{ i "x" "size-4" }} cancel
+
</button>
+
{{ end }}
-
function toggleDarkMode() {
-
document.documentElement.classList.toggle('dark');
-
}
-
</script>
+
{{ define "submitButton" }}
+
<button type="submit" class="btn-create w-1/2 flex items-center">
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
{{ end }}
+
+16 -2
appview/pages/templates/repo/settings/fragments/labelListing.html
···
<div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
{{ template "labels/fragments/labelDef" $label }}
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
-
{{ $label.ValueType.Type }} type
-
{{ if $label.ValueType.IsEnumType }}
+
{{ if $label.ValueType.IsNull }}
+
basic
+
{{ else }}
+
{{ $label.ValueType.Type }} type
+
{{ end }}
+
+
{{ if $label.ValueType.IsEnum }}
<span class="before:content-['·'] before:select-none"></span>
{{ join $label.ValueType.Enum ", " }}
{{ end }}
+
{{ if $label.ValueType.IsDidFormat }}
<span class="before:content-['·'] before:select-none"></span>
DID format
{{ end }}
+
+
{{ if $label.Multiple }}
+
<span class="before:content-['·'] before:select-none"></span>
+
multiple
+
{{ end }}
+
+
<span class="before:content-['·'] before:select-none"></span>
+
{{ $label.Scope }}
</div>
</div>
{{ if $root.RepoInfo.Roles.IsOwner }}
+1 -1
appview/pages/templates/repo/settings/general.html
···
<div
id="add-labeldef-modal"
popover
-
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">
+
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" . }}
</div>
</div>
+106 -2
appview/repo/repo.go
···
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)
···
if part = strings.TrimSpace(part); part != "" {
variants = append(variants, part)
}
+
}
+
+
if concreteType == "" {
+
concreteType = "null"
}
format := db.ValueTypeFormatAny
···
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 {
···
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)
···
// everything succeeded
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) {
+8 -2
appview/repo/router.go
···
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))
···
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)
+1
appview/reporesolver/resolver.go
···
OwnerDid: f.OwnerDid(),
OwnerHandle: f.OwnerHandle(),
Name: f.Name,
+
Rkey: f.Repo.Rkey,
RepoAt: repoAt,
Description: f.Description,
IsStarred: isStarred,
+27 -5
appview/validator/label.go
···
}
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)
···
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
···
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)
}
···
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)
}
···
}
// 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)
}