mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
feat: ui for managing project labels
This commit is contained in:
parent
3780ae4255
commit
490cca05eb
2
main.go
2
main.go
@ -177,7 +177,7 @@ func main() {
|
|||||||
|
|
||||||
// MVC Handlers
|
// MVC Handlers
|
||||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService)
|
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
||||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||||
|
@ -6,6 +6,8 @@ type SettingsViewModel struct {
|
|||||||
User *models.User
|
User *models.User
|
||||||
LanguageMappings []*models.LanguageMapping
|
LanguageMappings []*models.LanguageMapping
|
||||||
Aliases []*SettingsVMCombinedAlias
|
Aliases []*SettingsVMCombinedAlias
|
||||||
|
Labels []*SettingsVMCombinedLabel
|
||||||
|
Projects []string
|
||||||
Success string
|
Success string
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
@ -16,6 +18,11 @@ type SettingsVMCombinedAlias struct {
|
|||||||
Values []string
|
Values []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettingsVMCombinedLabel struct {
|
||||||
|
Key string
|
||||||
|
Values []string
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||||
s.Success = m
|
s.Success = m
|
||||||
return s
|
return s
|
||||||
|
@ -14,10 +14,14 @@ import (
|
|||||||
"github.com/muety/wakapi/services/imports"
|
"github.com/muety/wakapi/services/imports"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const criticalError = "a critical error has occurred, sorry"
|
||||||
|
|
||||||
type SettingsHandler struct {
|
type SettingsHandler struct {
|
||||||
config *conf.Config
|
config *conf.Config
|
||||||
userSrvc services.IUserService
|
userSrvc services.IUserService
|
||||||
@ -26,6 +30,7 @@ type SettingsHandler struct {
|
|||||||
aliasSrvc services.IAliasService
|
aliasSrvc services.IAliasService
|
||||||
aggregationSrvc services.IAggregationService
|
aggregationSrvc services.IAggregationService
|
||||||
languageMappingSrvc services.ILanguageMappingService
|
languageMappingSrvc services.ILanguageMappingService
|
||||||
|
projectLabelSrvc services.IProjectLabelService
|
||||||
keyValueSrvc services.IKeyValueService
|
keyValueSrvc services.IKeyValueService
|
||||||
mailSrvc services.IMailService
|
mailSrvc services.IMailService
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@ -40,6 +45,7 @@ func NewSettingsHandler(
|
|||||||
aliasService services.IAliasService,
|
aliasService services.IAliasService,
|
||||||
aggregationService services.IAggregationService,
|
aggregationService services.IAggregationService,
|
||||||
languageMappingService services.ILanguageMappingService,
|
languageMappingService services.ILanguageMappingService,
|
||||||
|
projectLabelService services.IProjectLabelService,
|
||||||
keyValueService services.IKeyValueService,
|
keyValueService services.IKeyValueService,
|
||||||
mailService services.IMailService,
|
mailService services.IMailService,
|
||||||
) *SettingsHandler {
|
) *SettingsHandler {
|
||||||
@ -49,6 +55,7 @@ func NewSettingsHandler(
|
|||||||
aliasSrvc: aliasService,
|
aliasSrvc: aliasService,
|
||||||
aggregationSrvc: aggregationService,
|
aggregationSrvc: aggregationService,
|
||||||
languageMappingSrvc: languageMappingService,
|
languageMappingSrvc: languageMappingService,
|
||||||
|
projectLabelSrvc: projectLabelService,
|
||||||
userSrvc: userService,
|
userSrvc: userService,
|
||||||
heartbeatSrvc: heartbeatService,
|
heartbeatSrvc: heartbeatService,
|
||||||
keyValueSrvc: keyValueService,
|
keyValueSrvc: keyValueService,
|
||||||
@ -70,7 +77,6 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,6 +134,10 @@ func (h *SettingsHandler) dispatchAction(action string) action {
|
|||||||
return h.actionDeleteAlias
|
return h.actionDeleteAlias
|
||||||
case "add_alias":
|
case "add_alias":
|
||||||
return h.actionAddAlias
|
return h.actionAddAlias
|
||||||
|
case "add_label":
|
||||||
|
return h.actionAddLabel
|
||||||
|
case "delete_label":
|
||||||
|
return h.actionDeleteLabel
|
||||||
case "delete_mapping":
|
case "delete_mapping":
|
||||||
return h.actionDeleteLanguageMapping
|
return h.actionDeleteLanguageMapping
|
||||||
case "add_mapping":
|
case "add_mapping":
|
||||||
@ -314,6 +324,59 @@ func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request)
|
|||||||
return http.StatusOK, "alias added successfully", ""
|
return http.StatusOK, "alias added successfully", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) actionAddLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
|
if h.config.IsDev() {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
user := middlewares.GetPrincipal(r)
|
||||||
|
|
||||||
|
label := &models.ProjectLabel{
|
||||||
|
UserID: user.ID,
|
||||||
|
ProjectKey: r.PostFormValue("key"),
|
||||||
|
Label: r.PostFormValue("value"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !label.IsValid() {
|
||||||
|
return http.StatusBadRequest, "", "invalid input"
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.projectLabelSrvc.Create(label); err != nil {
|
||||||
|
// TODO: distinguish between bad request, conflict and server error
|
||||||
|
return http.StatusBadRequest, "", "invalid input"
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusOK, "label added successfully", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) actionDeleteLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
|
if h.config.IsDev() {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
user := middlewares.GetPrincipal(r)
|
||||||
|
labelKey := r.PostFormValue("key")
|
||||||
|
labelValue := r.PostFormValue("value")
|
||||||
|
|
||||||
|
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "", "could not delete label"
|
||||||
|
}
|
||||||
|
|
||||||
|
if projectLabels, ok := labelMap[labelKey]; ok {
|
||||||
|
for _, l := range projectLabels {
|
||||||
|
if l.Label == labelValue {
|
||||||
|
if err := h.projectLabelSrvc.Delete(l); err != nil {
|
||||||
|
return http.StatusInternalServerError, "", "could not delete label"
|
||||||
|
}
|
||||||
|
return http.StatusOK, "label deleted successfully", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return http.StatusNotFound, "", "label not found"
|
||||||
|
} else {
|
||||||
|
return http.StatusNotFound, "", "project not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
@ -554,8 +617,16 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
|
|||||||
|
|
||||||
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
||||||
user := middlewares.GetPrincipal(r)
|
user := middlewares.GetPrincipal(r)
|
||||||
|
|
||||||
|
// mappings
|
||||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
||||||
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
|
|
||||||
|
// aliases
|
||||||
|
aliases, err := h.aliasSrvc.GetByUser(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
conf.Log().Request(r).Error("error while building alias map - %v", err)
|
||||||
|
return &view.SettingsViewModel{Error: criticalError}
|
||||||
|
}
|
||||||
aliasMap := make(map[string][]*models.Alias)
|
aliasMap := make(map[string][]*models.Alias)
|
||||||
for _, a := range aliases {
|
for _, a := range aliases {
|
||||||
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
|
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
|
||||||
@ -579,10 +650,42 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
|||||||
combinedAliases = append(combinedAliases, ca)
|
combinedAliases = append(combinedAliases, ca)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// labels
|
||||||
|
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
|
||||||
|
return &view.SettingsViewModel{Error: criticalError}
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
|
||||||
|
for _, l := range labelMap {
|
||||||
|
cl := &view.SettingsVMCombinedLabel{
|
||||||
|
Key: l[0].ProjectKey,
|
||||||
|
Values: make([]string, len(l)),
|
||||||
|
}
|
||||||
|
for i, l1 := range l {
|
||||||
|
cl.Values[i] = l1.Label
|
||||||
|
}
|
||||||
|
combinedLabels = append(combinedLabels, cl)
|
||||||
|
}
|
||||||
|
sort.Slice(combinedLabels, func(i, j int) bool {
|
||||||
|
return strings.Compare(combinedLabels[i].Key, combinedLabels[j].Key) < 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// projects
|
||||||
|
projects, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
|
||||||
|
if err != nil {
|
||||||
|
conf.Log().Request(r).Error("error while fetching projects - %v", err)
|
||||||
|
return &view.SettingsViewModel{Error: criticalError}
|
||||||
|
}
|
||||||
|
sort.Strings(projects)
|
||||||
|
|
||||||
return &view.SettingsViewModel{
|
return &view.SettingsViewModel{
|
||||||
User: user,
|
User: user,
|
||||||
LanguageMappings: mappings,
|
LanguageMappings: mappings,
|
||||||
Aliases: combinedAliases,
|
Aliases: combinedAliases,
|
||||||
|
Labels: combinedLabels,
|
||||||
|
Projects: projects,
|
||||||
Success: r.URL.Query().Get("success"),
|
Success: r.URL.Query().Get("success"),
|
||||||
Error: r.URL.Query().Get("error"),
|
Error: r.URL.Query().Get("error"),
|
||||||
}
|
}
|
||||||
|
@ -40,16 +40,21 @@ func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel
|
|||||||
return labels, nil
|
return labels, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *ProjectLabelService) ResolveByUser(userId string) (map[string]string, error) {
|
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
|
||||||
labels := make(map[string]string)
|
labels := make(map[string][]*models.ProjectLabel)
|
||||||
userLabels, err := srv.GetByUser(userId)
|
userLabels, err := srv.GetByUser(userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range userLabels {
|
for _, l := range userLabels {
|
||||||
labels[m.ProjectKey] = m.Label
|
if _, ok := labels[l.ProjectKey]; !ok {
|
||||||
|
labels[l.ProjectKey] = []*models.ProjectLabel{l}
|
||||||
|
} else {
|
||||||
|
labels[l.ProjectKey] = append(labels[l.ProjectKey], l)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return labels, nil
|
return labels, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,7 +57,7 @@ type ILanguageMappingService interface {
|
|||||||
type IProjectLabelService interface {
|
type IProjectLabelService interface {
|
||||||
GetById(uint) (*models.ProjectLabel, error)
|
GetById(uint) (*models.ProjectLabel, error)
|
||||||
GetByUser(string) ([]*models.ProjectLabel, error)
|
GetByUser(string) ([]*models.ProjectLabel, error)
|
||||||
ResolveByUser(string) (map[string]string, error)
|
GetByUserGrouped(string) (map[string][]*models.ProjectLabel, error)
|
||||||
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
|
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
|
||||||
Delete(mapping *models.ProjectLabel) error
|
Delete(mapping *models.ProjectLabel) error
|
||||||
}
|
}
|
||||||
|
@ -203,6 +203,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="mb-8 pb-8 border-b border-gray-700">
|
||||||
|
<summary class="cursor-pointer">
|
||||||
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
|
Project Labels
|
||||||
|
</h2>
|
||||||
|
</summary>
|
||||||
|
<div class="w-full" id="project-labels">
|
||||||
|
<div class="text-gray-300 text-sm mb-4 mt-6">
|
||||||
|
You can assign labels (aka. tags) to projects to group them together, e.g. by <span class="inline-block mb-1 text-gray-500 italic">private</span> and <span
|
||||||
|
class="inline-block mb-1 text-gray-500 italic">work</span>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .Labels }}
|
||||||
|
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Labels</h3>
|
||||||
|
{{ range $i, $label := .Labels }}
|
||||||
|
<div class="flex items-center" action="" method="post">
|
||||||
|
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
|
||||||
|
style="line-height: 1.8">
|
||||||
|
▸ <span class="font-semibold text-white">{{ $label.Key }}:</span>
|
||||||
|
{{ range $j, $value := $label.Values }}
|
||||||
|
<form action="" method="post" class="text-white text-xs bg-gray-900 rounded-full py-1 px-2 font-mono inline-flex justify-between items-center space-x-2">
|
||||||
|
<input type="hidden" name="action" value="delete_label">
|
||||||
|
<input type="hidden" name="key" value="{{ $label.Key }}">
|
||||||
|
<input type="hidden" name="value" value="{{ $value }}">
|
||||||
|
<span>{{- $value -}}</span>
|
||||||
|
<button type="submit" class="bg-gray-800 text-center hover:bg-gray-700 rounded-full w-4 h-4 leading-none" title="Delete label">x</button>
|
||||||
|
</form>
|
||||||
|
{{ if lt $j (add (len $label.Values) -1) }}
|
||||||
|
<span class="-ml-1">{{- ", " | capitalize -}}</span>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="mb-8"></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{ if .Projects }}
|
||||||
|
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Label</h3>
|
||||||
|
<form action="" method="post">
|
||||||
|
<input type="hidden" name="action" value="add_label">
|
||||||
|
<div class="flex items-center mt-2 w-full text-gray-500 text-sm">
|
||||||
|
<span class="mr-2">Project:</span>
|
||||||
|
<select name="key" id="select-project"
|
||||||
|
class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3 cursor-pointer">
|
||||||
|
{{ range $i, $p := .Projects }}
|
||||||
|
<option value="{{ $p }}">{{ $p }}</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
<span class="ml-8 mr-2">Label:</span>
|
||||||
|
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
||||||
|
type="text" id="label-value" style="width: 130px;"
|
||||||
|
name="value" placeholder="work" minlength="1" required>
|
||||||
|
<div class="flex-grow flex justify-end">
|
||||||
|
<button type="submit"
|
||||||
|
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{ else }}
|
||||||
|
<div class="text-gray-300 text-sm mb-4 mt-6">You don't have any projects, yet. Start out by sending a few heartbeats before you can then assign labels.</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details class="mb-8 pb-8 border-b border-gray-700">
|
<details class="mb-8 pb-8 border-b border-gray-700">
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||||
@ -662,16 +729,8 @@
|
|||||||
|
|
||||||
// Time zone stuff
|
// Time zone stuff
|
||||||
|
|
||||||
const userTimeZone = {
|
const userTimeZone = {{ .User.Location }}
|
||||||
{ .
|
const userTzOffset = {{ .User.TZOffset.Hours }}
|
||||||
User.Location
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const userTzOffset = {
|
|
||||||
{ .
|
|
||||||
User.TZOffset.Hours
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const selectTimezone = document.getElementById('select-timezone')
|
const selectTimezone = document.getElementById('select-timezone')
|
||||||
const createTzOption = (tz) => {
|
const createTzOption = (tz) => {
|
||||||
if (!tz) tz = 'Local'
|
if (!tz) tz = 'Local'
|
||||||
|
Loading…
x
Reference in New Issue
Block a user