mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
add document tags to web
This commit is contained in:
parent
43e271b144
commit
20f43c3ef6
451
server/core/internal/server/internal/tag/0mock_db_test.go
Normal file
451
server/core/internal/server/internal/tag/0mock_db_test.go
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
// Code generated by moq; DO NOT EDIT.
|
||||
// github.com/matryer/moq
|
||||
|
||||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
tagCore "github.com/oxynote/oxynote/server/core/internal/tag"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// Ensure, that DBMock does implement DB.
|
||||
// If this is not the case, regenerate this file with moq.
|
||||
var _ DB = &DBMock{}
|
||||
|
||||
// DBMock is a mock implementation of DB.
|
||||
//
|
||||
// func TestSomethingThatUsesDB(t *testing.T) {
|
||||
//
|
||||
// // make and configure a mocked DB
|
||||
// mockedDB := &DBMock{
|
||||
// FetchTagTreeFunc: func(ctx context.Context, organizationID string) (tagCore.Summaries, error) {
|
||||
// panic("mock out the FetchTagTree method")
|
||||
// },
|
||||
// UpdateTagTreeFunc: func(ctx context.Context, tree tagCore.Summaries, organizationID string) error {
|
||||
// panic("mock out the UpdateTagTree method")
|
||||
// },
|
||||
// InsertTagFunc: func(ctx context.Context, t tagCore.Tag) error {
|
||||
// panic("mock out the InsertTag method")
|
||||
// },
|
||||
// DeleteTagFunc: func(ctx context.Context, id xid.ID, organizationID string) error {
|
||||
// panic("mock out the DeleteTag method")
|
||||
// },
|
||||
// AssignDocumentTagFunc: func(ctx context.Context, organizationID string, documentID xid.ID, tagID xid.ID) error {
|
||||
// panic("mock out the AssignDocumentTag method")
|
||||
// },
|
||||
// UnassignDocumentTagFunc: func(ctx context.Context, organizationID string, documentID xid.ID, tagID xid.ID) error {
|
||||
// panic("mock out the UnassignDocumentTag method")
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// // use mockedDB in code that requires DB
|
||||
// // and then make assertions.
|
||||
//
|
||||
// }
|
||||
type DBMock struct {
|
||||
// FetchTagTreeFunc mocks the FetchTagTree method.
|
||||
FetchTagTreeFunc func(ctx context.Context, organizationID string, userID string) (tagCore.Summaries, error)
|
||||
|
||||
// UpdateTagTreeFunc mocks the UpdateTagTree method.
|
||||
UpdateTagTreeFunc func(ctx context.Context, tree tagCore.Summaries, organizationID string) error
|
||||
|
||||
// InsertTagFunc mocks the InsertTag method.
|
||||
InsertTagFunc func(ctx context.Context, t tagCore.Tag) error
|
||||
|
||||
// SetTagVisibilityFunc mocks the SetTagVisibility method.
|
||||
SetTagVisibilityFunc func(ctx context.Context, organizationID string, userID string, id xid.ID, inp tagCore.VisibilityInput) error
|
||||
|
||||
// DeleteTagFunc mocks the DeleteTag method.
|
||||
DeleteTagFunc func(ctx context.Context, id xid.ID, organizationID string) error
|
||||
|
||||
// AssignDocumentTagFunc mocks the AssignDocumentTag method.
|
||||
AssignDocumentTagFunc func(ctx context.Context, organizationID string, documentID xid.ID, tagID xid.ID) error
|
||||
|
||||
// UnassignDocumentTagFunc mocks the UnassignDocumentTag method.
|
||||
UnassignDocumentTagFunc func(ctx context.Context, organizationID string, documentID xid.ID, tagID xid.ID) error
|
||||
|
||||
// calls tracks calls to the methods.
|
||||
calls struct {
|
||||
// FetchTagTree holds details about calls to the FetchTagTree method.
|
||||
FetchTagTree []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// OrganizationID is the organizationID argument value.
|
||||
OrganizationID string
|
||||
// UserID is the userID argument value.
|
||||
UserID string
|
||||
}
|
||||
// UpdateTagTree holds details about calls to the UpdateTagTree method.
|
||||
UpdateTagTree []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// Tree is the tree argument value.
|
||||
Tree tagCore.Summaries
|
||||
// OrganizationID is the organizationID argument value.
|
||||
OrganizationID string
|
||||
}
|
||||
// InsertTag holds details about calls to the InsertTag method.
|
||||
InsertTag []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// T is the t argument value.
|
||||
T tagCore.Tag
|
||||
}
|
||||
// SetTagVisibility holds details about calls to the SetTagVisibility method.
|
||||
SetTagVisibility []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// OrganizationID is the organizationID argument value.
|
||||
OrganizationID string
|
||||
// UserID is the userID argument value.
|
||||
UserID string
|
||||
// ID is the id argument value.
|
||||
ID xid.ID
|
||||
// Inp is the inp argument value.
|
||||
Inp tagCore.VisibilityInput
|
||||
}
|
||||
// DeleteTag holds details about calls to the DeleteTag method.
|
||||
DeleteTag []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// ID is the id argument value.
|
||||
ID xid.ID
|
||||
// OrganizationID is the organizationID argument value.
|
||||
OrganizationID string
|
||||
}
|
||||
// AssignDocumentTag holds details about calls to the AssignDocumentTag method.
|
||||
AssignDocumentTag []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// OrganizationID is the organizationID argument value.
|
||||
OrganizationID string
|
||||
// DocumentID is the documentID argument value.
|
||||
DocumentID xid.ID
|
||||
// TagID is the tagID argument value.
|
||||
TagID xid.ID
|
||||
}
|
||||
// UnassignDocumentTag holds details about calls to the UnassignDocumentTag method.
|
||||
UnassignDocumentTag []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// OrganizationID is the organizationID argument value.
|
||||
OrganizationID string
|
||||
// DocumentID is the documentID argument value.
|
||||
DocumentID xid.ID
|
||||
// TagID is the tagID argument value.
|
||||
TagID xid.ID
|
||||
}
|
||||
}
|
||||
lockFetchTagTree sync.RWMutex
|
||||
lockUpdateTagTree sync.RWMutex
|
||||
lockInsertTag sync.RWMutex
|
||||
lockSetTagVisibility sync.RWMutex
|
||||
lockDeleteTag sync.RWMutex
|
||||
lockAssignDocumentTag sync.RWMutex
|
||||
lockUnassignDocumentTag sync.RWMutex
|
||||
}
|
||||
|
||||
// FetchTagTree calls FetchTagTreeFunc.
|
||||
func (mock *DBMock) FetchTagTree(ctx context.Context, organizationID string, userID string) (tagCore.Summaries, error) {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
UserID string
|
||||
}{
|
||||
Ctx: ctx,
|
||||
OrganizationID: organizationID,
|
||||
UserID: userID,
|
||||
}
|
||||
mock.lockFetchTagTree.Lock()
|
||||
mock.calls.FetchTagTree = append(mock.calls.FetchTagTree, callInfo)
|
||||
mock.lockFetchTagTree.Unlock()
|
||||
if mock.FetchTagTreeFunc == nil {
|
||||
var (
|
||||
summariesOut tagCore.Summaries
|
||||
errOut error
|
||||
)
|
||||
return summariesOut, errOut
|
||||
}
|
||||
return mock.FetchTagTreeFunc(ctx, organizationID, userID)
|
||||
}
|
||||
|
||||
// FetchTagTreeCalls gets all the calls that were made to FetchTagTree.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedDB.FetchTagTreeCalls())
|
||||
func (mock *DBMock) FetchTagTreeCalls() []struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
UserID string
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
UserID string
|
||||
}
|
||||
mock.lockFetchTagTree.RLock()
|
||||
calls = mock.calls.FetchTagTree
|
||||
mock.lockFetchTagTree.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// UpdateTagTree calls UpdateTagTreeFunc.
|
||||
func (mock *DBMock) UpdateTagTree(ctx context.Context, tree tagCore.Summaries, organizationID string) error {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
Tree tagCore.Summaries
|
||||
OrganizationID string
|
||||
}{
|
||||
Ctx: ctx,
|
||||
Tree: tree,
|
||||
OrganizationID: organizationID,
|
||||
}
|
||||
mock.lockUpdateTagTree.Lock()
|
||||
mock.calls.UpdateTagTree = append(mock.calls.UpdateTagTree, callInfo)
|
||||
mock.lockUpdateTagTree.Unlock()
|
||||
if mock.UpdateTagTreeFunc == nil {
|
||||
var errOut error
|
||||
return errOut
|
||||
}
|
||||
return mock.UpdateTagTreeFunc(ctx, tree, organizationID)
|
||||
}
|
||||
|
||||
// UpdateTagTreeCalls gets all the calls that were made to UpdateTagTree.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedDB.UpdateTagTreeCalls())
|
||||
func (mock *DBMock) UpdateTagTreeCalls() []struct {
|
||||
Ctx context.Context
|
||||
Tree tagCore.Summaries
|
||||
OrganizationID string
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
Tree tagCore.Summaries
|
||||
OrganizationID string
|
||||
}
|
||||
mock.lockUpdateTagTree.RLock()
|
||||
calls = mock.calls.UpdateTagTree
|
||||
mock.lockUpdateTagTree.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// InsertTag calls InsertTagFunc.
|
||||
func (mock *DBMock) InsertTag(ctx context.Context, t tagCore.Tag) error {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
T tagCore.Tag
|
||||
}{
|
||||
Ctx: ctx,
|
||||
T: t,
|
||||
}
|
||||
mock.lockInsertTag.Lock()
|
||||
mock.calls.InsertTag = append(mock.calls.InsertTag, callInfo)
|
||||
mock.lockInsertTag.Unlock()
|
||||
if mock.InsertTagFunc == nil {
|
||||
var errOut error
|
||||
return errOut
|
||||
}
|
||||
return mock.InsertTagFunc(ctx, t)
|
||||
}
|
||||
|
||||
// InsertTagCalls gets all the calls that were made to InsertTag.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedDB.InsertTagCalls())
|
||||
func (mock *DBMock) InsertTagCalls() []struct {
|
||||
Ctx context.Context
|
||||
T tagCore.Tag
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
T tagCore.Tag
|
||||
}
|
||||
mock.lockInsertTag.RLock()
|
||||
calls = mock.calls.InsertTag
|
||||
mock.lockInsertTag.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// SetTagVisibility calls SetTagVisibilityFunc.
|
||||
func (mock *DBMock) SetTagVisibility(ctx context.Context, organizationID string, userID string, id xid.ID, inp tagCore.VisibilityInput) error {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
UserID string
|
||||
ID xid.ID
|
||||
Inp tagCore.VisibilityInput
|
||||
}{
|
||||
Ctx: ctx,
|
||||
OrganizationID: organizationID,
|
||||
UserID: userID,
|
||||
ID: id,
|
||||
Inp: inp,
|
||||
}
|
||||
mock.lockSetTagVisibility.Lock()
|
||||
mock.calls.SetTagVisibility = append(mock.calls.SetTagVisibility, callInfo)
|
||||
mock.lockSetTagVisibility.Unlock()
|
||||
if mock.SetTagVisibilityFunc == nil {
|
||||
var errOut error
|
||||
return errOut
|
||||
}
|
||||
return mock.SetTagVisibilityFunc(ctx, organizationID, userID, id, inp)
|
||||
}
|
||||
|
||||
// SetTagVisibilityCalls gets all the calls that were made to SetTagVisibility.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedDB.SetTagVisibilityCalls())
|
||||
func (mock *DBMock) SetTagVisibilityCalls() []struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
UserID string
|
||||
ID xid.ID
|
||||
Inp tagCore.VisibilityInput
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
UserID string
|
||||
ID xid.ID
|
||||
Inp tagCore.VisibilityInput
|
||||
}
|
||||
mock.lockSetTagVisibility.RLock()
|
||||
calls = mock.calls.SetTagVisibility
|
||||
mock.lockSetTagVisibility.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// DeleteTag calls DeleteTagFunc.
|
||||
func (mock *DBMock) DeleteTag(ctx context.Context, id xid.ID, organizationID string) error {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
ID xid.ID
|
||||
OrganizationID string
|
||||
}{
|
||||
Ctx: ctx,
|
||||
ID: id,
|
||||
OrganizationID: organizationID,
|
||||
}
|
||||
mock.lockDeleteTag.Lock()
|
||||
mock.calls.DeleteTag = append(mock.calls.DeleteTag, callInfo)
|
||||
mock.lockDeleteTag.Unlock()
|
||||
if mock.DeleteTagFunc == nil {
|
||||
var errOut error
|
||||
return errOut
|
||||
}
|
||||
return mock.DeleteTagFunc(ctx, id, organizationID)
|
||||
}
|
||||
|
||||
// DeleteTagCalls gets all the calls that were made to DeleteTag.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedDB.DeleteTagCalls())
|
||||
func (mock *DBMock) DeleteTagCalls() []struct {
|
||||
Ctx context.Context
|
||||
ID xid.ID
|
||||
OrganizationID string
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
ID xid.ID
|
||||
OrganizationID string
|
||||
}
|
||||
mock.lockDeleteTag.RLock()
|
||||
calls = mock.calls.DeleteTag
|
||||
mock.lockDeleteTag.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// AssignDocumentTag calls AssignDocumentTagFunc.
|
||||
func (mock *DBMock) AssignDocumentTag(ctx context.Context, organizationID string, documentID xid.ID, tagID xid.ID) error {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
DocumentID xid.ID
|
||||
TagID xid.ID
|
||||
}{
|
||||
Ctx: ctx,
|
||||
OrganizationID: organizationID,
|
||||
DocumentID: documentID,
|
||||
TagID: tagID,
|
||||
}
|
||||
mock.lockAssignDocumentTag.Lock()
|
||||
mock.calls.AssignDocumentTag = append(mock.calls.AssignDocumentTag, callInfo)
|
||||
mock.lockAssignDocumentTag.Unlock()
|
||||
if mock.AssignDocumentTagFunc == nil {
|
||||
var errOut error
|
||||
return errOut
|
||||
}
|
||||
return mock.AssignDocumentTagFunc(ctx, organizationID, documentID, tagID)
|
||||
}
|
||||
|
||||
// AssignDocumentTagCalls gets all the calls that were made to AssignDocumentTag.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedDB.AssignDocumentTagCalls())
|
||||
func (mock *DBMock) AssignDocumentTagCalls() []struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
DocumentID xid.ID
|
||||
TagID xid.ID
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
DocumentID xid.ID
|
||||
TagID xid.ID
|
||||
}
|
||||
mock.lockAssignDocumentTag.RLock()
|
||||
calls = mock.calls.AssignDocumentTag
|
||||
mock.lockAssignDocumentTag.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// UnassignDocumentTag calls UnassignDocumentTagFunc.
|
||||
func (mock *DBMock) UnassignDocumentTag(ctx context.Context, organizationID string, documentID xid.ID, tagID xid.ID) error {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
DocumentID xid.ID
|
||||
TagID xid.ID
|
||||
}{
|
||||
Ctx: ctx,
|
||||
OrganizationID: organizationID,
|
||||
DocumentID: documentID,
|
||||
TagID: tagID,
|
||||
}
|
||||
mock.lockUnassignDocumentTag.Lock()
|
||||
mock.calls.UnassignDocumentTag = append(mock.calls.UnassignDocumentTag, callInfo)
|
||||
mock.lockUnassignDocumentTag.Unlock()
|
||||
if mock.UnassignDocumentTagFunc == nil {
|
||||
var errOut error
|
||||
return errOut
|
||||
}
|
||||
return mock.UnassignDocumentTagFunc(ctx, organizationID, documentID, tagID)
|
||||
}
|
||||
|
||||
// UnassignDocumentTagCalls gets all the calls that were made to UnassignDocumentTag.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedDB.UnassignDocumentTagCalls())
|
||||
func (mock *DBMock) UnassignDocumentTagCalls() []struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
DocumentID xid.ID
|
||||
TagID xid.ID
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
OrganizationID string
|
||||
DocumentID xid.ID
|
||||
TagID xid.ID
|
||||
}
|
||||
mock.lockUnassignDocumentTag.RLock()
|
||||
calls = mock.calls.UnassignDocumentTag
|
||||
mock.lockUnassignDocumentTag.RUnlock()
|
||||
return calls
|
||||
}
|
||||
274
server/core/internal/server/internal/tag/http.go
Normal file
274
server/core/internal/server/internal/tag/http.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
// Package tag provides HTTP handlers for managing document tags.
|
||||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
|
||||
tagCore "github.com/oxynote/oxynote/server/core/internal/tag"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// Handler holds dependencies required for tag operations.
|
||||
type Handler struct {
|
||||
log *slog.Logger
|
||||
db DB
|
||||
|
||||
tree struct {
|
||||
changeCallback func(organizationID string)
|
||||
userChangeCallback func(organizationID, userID string)
|
||||
}
|
||||
}
|
||||
|
||||
// NewHandler creates a new tag handling instance.
|
||||
func NewHandler(log *slog.Logger, db DB) *Handler {
|
||||
return &Handler{
|
||||
log: log.With("component", "tag-handler"),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// FetchTagTree handles the retrieval of the organization's tag tree.
|
||||
func (h *Handler) FetchTagTree(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := auth.RequireSession(h.log, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
tree, err := h.db.FetchTagTree(r.Context(), session.ActiveOrganizationID, session.UserID)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, tree, http.StatusOK)
|
||||
}
|
||||
|
||||
// UpdateTagTree handles moving a tag to another position in the tree.
|
||||
func (h *Handler) UpdateTagTree(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := auth.RequireSession(h.log, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var data tagCore.SwapInput
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &data); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
tree, err := h.db.FetchTagTree(r.Context(), session.ActiveOrganizationID, session.UserID)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
swapped, err := tree.Swap(data.ID, data.SortIndex)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.db.UpdateTagTree(r.Context(), swapped, session.ActiveOrganizationID); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.notifyTreeChange(session.ActiveOrganizationID)
|
||||
|
||||
httpserver.Respond(h.log, w, swapped, http.StatusOK)
|
||||
}
|
||||
|
||||
// CreateTag handles the creation of a new tag.
|
||||
func (h *Handler) CreateTag(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := auth.RequireSession(h.log, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var inp tagCore.CreateInput
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &inp); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := inp.Validate(); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
t := tagCore.NewTag(inp, session.ActiveOrganizationID, session.UserID)
|
||||
|
||||
if err := h.db.InsertTag(r.Context(), t); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.notifyTreeChange(session.ActiveOrganizationID)
|
||||
|
||||
httpserver.Respond(h.log, w, t, http.StatusCreated)
|
||||
}
|
||||
|
||||
// SetTagVisibility handles whether the caller keeps a tag out of their own
|
||||
// sidebar. Nobody else's tree changes, so only the caller is told.
|
||||
func (h *Handler) SetTagVisibility(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := auth.RequireSession(h.log, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := httpserver.ExtractNamedID(r, "tagId")
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var inp tagCore.VisibilityInput
|
||||
|
||||
if err = httpserver.DecodeJSON(r, &inp); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.db.SetTagVisibility(
|
||||
r.Context(),
|
||||
session.ActiveOrganizationID,
|
||||
session.UserID,
|
||||
tagID,
|
||||
inp,
|
||||
)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.notifyUserTreeChange(session.ActiveOrganizationID, session.UserID)
|
||||
|
||||
httpserver.Respond(h.log, w, nil, http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteTag handles the removal of a tag and every assignment of it.
|
||||
func (h *Handler) DeleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := auth.RequireSession(h.log, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := httpserver.ExtractNamedID(r, "tagId")
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.db.DeleteTag(r.Context(), tagID, session.ActiveOrganizationID); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.notifyTreeChange(session.ActiveOrganizationID)
|
||||
|
||||
httpserver.Respond(h.log, w, nil, http.StatusNoContent)
|
||||
}
|
||||
|
||||
// AssignDocumentTag handles making the path document carry a tag.
|
||||
func (h *Handler) AssignDocumentTag(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := auth.RequireSession(h.log, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
documentID, err := httpserver.ExtractNamedID(r, "documentId")
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var inp tagCore.AssignInput
|
||||
|
||||
if err = httpserver.DecodeJSON(r, &inp); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.db.AssignDocumentTag(r.Context(), session.ActiveOrganizationID, documentID, inp.TagID)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.notifyTreeChange(session.ActiveOrganizationID)
|
||||
|
||||
httpserver.Respond(h.log, w, nil, http.StatusNoContent)
|
||||
}
|
||||
|
||||
// UnassignDocumentTag handles stopping the path document carrying a tag.
|
||||
func (h *Handler) UnassignDocumentTag(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := auth.RequireSession(h.log, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
documentID, err := httpserver.ExtractNamedID(r, "documentId")
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := httpserver.ExtractNamedID(r, "tagId")
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.db.UnassignDocumentTag(r.Context(), session.ActiveOrganizationID, documentID, tagID)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.notifyTreeChange(session.ActiveOrganizationID)
|
||||
|
||||
httpserver.Respond(h.log, w, nil, http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DB is an interface that handles communication with the tag database.
|
||||
//
|
||||
//go:generate ../../../../scripts/codegen/mock -t internal DB db
|
||||
type DB interface {
|
||||
// FetchTagTree should fetch an organization's tags together with the
|
||||
// documents carrying each of them, in their display order. Each
|
||||
// summary's Hidden should reflect the given user's own preference.
|
||||
FetchTagTree(ctx context.Context, organizationID, userID string) (tagCore.Summaries, error)
|
||||
|
||||
// UpdateTagTree should rewrite the display order of an organization's
|
||||
// tags to the order of the given tree.
|
||||
UpdateTagTree(ctx context.Context, tree tagCore.Summaries, organizationID string) error
|
||||
|
||||
// InsertTag should store a new tag at the end of its organization's tags.
|
||||
InsertTag(ctx context.Context, t tagCore.Tag) error
|
||||
|
||||
// SetTagVisibility should record whether one user keeps a tag out of
|
||||
// their sidebar, leaving every other user's view untouched.
|
||||
SetTagVisibility(
|
||||
ctx context.Context,
|
||||
organizationID, userID string,
|
||||
id xid.ID,
|
||||
inp tagCore.VisibilityInput,
|
||||
) error
|
||||
|
||||
// DeleteTag should remove a tag and every assignment of it.
|
||||
DeleteTag(ctx context.Context, id xid.ID, organizationID string) error
|
||||
|
||||
// AssignDocumentTag should make a document carry a tag. Assigning a tag
|
||||
// the document already carries should change nothing.
|
||||
AssignDocumentTag(ctx context.Context, organizationID string, documentID, tagID xid.ID) error
|
||||
|
||||
// UnassignDocumentTag should stop a document carrying a tag. Removing a
|
||||
// tag the document does not carry should change nothing.
|
||||
UnassignDocumentTag(ctx context.Context, organizationID string, documentID, tagID xid.ID) error
|
||||
}
|
||||
749
server/core/internal/server/internal/tag/http_test.go
Normal file
749
server/core/internal/server/internal/tag/http_test.go
Normal file
|
|
@ -0,0 +1,749 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
documentCore "github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
|
||||
tagCore "github.com/oxynote/oxynote/server/core/internal/tag"
|
||||
wsMock "github.com/oxynote/wetsocks/wsserver/_mock"
|
||||
"github.com/rs/xid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
var (
|
||||
// _tagID is the tag the path-addressed tests operate on.
|
||||
_tagID = xid.New()
|
||||
|
||||
// _documentID is the document the assignment tests operate on.
|
||||
_documentID = xid.New()
|
||||
)
|
||||
|
||||
// addSession stores a test session on the request context.
|
||||
func addSession(ctx context.Context) context.Context {
|
||||
return auth.AddSessionToContext(ctx, auth.Session{
|
||||
UserID: "u1",
|
||||
ActiveOrganizationID: "org1",
|
||||
})
|
||||
}
|
||||
|
||||
// otherUserCtx returns a subscriber context for a different member of the
|
||||
// same organization.
|
||||
func otherUserCtx() context.Context {
|
||||
return auth.AddSessionToContext(context.Background(), auth.Session{
|
||||
UserID: "u2",
|
||||
ActiveOrganizationID: "org1",
|
||||
})
|
||||
}
|
||||
|
||||
// withParams puts chi path parameters on the request, which is where
|
||||
// httpserver.ExtractNamedID reads them from.
|
||||
func withParams(r *http.Request, params map[string]string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
|
||||
for k, v := range params {
|
||||
rctx.URLParams.Add(k, v)
|
||||
}
|
||||
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// summary builds a tag summary carrying one document.
|
||||
func summary(id xid.ID, name string) tagCore.Summary {
|
||||
return tagCore.Summary{
|
||||
ID: id,
|
||||
TagName: name,
|
||||
Color: "#22c55e",
|
||||
Documents: documentCore.Summaries{
|
||||
{ID: _documentID, DocumentName: "Runbook", Icon: "lucide:file"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Test_NewHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := &DBMock{}
|
||||
|
||||
hdl := NewHandler(slog.New(slog.DiscardHandler), db)
|
||||
require.NotNil(t, hdl)
|
||||
assert.NotNil(t, hdl.log)
|
||||
assert.Same(t, db, hdl.db)
|
||||
}
|
||||
|
||||
func Test_Handler_FetchTagTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
NoSession bool
|
||||
Code int
|
||||
Body string
|
||||
Fetches int
|
||||
}{
|
||||
"No session in context": {
|
||||
DB: &DBMock{},
|
||||
NoSession: true,
|
||||
Code: http.StatusUnauthorized,
|
||||
Body: `{"code":"account.not_authenticated","message":"not authenticated"}`,
|
||||
},
|
||||
"Tree fetch error": {
|
||||
DB: &DBMock{
|
||||
FetchTagTreeFunc: func(context.Context, string, string) (tagCore.Summaries, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
},
|
||||
Code: http.StatusInternalServerError,
|
||||
Body: `{"code":"general","message":"internal server error"}`,
|
||||
Fetches: 1,
|
||||
},
|
||||
"Successful fetch": {
|
||||
DB: &DBMock{
|
||||
FetchTagTreeFunc: func(context.Context, string, string) (tagCore.Summaries, error) {
|
||||
return tagCore.Summaries{summary(_tagID, "Production")}, nil
|
||||
},
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
Fetches: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hdl := Handler{log: slog.New(slog.DiscardHandler), db: c.DB}
|
||||
req := httptest.NewRequest(http.MethodGet, "http://test.com/", http.NoBody)
|
||||
|
||||
if !c.NoSession {
|
||||
req = req.WithContext(addSession(req.Context()))
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
hdl.FetchTagTree(rec, req)
|
||||
|
||||
assert.Equal(t, c.Code, rec.Code)
|
||||
assert.Len(t, c.DB.FetchTagTreeCalls(), c.Fetches)
|
||||
|
||||
if c.Fetches > 0 {
|
||||
assert.Equal(t, "org1", c.DB.FetchTagTreeCalls()[0].OrganizationID)
|
||||
assert.Equal(t, "u1", c.DB.FetchTagTreeCalls()[0].UserID)
|
||||
}
|
||||
|
||||
if c.Body != "" {
|
||||
assert.JSONEq(t, c.Body, rec.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
if c.Code == http.StatusOK {
|
||||
assert.Contains(t, rec.Body.String(), `"tagName":"Production"`)
|
||||
assert.Contains(t, rec.Body.String(), `"documentName":"Runbook"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Handler_UpdateTagTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tree := func() tagCore.Summaries {
|
||||
return tagCore.Summaries{summary(_tagID, "Production"), summary(xid.New(), "Staging")}
|
||||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
NoSession bool
|
||||
Payload string
|
||||
Code int
|
||||
Body string
|
||||
Updates int
|
||||
Notifies int
|
||||
}{
|
||||
"No session in context": {
|
||||
DB: &DBMock{},
|
||||
NoSession: true,
|
||||
Payload: `{}`,
|
||||
Code: http.StatusUnauthorized,
|
||||
Body: `{"code":"account.not_authenticated","message":"not authenticated"}`,
|
||||
},
|
||||
"Malformed payload": {
|
||||
DB: &DBMock{},
|
||||
Payload: `{`,
|
||||
Code: http.StatusBadRequest,
|
||||
Body: `{"code":"request.invalid_json","message":"invalid JSON body"}`,
|
||||
},
|
||||
"Tree fetch error": {
|
||||
DB: &DBMock{
|
||||
FetchTagTreeFunc: func(context.Context, string, string) (tagCore.Summaries, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
},
|
||||
Payload: `{"id":"` + _tagID.String() + `","sortIndex":0}`,
|
||||
Code: http.StatusInternalServerError,
|
||||
Body: `{"code":"general","message":"internal server error"}`,
|
||||
},
|
||||
"Sort index out of range": {
|
||||
DB: &DBMock{
|
||||
FetchTagTreeFunc: func(context.Context, string, string) (tagCore.Summaries, error) {
|
||||
return tree(), nil
|
||||
},
|
||||
},
|
||||
Payload: `{"id":"` + _tagID.String() + `","sortIndex":9}`,
|
||||
Code: http.StatusBadRequest,
|
||||
Body: `{"code":"tag_summary.invalid_sort_index","message":"sort index is out of range"}`,
|
||||
},
|
||||
"Unknown tag": {
|
||||
DB: &DBMock{
|
||||
FetchTagTreeFunc: func(context.Context, string, string) (tagCore.Summaries, error) {
|
||||
return tree(), nil
|
||||
},
|
||||
},
|
||||
Payload: `{"id":"` + xid.New().String() + `","sortIndex":0}`,
|
||||
Code: http.StatusNotFound,
|
||||
Body: `{"code":"general","message":"not found"}`,
|
||||
},
|
||||
"Tree update error": {
|
||||
DB: &DBMock{
|
||||
FetchTagTreeFunc: func(context.Context, string, string) (tagCore.Summaries, error) {
|
||||
return tree(), nil
|
||||
},
|
||||
UpdateTagTreeFunc: func(context.Context, tagCore.Summaries, string) error {
|
||||
return errors.New("boom")
|
||||
},
|
||||
},
|
||||
Payload: `{"id":"` + _tagID.String() + `","sortIndex":1}`,
|
||||
Code: http.StatusInternalServerError,
|
||||
Body: `{"code":"general","message":"internal server error"}`,
|
||||
Updates: 1,
|
||||
},
|
||||
"Successful swap": {
|
||||
DB: &DBMock{
|
||||
FetchTagTreeFunc: func(context.Context, string, string) (tagCore.Summaries, error) {
|
||||
return tree(), nil
|
||||
},
|
||||
},
|
||||
Payload: `{"id":"` + _tagID.String() + `","sortIndex":1}`,
|
||||
Code: http.StatusOK,
|
||||
Updates: 1,
|
||||
Notifies: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tpc := &wsMock.Topic{}
|
||||
hdl := Handler{log: slog.New(slog.DiscardHandler), db: c.DB}
|
||||
hdl.BindTreeChange(tpc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "http://test.com/", strings.NewReader(c.Payload))
|
||||
|
||||
if !c.NoSession {
|
||||
req = req.WithContext(addSession(req.Context()))
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
hdl.UpdateTagTree(rec, req)
|
||||
|
||||
assert.Equal(t, c.Code, rec.Code)
|
||||
assert.Len(t, c.DB.UpdateTagTreeCalls(), c.Updates)
|
||||
assert.Len(t, tpc.PublishManyCalls(), c.Notifies)
|
||||
|
||||
if c.Body != "" {
|
||||
assert.JSONEq(t, c.Body, rec.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
if c.Code == http.StatusOK {
|
||||
persisted := c.DB.UpdateTagTreeCalls()[0].Tree
|
||||
require.Len(t, persisted, 2)
|
||||
assert.Equal(t, _tagID, persisted[1].ID)
|
||||
assert.Contains(t, rec.Body.String(), `"tagName":"Staging"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Handler_CreateTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
NoSession bool
|
||||
Payload string
|
||||
Code int
|
||||
Body string
|
||||
Inserts int
|
||||
Notifies int
|
||||
}{
|
||||
"No session in context": {
|
||||
DB: &DBMock{},
|
||||
NoSession: true,
|
||||
Payload: `{}`,
|
||||
Code: http.StatusUnauthorized,
|
||||
Body: `{"code":"account.not_authenticated","message":"not authenticated"}`,
|
||||
},
|
||||
"Malformed payload": {
|
||||
DB: &DBMock{},
|
||||
Payload: `{`,
|
||||
Code: http.StatusBadRequest,
|
||||
Body: `{"code":"request.invalid_json","message":"invalid JSON body"}`,
|
||||
},
|
||||
"Missing name": {
|
||||
DB: &DBMock{},
|
||||
Payload: `{"tagName":"","color":"#22c55e"}`,
|
||||
Code: http.StatusBadRequest,
|
||||
Body: `{"code":"tag.invalid_name","message":"tag name cannot be empty"}`,
|
||||
},
|
||||
"Malformed colour": {
|
||||
DB: &DBMock{},
|
||||
Payload: `{"tagName":"Production","color":"green"}`,
|
||||
Code: http.StatusBadRequest,
|
||||
Body: `{"code":"tag.invalid_color","message":"tag colour must be a hex triplet"}`,
|
||||
},
|
||||
"Tag insert error": {
|
||||
DB: &DBMock{
|
||||
InsertTagFunc: func(context.Context, tagCore.Tag) error {
|
||||
return errors.New("boom")
|
||||
},
|
||||
},
|
||||
Payload: `{"tagName":"Production","color":"#22c55e"}`,
|
||||
Code: http.StatusInternalServerError,
|
||||
Body: `{"code":"general","message":"internal server error"}`,
|
||||
Inserts: 1,
|
||||
},
|
||||
"Successful creation": {
|
||||
DB: &DBMock{},
|
||||
Payload: `{"tagName":"Production","color":"#22c55e"}`,
|
||||
Code: http.StatusCreated,
|
||||
Inserts: 1,
|
||||
Notifies: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tpc := &wsMock.Topic{}
|
||||
hdl := Handler{log: slog.New(slog.DiscardHandler), db: c.DB}
|
||||
hdl.BindTreeChange(tpc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "http://test.com/", strings.NewReader(c.Payload))
|
||||
|
||||
if !c.NoSession {
|
||||
req = req.WithContext(addSession(req.Context()))
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
hdl.CreateTag(rec, req)
|
||||
|
||||
assert.Equal(t, c.Code, rec.Code)
|
||||
assert.Len(t, c.DB.InsertTagCalls(), c.Inserts)
|
||||
assert.Len(t, tpc.PublishManyCalls(), c.Notifies)
|
||||
|
||||
if c.Body != "" {
|
||||
assert.JSONEq(t, c.Body, rec.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
if c.Code == http.StatusCreated {
|
||||
stored := c.DB.InsertTagCalls()[0].T
|
||||
assert.Equal(t, "Production", stored.TagName)
|
||||
assert.Equal(t, "org1", stored.OrganizationID)
|
||||
assert.Equal(t, "u1", stored.CreatedBy.String)
|
||||
assert.False(t, stored.ID.IsNil())
|
||||
assert.Contains(t, rec.Body.String(), `"tagName":"Production"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Handler_SetTagVisibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
NoSession bool
|
||||
TagID string
|
||||
Payload string
|
||||
Code int
|
||||
Body string
|
||||
Updates int
|
||||
Notifies int
|
||||
}{
|
||||
"No session in context": {
|
||||
DB: &DBMock{},
|
||||
NoSession: true,
|
||||
TagID: _tagID.String(),
|
||||
Payload: `{}`,
|
||||
Code: http.StatusUnauthorized,
|
||||
Body: `{"code":"account.not_authenticated","message":"not authenticated"}`,
|
||||
},
|
||||
"Malformed tag id": {
|
||||
DB: &DBMock{},
|
||||
TagID: "not-an-xid",
|
||||
Payload: `{}`,
|
||||
Code: http.StatusNotFound,
|
||||
Body: `{"code":"general","message":"not found"}`,
|
||||
},
|
||||
"Malformed payload": {
|
||||
DB: &DBMock{},
|
||||
TagID: _tagID.String(),
|
||||
Payload: `{`,
|
||||
Code: http.StatusBadRequest,
|
||||
Body: `{"code":"request.invalid_json","message":"invalid JSON body"}`,
|
||||
},
|
||||
"Visibility write error": {
|
||||
DB: &DBMock{
|
||||
SetTagVisibilityFunc: func(context.Context, string, string, xid.ID, tagCore.VisibilityInput) error {
|
||||
return errors.New("boom")
|
||||
},
|
||||
},
|
||||
TagID: _tagID.String(),
|
||||
Payload: `{"hidden":true}`,
|
||||
Code: http.StatusInternalServerError,
|
||||
Body: `{"code":"general","message":"internal server error"}`,
|
||||
Updates: 1,
|
||||
},
|
||||
"Successful hide": {
|
||||
DB: &DBMock{},
|
||||
TagID: _tagID.String(),
|
||||
Payload: `{"hidden":true}`,
|
||||
Code: http.StatusNoContent,
|
||||
Updates: 1,
|
||||
Notifies: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tpc := &wsMock.Topic{}
|
||||
hdl := Handler{log: slog.New(slog.DiscardHandler), db: c.DB}
|
||||
hdl.BindTreeChange(tpc)
|
||||
|
||||
req := withParams(
|
||||
httptest.NewRequest(http.MethodPut, "http://test.com/", strings.NewReader(c.Payload)),
|
||||
map[string]string{"tagId": c.TagID},
|
||||
)
|
||||
|
||||
if !c.NoSession {
|
||||
req = req.WithContext(addSession(req.Context()))
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
hdl.SetTagVisibility(rec, req)
|
||||
|
||||
assert.Equal(t, c.Code, rec.Code)
|
||||
assert.Len(t, c.DB.SetTagVisibilityCalls(), c.Updates)
|
||||
assert.Len(t, tpc.PublishManyCalls(), c.Notifies)
|
||||
|
||||
if c.Body != "" {
|
||||
assert.JSONEq(t, c.Body, rec.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
assert.Zero(t, rec.Body.Len())
|
||||
|
||||
call := c.DB.SetTagVisibilityCalls()[0]
|
||||
assert.Equal(t, _tagID, call.ID)
|
||||
assert.Equal(t, "org1", call.OrganizationID)
|
||||
assert.Equal(t, "u1", call.UserID)
|
||||
assert.True(t, call.Inp.Hidden)
|
||||
|
||||
// the change is one user's own, so only their sessions hear it
|
||||
pubs := tpc.PublishManyCalls()
|
||||
assert.True(t, pubs[0].Filter(sessionCtx(), "topic"))
|
||||
assert.False(t, pubs[0].Filter(otherUserCtx(), "topic"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Handler_DeleteTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
NoSession bool
|
||||
TagID string
|
||||
Code int
|
||||
Body string
|
||||
Deletes int
|
||||
Notifies int
|
||||
}{
|
||||
"No session in context": {
|
||||
DB: &DBMock{},
|
||||
NoSession: true,
|
||||
TagID: _tagID.String(),
|
||||
Code: http.StatusUnauthorized,
|
||||
Body: `{"code":"account.not_authenticated","message":"not authenticated"}`,
|
||||
},
|
||||
"Malformed tag id": {
|
||||
DB: &DBMock{},
|
||||
TagID: "not-an-xid",
|
||||
Code: http.StatusNotFound,
|
||||
Body: `{"code":"general","message":"not found"}`,
|
||||
},
|
||||
"Tag delete error": {
|
||||
DB: &DBMock{
|
||||
DeleteTagFunc: func(context.Context, xid.ID, string) error {
|
||||
return errors.New("boom")
|
||||
},
|
||||
},
|
||||
TagID: _tagID.String(),
|
||||
Code: http.StatusInternalServerError,
|
||||
Body: `{"code":"general","message":"internal server error"}`,
|
||||
Deletes: 1,
|
||||
},
|
||||
"Successful deletion": {
|
||||
DB: &DBMock{},
|
||||
TagID: _tagID.String(),
|
||||
Code: http.StatusNoContent,
|
||||
Deletes: 1,
|
||||
Notifies: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tpc := &wsMock.Topic{}
|
||||
hdl := Handler{log: slog.New(slog.DiscardHandler), db: c.DB}
|
||||
hdl.BindTreeChange(tpc)
|
||||
|
||||
req := withParams(
|
||||
httptest.NewRequest(http.MethodDelete, "http://test.com/", http.NoBody),
|
||||
map[string]string{"tagId": c.TagID},
|
||||
)
|
||||
|
||||
if !c.NoSession {
|
||||
req = req.WithContext(addSession(req.Context()))
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
hdl.DeleteTag(rec, req)
|
||||
|
||||
assert.Equal(t, c.Code, rec.Code)
|
||||
assert.Len(t, c.DB.DeleteTagCalls(), c.Deletes)
|
||||
assert.Len(t, tpc.PublishManyCalls(), c.Notifies)
|
||||
|
||||
if c.Body != "" {
|
||||
assert.JSONEq(t, c.Body, rec.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
assert.Zero(t, rec.Body.Len())
|
||||
assert.Equal(t, _tagID, c.DB.DeleteTagCalls()[0].ID)
|
||||
assert.Equal(t, "org1", c.DB.DeleteTagCalls()[0].OrganizationID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Handler_AssignDocumentTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
NoSession bool
|
||||
DocumentID string
|
||||
Payload string
|
||||
Code int
|
||||
Body string
|
||||
Assigns int
|
||||
Notifies int
|
||||
}{
|
||||
"No session in context": {
|
||||
DB: &DBMock{},
|
||||
NoSession: true,
|
||||
DocumentID: _documentID.String(),
|
||||
Payload: `{}`,
|
||||
Code: http.StatusUnauthorized,
|
||||
Body: `{"code":"account.not_authenticated","message":"not authenticated"}`,
|
||||
},
|
||||
"Malformed document id": {
|
||||
DB: &DBMock{},
|
||||
DocumentID: "not-an-xid",
|
||||
Payload: `{}`,
|
||||
Code: http.StatusNotFound,
|
||||
Body: `{"code":"general","message":"not found"}`,
|
||||
},
|
||||
"Malformed payload": {
|
||||
DB: &DBMock{},
|
||||
DocumentID: _documentID.String(),
|
||||
Payload: `{`,
|
||||
Code: http.StatusBadRequest,
|
||||
Body: `{"code":"request.invalid_json","message":"invalid JSON body"}`,
|
||||
},
|
||||
"Assignment error": {
|
||||
DB: &DBMock{
|
||||
AssignDocumentTagFunc: func(context.Context, string, xid.ID, xid.ID) error {
|
||||
return errors.New("boom")
|
||||
},
|
||||
},
|
||||
DocumentID: _documentID.String(),
|
||||
Payload: `{"tagId":"` + _tagID.String() + `"}`,
|
||||
Code: http.StatusInternalServerError,
|
||||
Body: `{"code":"general","message":"internal server error"}`,
|
||||
Assigns: 1,
|
||||
},
|
||||
"Successful assignment": {
|
||||
DB: &DBMock{},
|
||||
DocumentID: _documentID.String(),
|
||||
Payload: `{"tagId":"` + _tagID.String() + `"}`,
|
||||
Code: http.StatusNoContent,
|
||||
Assigns: 1,
|
||||
Notifies: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tpc := &wsMock.Topic{}
|
||||
hdl := Handler{log: slog.New(slog.DiscardHandler), db: c.DB}
|
||||
hdl.BindTreeChange(tpc)
|
||||
|
||||
req := withParams(
|
||||
httptest.NewRequest(http.MethodPost, "http://test.com/", strings.NewReader(c.Payload)),
|
||||
map[string]string{"documentId": c.DocumentID},
|
||||
)
|
||||
|
||||
if !c.NoSession {
|
||||
req = req.WithContext(addSession(req.Context()))
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
hdl.AssignDocumentTag(rec, req)
|
||||
|
||||
assert.Equal(t, c.Code, rec.Code)
|
||||
assert.Len(t, c.DB.AssignDocumentTagCalls(), c.Assigns)
|
||||
assert.Len(t, tpc.PublishManyCalls(), c.Notifies)
|
||||
|
||||
if c.Body != "" {
|
||||
assert.JSONEq(t, c.Body, rec.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
assert.Zero(t, rec.Body.Len())
|
||||
|
||||
call := c.DB.AssignDocumentTagCalls()[0]
|
||||
assert.Equal(t, "org1", call.OrganizationID)
|
||||
assert.Equal(t, _documentID, call.DocumentID)
|
||||
assert.Equal(t, _tagID, call.TagID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Handler_UnassignDocumentTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
NoSession bool
|
||||
DocumentID string
|
||||
TagID string
|
||||
Code int
|
||||
Body string
|
||||
Unassigns int
|
||||
Notifies int
|
||||
}{
|
||||
"No session in context": {
|
||||
DB: &DBMock{},
|
||||
NoSession: true,
|
||||
DocumentID: _documentID.String(),
|
||||
TagID: _tagID.String(),
|
||||
Code: http.StatusUnauthorized,
|
||||
Body: `{"code":"account.not_authenticated","message":"not authenticated"}`,
|
||||
},
|
||||
"Malformed document id": {
|
||||
DB: &DBMock{},
|
||||
DocumentID: "not-an-xid",
|
||||
TagID: _tagID.String(),
|
||||
Code: http.StatusNotFound,
|
||||
Body: `{"code":"general","message":"not found"}`,
|
||||
},
|
||||
"Malformed tag id": {
|
||||
DB: &DBMock{},
|
||||
DocumentID: _documentID.String(),
|
||||
TagID: "not-an-xid",
|
||||
Code: http.StatusNotFound,
|
||||
Body: `{"code":"general","message":"not found"}`,
|
||||
},
|
||||
"Unassignment error": {
|
||||
DB: &DBMock{
|
||||
UnassignDocumentTagFunc: func(context.Context, string, xid.ID, xid.ID) error {
|
||||
return errors.New("boom")
|
||||
},
|
||||
},
|
||||
DocumentID: _documentID.String(),
|
||||
TagID: _tagID.String(),
|
||||
Code: http.StatusInternalServerError,
|
||||
Body: `{"code":"general","message":"internal server error"}`,
|
||||
Unassigns: 1,
|
||||
},
|
||||
"Successful unassignment": {
|
||||
DB: &DBMock{},
|
||||
DocumentID: _documentID.String(),
|
||||
TagID: _tagID.String(),
|
||||
Code: http.StatusNoContent,
|
||||
Unassigns: 1,
|
||||
Notifies: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tpc := &wsMock.Topic{}
|
||||
hdl := Handler{log: slog.New(slog.DiscardHandler), db: c.DB}
|
||||
hdl.BindTreeChange(tpc)
|
||||
|
||||
req := withParams(
|
||||
httptest.NewRequest(http.MethodDelete, "http://test.com/", http.NoBody),
|
||||
map[string]string{"documentId": c.DocumentID, "tagId": c.TagID},
|
||||
)
|
||||
|
||||
if !c.NoSession {
|
||||
req = req.WithContext(addSession(req.Context()))
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
hdl.UnassignDocumentTag(rec, req)
|
||||
|
||||
assert.Equal(t, c.Code, rec.Code)
|
||||
assert.Len(t, c.DB.UnassignDocumentTagCalls(), c.Unassigns)
|
||||
assert.Len(t, tpc.PublishManyCalls(), c.Notifies)
|
||||
|
||||
if c.Body != "" {
|
||||
assert.JSONEq(t, c.Body, rec.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
assert.Zero(t, rec.Body.Len())
|
||||
|
||||
call := c.DB.UnassignDocumentTagCalls()[0]
|
||||
assert.Equal(t, "org1", call.OrganizationID)
|
||||
assert.Equal(t, _documentID, call.DocumentID)
|
||||
assert.Equal(t, _tagID, call.TagID)
|
||||
})
|
||||
}
|
||||
}
|
||||
55
server/core/internal/server/internal/tag/ws.go
Normal file
55
server/core/internal/server/internal/tag/ws.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
|
||||
"github.com/oxynote/wetsocks/wsserver"
|
||||
)
|
||||
|
||||
// _publishTimeout bounds each WebSocket publish triggered by a domain
|
||||
// callback.
|
||||
const _publishTimeout = 5 * time.Second
|
||||
|
||||
// TreeChangeMessage represents a change in the tree of tags. The tree is
|
||||
// small and always fetched whole, so the message carries no payload: it
|
||||
// tells a subscriber to refetch.
|
||||
type TreeChangeMessage struct{}
|
||||
|
||||
// BindTreeChange binds a tag tree change event to the given topic.
|
||||
func (h *Handler) BindTreeChange(tpc wsserver.Topic) {
|
||||
h.tree.changeCallback = func(organizationID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout)
|
||||
defer cancel()
|
||||
|
||||
tpc.PublishMany(ctx, TreeChangeMessage{}, auth.FilterOrganization(organizationID))
|
||||
}
|
||||
h.tree.userChangeCallback = func(organizationID, userID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout)
|
||||
defer cancel()
|
||||
|
||||
tpc.PublishMany(ctx, TreeChangeMessage{}, auth.FilterUser(organizationID, userID))
|
||||
}
|
||||
}
|
||||
|
||||
// notifyTreeChange announces a change of the tag tree to every subscriber
|
||||
// in the organization. Safe to call before BindTreeChange has been invoked
|
||||
// — no subscribers means it is a no-op.
|
||||
func (h *Handler) notifyTreeChange(organizationID string) {
|
||||
if h.tree.changeCallback == nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.tree.changeCallback(organizationID)
|
||||
}
|
||||
|
||||
// notifyUserTreeChange announces a change only one user's tree carries, so
|
||||
// their other sessions refetch and nobody else is disturbed.
|
||||
func (h *Handler) notifyUserTreeChange(organizationID, userID string) {
|
||||
if h.tree.userChangeCallback == nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.tree.userChangeCallback(organizationID, userID)
|
||||
}
|
||||
74
server/core/internal/server/internal/tag/ws_test.go
Normal file
74
server/core/internal/server/internal/tag/ws_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
|
||||
wsMock "github.com/oxynote/wetsocks/wsserver/_mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// sessionCtx returns a subscriber context carrying a test session.
|
||||
func sessionCtx() context.Context {
|
||||
return auth.AddSessionToContext(context.Background(), auth.Session{
|
||||
UserID: "u1",
|
||||
ActiveOrganizationID: "org1",
|
||||
})
|
||||
}
|
||||
|
||||
func Test_Handler_BindTreeChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hdl := &Handler{}
|
||||
tpc := &wsMock.Topic{}
|
||||
|
||||
hdl.BindTreeChange(tpc)
|
||||
require.NotNil(t, hdl.tree.changeCallback)
|
||||
require.NotNil(t, hdl.tree.userChangeCallback)
|
||||
}
|
||||
|
||||
func Test_Handler_notifyTreeChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hdl := &Handler{}
|
||||
|
||||
// notifying before binding must be a safe no-op.
|
||||
hdl.notifyTreeChange("org1")
|
||||
|
||||
tpc := &wsMock.Topic{}
|
||||
|
||||
hdl.BindTreeChange(tpc)
|
||||
hdl.notifyTreeChange("org1")
|
||||
|
||||
pubs := tpc.PublishManyCalls()
|
||||
require.Len(t, pubs, 1)
|
||||
assert.Equal(t, TreeChangeMessage{}, pubs[0].Payload)
|
||||
|
||||
assert.True(t, pubs[0].Filter(sessionCtx(), "topic"))
|
||||
assert.False(t, pubs[0].Filter(context.Background(), "topic"))
|
||||
}
|
||||
|
||||
func Test_Handler_notifyUserTreeChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hdl := &Handler{}
|
||||
|
||||
// notifying before binding must be a safe no-op.
|
||||
hdl.notifyUserTreeChange("org1", "u1")
|
||||
|
||||
tpc := &wsMock.Topic{}
|
||||
|
||||
hdl.BindTreeChange(tpc)
|
||||
hdl.notifyUserTreeChange("org1", "u1")
|
||||
|
||||
pubs := tpc.PublishManyCalls()
|
||||
require.Len(t, pubs, 1)
|
||||
assert.Equal(t, TreeChangeMessage{}, pubs[0].Payload)
|
||||
|
||||
// the same organization is not enough: it has to be the same user
|
||||
assert.True(t, pubs[0].Filter(sessionCtx(), "topic"))
|
||||
assert.False(t, pubs[0].Filter(otherUserCtx(), "topic"))
|
||||
assert.False(t, pubs[0].Filter(context.Background(), "topic"))
|
||||
}
|
||||
|
|
@ -46,6 +46,12 @@ func (s *Server) wsRouter() *wsserver.Router {
|
|||
},
|
||||
)
|
||||
|
||||
binderFn("change@tag-tree",
|
||||
func(tpc wsserver.Topic) {
|
||||
s.handlers.tag.BindTreeChange(tpc)
|
||||
},
|
||||
)
|
||||
|
||||
binderFn("change@documents.{documentId}.comments",
|
||||
func(tpc wsserver.Topic) {
|
||||
s.handlers.comment.BindCommentsChange(tpc)
|
||||
|
|
@ -303,6 +309,10 @@ func (s *Server) router() chi.Router {
|
|||
})
|
||||
})
|
||||
})
|
||||
ssr.Route("/tags", func(sssr chi.Router) {
|
||||
sssr.Post("/", s.handlers.tag.AssignDocumentTag)
|
||||
sssr.Delete("/{tagId}", s.handlers.tag.UnassignDocumentTag)
|
||||
})
|
||||
ssr.Route("/hooks", func(sssr chi.Router) {
|
||||
sssr.Get("/", s.handlers.hook.FetchDocumentHooks)
|
||||
sssr.Post("/", s.handlers.hook.CreateDocumentHook)
|
||||
|
|
@ -344,6 +354,16 @@ func (s *Server) router() chi.Router {
|
|||
})
|
||||
})
|
||||
|
||||
r.Route("/tags", func(sr chi.Router) {
|
||||
sr.Post("/", s.handlers.tag.CreateTag)
|
||||
sr.Put("/{tagId}/visibility", s.handlers.tag.SetTagVisibility)
|
||||
sr.Delete("/{tagId}", s.handlers.tag.DeleteTag)
|
||||
sr.Route("/tree", func(ssr chi.Router) {
|
||||
ssr.Get("/", s.handlers.tag.FetchTagTree)
|
||||
ssr.Put("/", s.handlers.tag.UpdateTagTree)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ import (
|
|||
"github.com/oxynote/oxynote/server/core/internal/server/internal/notification"
|
||||
"github.com/oxynote/oxynote/server/core/internal/server/internal/org"
|
||||
"github.com/oxynote/oxynote/server/core/internal/server/internal/slack"
|
||||
"github.com/oxynote/oxynote/server/core/internal/server/internal/tag"
|
||||
"github.com/oxynote/oxynote/server/core/internal/server/internal/user"
|
||||
tagCore "github.com/oxynote/oxynote/server/core/internal/tag"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/logutil"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/metricutil"
|
||||
|
|
@ -112,6 +114,7 @@ type Server struct {
|
|||
user *user.Handler
|
||||
organization *org.Handler
|
||||
document *document.Handler
|
||||
tag *tag.Handler
|
||||
comment *comment.Handler
|
||||
files *files.Handler
|
||||
hook *hook.Handler
|
||||
|
|
@ -177,6 +180,10 @@ func NewServer(
|
|||
opts.PublicURL+_organizationLogoLocation,
|
||||
)
|
||||
srv.handlers.document = document.NewHandler(log, db, githubMan, webchangeClient, searchGateway, searchJobs, notifier, storageClient)
|
||||
// FIXME: the tag tables do not exist yet, so the handler runs against
|
||||
// an in-memory stand-in seeded per organization. Swap tagCore.NewStubDB
|
||||
// for db once the real agent methods land.
|
||||
srv.handlers.tag = tag.NewHandler(log, tagCore.NewStubDB(db))
|
||||
srv.handlers.comment = comment.NewHandler(log, db, notifier)
|
||||
srv.handlers.files = files.NewHandler(log, db, storageClient, opts.PublicURL+documentCore.FilePathFormat)
|
||||
srv.handlers.hook = hook.NewHandler(log, db, githubMan, webchangeClient)
|
||||
|
|
|
|||
285
server/core/internal/tag/stub_db.go
Normal file
285
server/core/internal/tag/stub_db.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/errutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
var (
|
||||
// _stubSeedColors are the colours the seeded tags carry, in order.
|
||||
_stubSeedColors = []string{"#22c55e", "#f97316", "#3b82f6"}
|
||||
|
||||
// _stubSeedNames are the names the seeded tags carry, in order.
|
||||
_stubSeedNames = []string{"Production", "Staging", "Incidents"}
|
||||
)
|
||||
|
||||
// DocumentTreeReader reads the organization's document tree.
|
||||
type DocumentTreeReader interface {
|
||||
// FetchDocumentTree should fetch the document tree of an organization.
|
||||
FetchDocumentTree(ctx context.Context, organizationID string) (document.Summaries, error)
|
||||
}
|
||||
|
||||
// StubDB stands in for the tag tables until they exist.
|
||||
//
|
||||
// FIXME: it keeps every organization's tags in memory, so they are lost on
|
||||
// restart and are not shared between instances, and it seeds an
|
||||
// organization with a few tags the first time it is read so the sidebar has
|
||||
// something to show.
|
||||
//
|
||||
// TODO: replace it with the real agent methods on internal/db:
|
||||
// - a `tags` table (id, organization_id, tag_name, color, sort_index,
|
||||
// created_at, created_by) mirroring `documents`;
|
||||
// - a `document_tags` join table (document_id, tag_id) cascading on both
|
||||
// sides, which is what makes a document reachable from several tags;
|
||||
// - a `user_tag_settings` join table (user_id, tag_id, hidden) cascading
|
||||
// on both sides. Visibility is per user, not a column on `tags`: two
|
||||
// members of one organization hide different tags;
|
||||
// - FetchTagTree as one query joining all three against the document
|
||||
// tree, left-joining the settings on the requesting user so a tag
|
||||
// nobody has touched reads as visible, and replacing the assembly this
|
||||
// type does by hand.
|
||||
//
|
||||
// The methods below are the contract the real agent has to keep; the
|
||||
// handler is written against it and needs no change when they are.
|
||||
type StubDB struct {
|
||||
docs DocumentTreeReader
|
||||
|
||||
mu sync.Mutex
|
||||
orgs map[string]*stubOrg
|
||||
}
|
||||
|
||||
// stubOrg holds one organization's tags, their document assignments and
|
||||
// each user's visibility preferences.
|
||||
type stubOrg struct {
|
||||
// tags are the organization's tags, in their display order.
|
||||
tags []Tag
|
||||
|
||||
// assigned maps a tag id to the documents carrying it.
|
||||
assigned map[xid.ID][]xid.ID
|
||||
|
||||
// hidden maps a user id to the tags that user keeps out of their
|
||||
// sidebar. A tag missing from the set reads as visible.
|
||||
hidden map[string]map[xid.ID]bool
|
||||
}
|
||||
|
||||
// NewStubDB creates a fresh instance of StubDB reading documents through
|
||||
// the given reader.
|
||||
func NewStubDB(docs DocumentTreeReader) *StubDB {
|
||||
return &StubDB{
|
||||
docs: docs,
|
||||
orgs: map[string]*stubOrg{},
|
||||
}
|
||||
}
|
||||
|
||||
// FetchTagTree retrieves an organization's tags together with the documents
|
||||
// carrying each of them.
|
||||
func (sd *StubDB) FetchTagTree(ctx context.Context, organizationID, userID string) (Summaries, error) {
|
||||
tree, err := sd.docs.FetchDocumentTree(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byID := map[xid.ID]document.Summary{}
|
||||
for _, doc := range tree.Descendants() {
|
||||
byID[doc.ID] = doc
|
||||
}
|
||||
|
||||
sd.mu.Lock()
|
||||
defer sd.mu.Unlock()
|
||||
|
||||
org := sd.org(organizationID, tree)
|
||||
hidden := org.hidden[userID]
|
||||
out := make(Summaries, 0, len(org.tags))
|
||||
|
||||
for _, t := range org.tags {
|
||||
docs := make(document.Summaries, 0, len(org.assigned[t.ID]))
|
||||
|
||||
for _, docID := range org.assigned[t.ID] {
|
||||
if doc, ok := byID[docID]; ok {
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, Summary{
|
||||
ID: t.ID,
|
||||
TagName: t.TagName,
|
||||
Color: t.Color,
|
||||
Hidden: hidden[t.ID],
|
||||
Documents: docs,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertTag stores a new tag at the end of its organization's tags.
|
||||
func (sd *StubDB) InsertTag(_ context.Context, t Tag) error {
|
||||
sd.mu.Lock()
|
||||
defer sd.mu.Unlock()
|
||||
|
||||
org := sd.org(t.OrganizationID, nil)
|
||||
t.SortIndex = len(org.tags)
|
||||
org.tags = append(org.tags, t)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTagVisibility records whether one user keeps a tag out of their
|
||||
// sidebar. It changes nothing for anybody else.
|
||||
func (sd *StubDB) SetTagVisibility(
|
||||
_ context.Context,
|
||||
organizationID, userID string,
|
||||
id xid.ID,
|
||||
inp VisibilityInput,
|
||||
) error {
|
||||
sd.mu.Lock()
|
||||
defer sd.mu.Unlock()
|
||||
|
||||
org := sd.org(organizationID, nil)
|
||||
|
||||
if !slices.ContainsFunc(org.tags, func(t Tag) bool { return t.ID == id }) {
|
||||
return errutil.ErrNotFound
|
||||
}
|
||||
|
||||
if !inp.Hidden {
|
||||
delete(org.hidden[userID], id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if org.hidden[userID] == nil {
|
||||
org.hidden[userID] = map[xid.ID]bool{}
|
||||
}
|
||||
|
||||
org.hidden[userID][id] = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTag removes a tag and every assignment of it.
|
||||
func (sd *StubDB) DeleteTag(_ context.Context, id xid.ID, organizationID string) error {
|
||||
sd.mu.Lock()
|
||||
defer sd.mu.Unlock()
|
||||
|
||||
org := sd.org(organizationID, nil)
|
||||
|
||||
index := slices.IndexFunc(org.tags, func(t Tag) bool {
|
||||
return t.ID == id
|
||||
})
|
||||
if index == -1 {
|
||||
return errutil.ErrNotFound
|
||||
}
|
||||
|
||||
org.tags = slices.Delete(org.tags, index, index+1)
|
||||
delete(org.assigned, id)
|
||||
|
||||
for _, tags := range org.hidden {
|
||||
delete(tags, id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTagTree rewrites the display order of an organization's tags to the
|
||||
// order of the given tree.
|
||||
func (sd *StubDB) UpdateTagTree(_ context.Context, tree Summaries, organizationID string) error {
|
||||
sd.mu.Lock()
|
||||
defer sd.mu.Unlock()
|
||||
|
||||
org := sd.org(organizationID, nil)
|
||||
reordered := make([]Tag, 0, len(org.tags))
|
||||
|
||||
for _, s := range tree {
|
||||
index := slices.IndexFunc(org.tags, func(t Tag) bool {
|
||||
return t.ID == s.ID
|
||||
})
|
||||
if index == -1 {
|
||||
return errutil.ErrNotFound
|
||||
}
|
||||
|
||||
t := org.tags[index]
|
||||
t.SortIndex = len(reordered)
|
||||
reordered = append(reordered, t)
|
||||
}
|
||||
|
||||
org.tags = reordered
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignDocumentTag makes a document carry a tag. Assigning a tag a
|
||||
// document already carries changes nothing.
|
||||
func (sd *StubDB) AssignDocumentTag(_ context.Context, organizationID string, documentID, tagID xid.ID) error {
|
||||
sd.mu.Lock()
|
||||
defer sd.mu.Unlock()
|
||||
|
||||
org := sd.org(organizationID, nil)
|
||||
|
||||
if !slices.ContainsFunc(org.tags, func(t Tag) bool { return t.ID == tagID }) {
|
||||
return errutil.ErrNotFound
|
||||
}
|
||||
|
||||
if slices.Contains(org.assigned[tagID], documentID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
org.assigned[tagID] = append(org.assigned[tagID], documentID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnassignDocumentTag stops a document carrying a tag. Removing a tag the
|
||||
// document does not carry changes nothing.
|
||||
func (sd *StubDB) UnassignDocumentTag(_ context.Context, organizationID string, documentID, tagID xid.ID) error {
|
||||
sd.mu.Lock()
|
||||
defer sd.mu.Unlock()
|
||||
|
||||
org := sd.org(organizationID, nil)
|
||||
|
||||
index := slices.Index(org.assigned[tagID], documentID)
|
||||
if index == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
org.assigned[tagID] = slices.Delete(org.assigned[tagID], index, index+1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// org returns an organization's state, seeding it from the given document
|
||||
// tree the first time it is touched. Callers must hold the mutex.
|
||||
func (sd *StubDB) org(organizationID string, seed document.Summaries) *stubOrg {
|
||||
if org, ok := sd.orgs[organizationID]; ok {
|
||||
return org
|
||||
}
|
||||
|
||||
org := &stubOrg{
|
||||
assigned: map[xid.ID][]xid.ID{},
|
||||
hidden: map[string]map[xid.ID]bool{},
|
||||
}
|
||||
sd.orgs[organizationID] = org
|
||||
|
||||
for i, name := range _stubSeedNames {
|
||||
org.tags = append(org.tags, Tag{
|
||||
ID: xid.New(),
|
||||
OrganizationID: organizationID,
|
||||
TagName: name,
|
||||
Color: _stubSeedColors[i],
|
||||
SortIndex: i,
|
||||
})
|
||||
}
|
||||
|
||||
// the seeded tags carry the organization's first documents, spread
|
||||
// round-robin, so every tag has something under it.
|
||||
for i, doc := range seed {
|
||||
t := org.tags[i%len(org.tags)]
|
||||
org.assigned[t.ID] = append(org.assigned[t.ID], doc.ID)
|
||||
}
|
||||
|
||||
return org
|
||||
}
|
||||
329
server/core/internal/tag/stub_db_test.go
Normal file
329
server/core/internal/tag/stub_db_test.go
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/errutil"
|
||||
"github.com/rs/xid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeDocs answers with a fixed document tree, or an error.
|
||||
type fakeDocs struct {
|
||||
tree document.Summaries
|
||||
err error
|
||||
}
|
||||
|
||||
func (fd fakeDocs) FetchDocumentTree(context.Context, string) (document.Summaries, error) {
|
||||
return fd.tree, fd.err
|
||||
}
|
||||
|
||||
// docTree builds a two-root tree whose first root carries a child.
|
||||
func docTree(idA, idB, idChild xid.ID) document.Summaries {
|
||||
return document.Summaries{
|
||||
{
|
||||
ID: idA,
|
||||
DocumentName: "Runbook",
|
||||
Children: document.Summaries{{ID: idChild, DocumentName: "Rollback"}},
|
||||
},
|
||||
{ID: idB, DocumentName: "Postmortem"},
|
||||
}
|
||||
}
|
||||
|
||||
// tagNames reads the names out of a tag tree, in order.
|
||||
func tagNames(ss Summaries) []string {
|
||||
out := make([]string, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
out = append(out, s.TagName)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func Test_NewStubDB(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
docs := fakeDocs{}
|
||||
|
||||
sd := NewStubDB(docs)
|
||||
require.NotNil(t, sd)
|
||||
assert.Equal(t, docs, sd.docs)
|
||||
assert.Empty(t, sd.orgs)
|
||||
}
|
||||
|
||||
func Test_StubDB_FetchTagTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Document tree error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{err: errors.New("boom")})
|
||||
|
||||
tree, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, tree)
|
||||
})
|
||||
|
||||
t.Run("Seeds an organization on first read", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
idA, idB, idChild := xid.New(), xid.New(), xid.New()
|
||||
sd := NewStubDB(fakeDocs{tree: docTree(idA, idB, idChild)})
|
||||
|
||||
tree, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, _stubSeedNames, tagNames(tree))
|
||||
|
||||
// the two roots are spread over the first two tags, and the third
|
||||
// seeded tag is left holding nothing
|
||||
require.Len(t, tree[0].Documents, 1)
|
||||
assert.Equal(t, idA, tree[0].Documents[0].ID)
|
||||
require.Len(t, tree[1].Documents, 1)
|
||||
assert.Equal(t, idB, tree[1].Documents[0].ID)
|
||||
assert.Empty(t, tree[2].Documents)
|
||||
})
|
||||
|
||||
t.Run("Drops an assignment whose document is gone", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{})
|
||||
|
||||
first, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, first)
|
||||
|
||||
require.NoError(t, sd.AssignDocumentTag(context.Background(), "org1", xid.New(), first[0].ID))
|
||||
|
||||
tree, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, tree[0].Documents)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_StubDB_InsertTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{})
|
||||
|
||||
tg := NewTag(CreateInput{TagName: "Rollout", Color: "#22c55e"}, "org1", "u1")
|
||||
require.NoError(t, sd.InsertTag(context.Background(), tg))
|
||||
|
||||
tree, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, append(append([]string{}, _stubSeedNames...), "Rollout"), tagNames(tree))
|
||||
assert.Equal(t, len(_stubSeedNames), sd.orgs["org1"].tags[len(_stubSeedNames)].SortIndex)
|
||||
}
|
||||
|
||||
func Test_StubDB_SetTagVisibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Unknown tag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{})
|
||||
|
||||
err := sd.SetTagVisibility(
|
||||
context.Background(), "org1", "u1", xid.New(), VisibilityInput{Hidden: true},
|
||||
)
|
||||
assert.Equal(t, errutil.ErrNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("Hides the tag without dropping it", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{})
|
||||
|
||||
before, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, before[0].Hidden)
|
||||
|
||||
require.NoError(t, sd.SetTagVisibility(
|
||||
context.Background(), "org1", "u1", before[0].ID, VisibilityInput{Hidden: true},
|
||||
))
|
||||
|
||||
after, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, _stubSeedNames, tagNames(after))
|
||||
assert.True(t, after[0].Hidden)
|
||||
})
|
||||
|
||||
t.Run("Shows the tag again", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{})
|
||||
|
||||
before, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sd.SetTagVisibility(
|
||||
context.Background(), "org1", "u1", before[0].ID, VisibilityInput{Hidden: true},
|
||||
))
|
||||
require.NoError(t, sd.SetTagVisibility(
|
||||
context.Background(), "org1", "u1", before[0].ID, VisibilityInput{Hidden: false},
|
||||
))
|
||||
|
||||
after, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, after[0].Hidden)
|
||||
})
|
||||
|
||||
t.Run("Leaves every other user's view alone", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{})
|
||||
|
||||
before, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sd.SetTagVisibility(
|
||||
context.Background(), "org1", "u1", before[0].ID, VisibilityInput{Hidden: true},
|
||||
))
|
||||
|
||||
mine, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mine[0].Hidden)
|
||||
|
||||
theirs, err := sd.FetchTagTree(context.Background(), "org1", "u2")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, theirs[0].Hidden)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_StubDB_DeleteTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Unknown tag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{})
|
||||
|
||||
assert.Equal(t, errutil.ErrNotFound, sd.DeleteTag(context.Background(), xid.New(), "org1"))
|
||||
})
|
||||
|
||||
t.Run("Drops the tag and its assignments", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
idA, idB, idChild := xid.New(), xid.New(), xid.New()
|
||||
sd := NewStubDB(fakeDocs{tree: docTree(idA, idB, idChild)})
|
||||
|
||||
before, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sd.SetTagVisibility(
|
||||
context.Background(), "org1", "u1", before[0].ID, VisibilityInput{Hidden: true},
|
||||
))
|
||||
require.NoError(t, sd.DeleteTag(context.Background(), before[0].ID, "org1"))
|
||||
|
||||
after, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, _stubSeedNames[1:], tagNames(after))
|
||||
assert.NotContains(t, sd.orgs["org1"].assigned, before[0].ID)
|
||||
// the per-user preference goes with the tag
|
||||
assert.NotContains(t, sd.orgs["org1"].hidden["u1"], before[0].ID)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_StubDB_UpdateTagTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Unknown tag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{})
|
||||
|
||||
err := sd.UpdateTagTree(context.Background(), Summaries{{ID: xid.New()}}, "org1")
|
||||
assert.Equal(t, errutil.ErrNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("Rewrites the display order", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{})
|
||||
|
||||
before, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
|
||||
reversed := Summaries{before[2], before[1], before[0]}
|
||||
require.NoError(t, sd.UpdateTagTree(context.Background(), reversed, "org1"))
|
||||
|
||||
after, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{_stubSeedNames[2], _stubSeedNames[1], _stubSeedNames[0]}, tagNames(after))
|
||||
|
||||
for i, tg := range sd.orgs["org1"].tags {
|
||||
assert.Equal(t, i, tg.SortIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Test_StubDB_AssignDocumentTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
idA, idB, idChild := xid.New(), xid.New(), xid.New()
|
||||
|
||||
t.Run("Unknown tag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{tree: docTree(idA, idB, idChild)})
|
||||
|
||||
err := sd.AssignDocumentTag(context.Background(), "org1", idA, xid.New())
|
||||
assert.Equal(t, errutil.ErrNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("Assigning twice changes nothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{tree: docTree(idA, idB, idChild)})
|
||||
|
||||
tree, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sd.AssignDocumentTag(context.Background(), "org1", idChild, tree[0].ID))
|
||||
require.NoError(t, sd.AssignDocumentTag(context.Background(), "org1", idChild, tree[0].ID))
|
||||
|
||||
after, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, after[0].Documents, 2)
|
||||
assert.Equal(t, idChild, after[0].Documents[1].ID)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_StubDB_UnassignDocumentTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
idA, idB, idChild := xid.New(), xid.New(), xid.New()
|
||||
|
||||
t.Run("Removing a tag the document does not carry changes nothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{tree: docTree(idA, idB, idChild)})
|
||||
|
||||
tree, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sd.UnassignDocumentTag(context.Background(), "org1", idChild, tree[0].ID))
|
||||
|
||||
after, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, after[0].Documents, 1)
|
||||
})
|
||||
|
||||
t.Run("Drops the assignment", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sd := NewStubDB(fakeDocs{tree: docTree(idA, idB, idChild)})
|
||||
|
||||
tree, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sd.UnassignDocumentTag(context.Background(), "org1", idA, tree[0].ID))
|
||||
|
||||
after, err := sd.FetchTagTree(context.Background(), "org1", "u1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, after[0].Documents)
|
||||
})
|
||||
}
|
||||
171
server/core/internal/tag/tag.go
Normal file
171
server/core/internal/tag/tag.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// Package tag provides the domain types for the tags a document carries.
|
||||
package tag
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/errutil"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/sliceutil"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidTagName is returned when a tag is created without a name.
|
||||
ErrInvalidTagName = errutil.New(http.StatusBadRequest, "tag.invalid_name", "tag name cannot be empty")
|
||||
|
||||
// ErrInvalidTagColor is returned when a tag carries a colour the frontend
|
||||
// cannot render as a dot.
|
||||
ErrInvalidTagColor = errutil.New(http.StatusBadRequest, "tag.invalid_color", "tag colour must be a hex triplet")
|
||||
)
|
||||
|
||||
// Tag represents a label a document can carry. A document may carry many
|
||||
// tags and a tag may be carried by many documents.
|
||||
type Tag struct {
|
||||
// ID is the unique identifier of the tag.
|
||||
ID xid.ID `json:"id" db:"id"`
|
||||
|
||||
// OrganizationID is the organization the tag belongs to.
|
||||
OrganizationID string `json:"organizationId" db:"organization_id"`
|
||||
|
||||
// TagName is the display name of the tag.
|
||||
TagName string `json:"tagName" db:"tag_name"`
|
||||
|
||||
// Color is the tag's colour as a hex triplet, including the leading hash.
|
||||
Color string `json:"color" db:"color"`
|
||||
|
||||
// SortIndex is the position of the tag among its organization's tags.
|
||||
SortIndex int `json:"sortIndex" db:"sort_index"`
|
||||
|
||||
// CreatedAt is the moment the tag was created.
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
|
||||
// CreatedBy is the user who created the tag.
|
||||
CreatedBy null.String `json:"createdBy" db:"created_by"`
|
||||
}
|
||||
|
||||
// CreateInput is the input structure for creating a tag.
|
||||
type CreateInput struct {
|
||||
// TagName is the display name of the tag.
|
||||
TagName string `json:"tagName"`
|
||||
|
||||
// Color is the tag's colour as a hex triplet, including the leading hash.
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// Validate reports whether the input can become a tag.
|
||||
func (ci CreateInput) Validate() error {
|
||||
if ci.TagName == "" {
|
||||
return ErrInvalidTagName
|
||||
}
|
||||
|
||||
if !isHexColorValid(ci.Color) {
|
||||
return ErrInvalidTagColor
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isHexColorValid reports whether the value is a "#rrggbb" hex triplet.
|
||||
func isHexColorValid(color string) bool {
|
||||
if len(color) != 7 || color[0] != '#' {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, c := range color[1:] {
|
||||
isDigit := c >= '0' && c <= '9'
|
||||
isLower := c >= 'a' && c <= 'f'
|
||||
isUpper := c >= 'A' && c <= 'F'
|
||||
|
||||
if !isDigit && !isLower && !isUpper {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// NewTag creates a fresh instance of Tag from the given input.
|
||||
func NewTag(inp CreateInput, organizationID, userID string) Tag {
|
||||
return Tag{
|
||||
ID: xid.New(),
|
||||
OrganizationID: organizationID,
|
||||
TagName: inp.TagName,
|
||||
Color: inp.Color,
|
||||
CreatedAt: timeutil.Now(),
|
||||
CreatedBy: null.StringFrom(userID),
|
||||
}
|
||||
}
|
||||
|
||||
// VisibilityInput is the input for changing whether one user sees a tag.
|
||||
type VisibilityInput struct {
|
||||
// Hidden keeps the tag out of that user's sidebar without deleting it.
|
||||
Hidden bool `json:"hidden"`
|
||||
}
|
||||
|
||||
// SwapInput is the input for moving a tag to another position.
|
||||
type SwapInput struct {
|
||||
// ID is the identifier of the tag to swap.
|
||||
ID xid.ID `json:"id"`
|
||||
|
||||
// SortIndex is the index the tag should end up at.
|
||||
SortIndex int `json:"sortIndex"`
|
||||
}
|
||||
|
||||
// AssignInput is the input for attaching a tag to a document.
|
||||
type AssignInput struct {
|
||||
// TagID is the identifier of the tag to attach.
|
||||
TagID xid.ID `json:"tagId"`
|
||||
}
|
||||
|
||||
// Summaries is a collection of Summary objects.
|
||||
type Summaries []Summary
|
||||
|
||||
// Swap returns a copy of the Summaries slice with the summary moved to the
|
||||
// given sort index. The receiver is left untouched.
|
||||
func (ss Summaries) Swap(id xid.ID, sortIndex int) (Summaries, error) {
|
||||
if sortIndex < 0 || sortIndex >= len(ss) {
|
||||
return nil, errutil.New(
|
||||
http.StatusBadRequest,
|
||||
"tag_summary.invalid_sort_index",
|
||||
"sort index is out of range",
|
||||
)
|
||||
}
|
||||
|
||||
index := slices.IndexFunc(ss, func(s Summary) bool {
|
||||
return s.ID == id
|
||||
})
|
||||
if index == -1 {
|
||||
return nil, errutil.ErrNotFound
|
||||
}
|
||||
|
||||
nss := slices.Clone(ss)
|
||||
sliceutil.Move(nss, index, sortIndex)
|
||||
|
||||
return nss, nil
|
||||
}
|
||||
|
||||
// Summary represents a tag together with the documents carrying it.
|
||||
type Summary struct {
|
||||
// ID is the unique identifier of the tag.
|
||||
ID xid.ID `json:"id" db:"id"`
|
||||
|
||||
// TagName is the display name of the tag.
|
||||
TagName string `json:"tagName" db:"tag_name"`
|
||||
|
||||
// Color is the tag's colour as a hex triplet, including the leading hash.
|
||||
Color string `json:"color" db:"color"`
|
||||
|
||||
// Hidden reports whether the user who asked for the tree keeps this tag
|
||||
// out of their sidebar. It is a per-user preference, so two members of
|
||||
// the same organization see different values.
|
||||
Hidden bool `json:"hidden" db:"hidden"`
|
||||
|
||||
// Documents are the documents carrying this tag, each with its own
|
||||
// subtree. A document carrying several tags appears under each of them.
|
||||
Documents document.Summaries `json:"documents,omitempty" db:"documents"`
|
||||
}
|
||||
148
server/core/internal/tag/tag_test.go
Normal file
148
server/core/internal/tag/tag_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/pkg/errutil"
|
||||
"github.com/rs/xid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
func Test_CreateInput_Validate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Input CreateInput
|
||||
Err error
|
||||
}{
|
||||
"Empty name": {
|
||||
Input: CreateInput{TagName: "", Color: "#22c55e"},
|
||||
Err: ErrInvalidTagName,
|
||||
},
|
||||
"Colour without a hash": {
|
||||
Input: CreateInput{TagName: "Production", Color: "22c55e"},
|
||||
Err: ErrInvalidTagColor,
|
||||
},
|
||||
"Colour of the wrong length": {
|
||||
Input: CreateInput{TagName: "Production", Color: "#22c5"},
|
||||
Err: ErrInvalidTagColor,
|
||||
},
|
||||
"Colour with a non-hex digit": {
|
||||
Input: CreateInput{TagName: "Production", Color: "#22c55z"},
|
||||
Err: ErrInvalidTagColor,
|
||||
},
|
||||
"Lowercase hex colour": {
|
||||
Input: CreateInput{TagName: "Production", Color: "#22c55e"},
|
||||
},
|
||||
"Uppercase hex colour": {
|
||||
Input: CreateInput{TagName: "Production", Color: "#22C55E"},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Err, c.Input.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_NewTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tg := NewTag(CreateInput{TagName: "Production", Color: "#22c55e"}, "org1", "u1")
|
||||
|
||||
assert.False(t, tg.ID.IsNil())
|
||||
assert.Equal(t, "org1", tg.OrganizationID)
|
||||
assert.Equal(t, "Production", tg.TagName)
|
||||
assert.Equal(t, "#22c55e", tg.Color)
|
||||
assert.Equal(t, "u1", tg.CreatedBy.String)
|
||||
assert.False(t, tg.CreatedAt.IsZero())
|
||||
assert.Zero(t, tg.SortIndex)
|
||||
}
|
||||
|
||||
func Test_Summaries_Swap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
idA, idB, idC := xid.New(), xid.New(), xid.New()
|
||||
|
||||
tree := func() Summaries {
|
||||
return Summaries{{ID: idA}, {ID: idB}, {ID: idC}}
|
||||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
ID xid.ID
|
||||
SortIndex int
|
||||
Err error
|
||||
Order []xid.ID
|
||||
}{
|
||||
"Sort index below the tree": {
|
||||
ID: idA,
|
||||
SortIndex: -1,
|
||||
Err: errutil.New(
|
||||
400,
|
||||
"tag_summary.invalid_sort_index",
|
||||
"sort index is out of range",
|
||||
),
|
||||
},
|
||||
"Sort index beyond the tree": {
|
||||
ID: idA,
|
||||
SortIndex: 3,
|
||||
Err: errutil.New(
|
||||
400,
|
||||
"tag_summary.invalid_sort_index",
|
||||
"sort index is out of range",
|
||||
),
|
||||
},
|
||||
"Unknown tag": {
|
||||
ID: xid.New(),
|
||||
SortIndex: 0,
|
||||
Err: errutil.ErrNotFound,
|
||||
},
|
||||
"Move to the top": {
|
||||
ID: idC,
|
||||
SortIndex: 0,
|
||||
Order: []xid.ID{idC, idA, idB},
|
||||
},
|
||||
"Move to the bottom": {
|
||||
ID: idA,
|
||||
SortIndex: 2,
|
||||
Order: []xid.ID{idB, idC, idA},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := tree()
|
||||
|
||||
swapped, err := original.Swap(c.ID, c.SortIndex)
|
||||
|
||||
if c.Err != nil {
|
||||
assert.Equal(t, c.Err, err)
|
||||
assert.Nil(t, swapped)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
got := make([]xid.ID, 0, len(swapped))
|
||||
for _, s := range swapped {
|
||||
got = append(got, s.ID)
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Order, got)
|
||||
// the receiver is left untouched
|
||||
assert.Equal(t, []xid.ID{idA, idB, idC}, []xid.ID{original[0].ID, original[1].ID, original[2].ID})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,14 @@ function cssVar(name: string, el: HTMLElement = document.documentElement) {
|
|||
return getComputedStyle(el).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
// converts a CSS variable's color value to hex.
|
||||
// converts any CSS color value to hex.
|
||||
// the canvas is never added to the DOM; it only exists in memory for color parsing.
|
||||
// we paint a pixel and read it back because modern Chromium round-trips
|
||||
// newer color formats like oklch through `fillStyle` unchanged, and downstream
|
||||
// consumers (mermaid, echarts) only accept hex/rgb.
|
||||
// consumers (mermaid, echarts, and anything persisting a color) only accept
|
||||
// hex/rgb.
|
||||
let colorCtx: CanvasRenderingContext2D | null = null
|
||||
function cssColorHex(name: string) {
|
||||
export function colorToHex(value: string) {
|
||||
if (!colorCtx) {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = 1
|
||||
|
|
@ -17,7 +18,7 @@ function cssColorHex(name: string) {
|
|||
colorCtx = canvas.getContext("2d", { willReadFrequently: true })!
|
||||
}
|
||||
colorCtx.clearRect(0, 0, 1, 1)
|
||||
colorCtx.fillStyle = cssVar(name)
|
||||
colorCtx.fillStyle = value
|
||||
colorCtx.fillRect(0, 0, 1, 1)
|
||||
const [r, g, b, a] = colorCtx.getImageData(0, 0, 1, 1).data as unknown as [
|
||||
number,
|
||||
|
|
@ -31,6 +32,11 @@ function cssColorHex(name: string) {
|
|||
: `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`
|
||||
}
|
||||
|
||||
// converts a CSS variable's color value to hex.
|
||||
function cssColorHex(name: string) {
|
||||
return colorToHex(cssVar(name))
|
||||
}
|
||||
|
||||
const rightArrowPath = `path://M8.59 14.59L13.17 10 8.59 5.41 10 4l6 6-6 6z`
|
||||
const leftArrowPath = `path://M15.41 14.59L10.83 10l4.58-4.59L14 4l-6 6 6 6z`
|
||||
|
||||
|
|
@ -75,25 +81,25 @@ export function chartStyles() {
|
|||
mutedText: cssVar("--muted-foreground-50"),
|
||||
emptyGauge: cssVar("--muted-foreground-7"),
|
||||
},
|
||||
thresholdColors: {
|
||||
default: cssVar("--chart-threshold-6"),
|
||||
selectableColors: {
|
||||
default: cssVar("--selectable-color-6"),
|
||||
available: [
|
||||
cssVar("--chart-threshold-1"),
|
||||
cssVar("--chart-threshold-2"),
|
||||
cssVar("--chart-threshold-3"),
|
||||
cssVar("--chart-threshold-4"),
|
||||
cssVar("--chart-threshold-5"),
|
||||
cssVar("--chart-threshold-6"),
|
||||
cssVar("--chart-threshold-7"),
|
||||
cssVar("--chart-threshold-8"),
|
||||
cssVar("--chart-threshold-9"),
|
||||
cssVar("--chart-threshold-10"),
|
||||
cssVar("--chart-threshold-11"),
|
||||
cssVar("--chart-threshold-12"),
|
||||
cssVar("--chart-threshold-13"),
|
||||
cssVar("--chart-threshold-14"),
|
||||
cssVar("--chart-threshold-15"),
|
||||
cssVar("--chart-threshold-16"),
|
||||
cssVar("--selectable-color-1"),
|
||||
cssVar("--selectable-color-2"),
|
||||
cssVar("--selectable-color-3"),
|
||||
cssVar("--selectable-color-4"),
|
||||
cssVar("--selectable-color-5"),
|
||||
cssVar("--selectable-color-6"),
|
||||
cssVar("--selectable-color-7"),
|
||||
cssVar("--selectable-color-8"),
|
||||
cssVar("--selectable-color-9"),
|
||||
cssVar("--selectable-color-10"),
|
||||
cssVar("--selectable-color-11"),
|
||||
cssVar("--selectable-color-12"),
|
||||
cssVar("--selectable-color-13"),
|
||||
cssVar("--selectable-color-14"),
|
||||
cssVar("--selectable-color-15"),
|
||||
cssVar("--selectable-color-16"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,22 +74,22 @@
|
|||
--diff-field-added: oklch(53.282% 0.16399 143.531 / 0.2);
|
||||
--diff-field-removed: oklch(57.909% 0.23191 28.689 / 0.2);
|
||||
|
||||
--chart-threshold-1: theme(--color-red-600);
|
||||
--chart-threshold-2: theme(--color-orange-600);
|
||||
--chart-threshold-3: theme(--color-amber-600);
|
||||
--chart-threshold-4: theme(--color-yellow-600);
|
||||
--chart-threshold-5: theme(--color-lime-600);
|
||||
--chart-threshold-6: theme(--color-green-600);
|
||||
--chart-threshold-7: theme(--color-emerald-600);
|
||||
--chart-threshold-8: theme(--color-teal-600);
|
||||
--chart-threshold-9: theme(--color-cyan-600);
|
||||
--chart-threshold-10: theme(--color-sky-600);
|
||||
--chart-threshold-11: theme(--color-blue-600);
|
||||
--chart-threshold-12: theme(--color-indigo-600);
|
||||
--chart-threshold-13: theme(--color-violet-600);
|
||||
--chart-threshold-14: theme(--color-purple-600);
|
||||
--chart-threshold-15: theme(--color-fuchsia-600);
|
||||
--chart-threshold-16: theme(--color-pink-600);
|
||||
--selectable-color-1: theme(--color-red-600);
|
||||
--selectable-color-2: theme(--color-orange-600);
|
||||
--selectable-color-3: theme(--color-amber-600);
|
||||
--selectable-color-4: theme(--color-yellow-600);
|
||||
--selectable-color-5: theme(--color-lime-600);
|
||||
--selectable-color-6: theme(--color-green-600);
|
||||
--selectable-color-7: theme(--color-emerald-600);
|
||||
--selectable-color-8: theme(--color-teal-600);
|
||||
--selectable-color-9: theme(--color-cyan-600);
|
||||
--selectable-color-10: theme(--color-sky-600);
|
||||
--selectable-color-11: theme(--color-blue-600);
|
||||
--selectable-color-12: theme(--color-indigo-600);
|
||||
--selectable-color-13: theme(--color-violet-600);
|
||||
--selectable-color-14: theme(--color-purple-600);
|
||||
--selectable-color-15: theme(--color-fuchsia-600);
|
||||
--selectable-color-16: theme(--color-pink-600);
|
||||
|
||||
--chart-data-1: theme(--color-amber-500);
|
||||
--chart-data-2: theme(--color-teal-500);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,16 @@ import {
|
|||
import { mockNuxtImport } from "@nuxt/test-utils/runtime"
|
||||
import AppSidebar from "./AppSidebar.vue"
|
||||
import {
|
||||
at,
|
||||
clearTeleportedOverlays,
|
||||
emitFrom,
|
||||
emitFromNth,
|
||||
menuItem,
|
||||
mockAuthEndpoint,
|
||||
mountUnderSidebarProvider,
|
||||
seedAuthOrganization,
|
||||
seedCapabilities,
|
||||
seedPersistentState,
|
||||
settleMutations,
|
||||
stubViewportMatches,
|
||||
t,
|
||||
|
|
@ -28,6 +33,26 @@ const navigateToMock = vi.hoisted(() => vi.fn())
|
|||
mockNuxtImport("navigateTo", () => navigateToMock)
|
||||
|
||||
const DOC_ID = "doc1".padEnd(20, "0")
|
||||
const TAG_A = "taga".padEnd(20, "0")
|
||||
const TAG_B = "tagb".padEnd(20, "0")
|
||||
|
||||
function tagElement(id: string, tagName: string, color: string) {
|
||||
return {
|
||||
id: id,
|
||||
tagName: tagName,
|
||||
color: color,
|
||||
hidden: false,
|
||||
documents: [
|
||||
{
|
||||
id: DOC_ID,
|
||||
documentName: "Runbook",
|
||||
icon: "lucide:file",
|
||||
protected: false,
|
||||
children: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function treeElement() {
|
||||
return {
|
||||
|
|
@ -44,12 +69,14 @@ function treeElement() {
|
|||
function stubQueries(
|
||||
options: {
|
||||
tree?: unknown[]
|
||||
tags?: unknown[]
|
||||
unread?: number
|
||||
gitHub?: { connected: boolean; configured: boolean }
|
||||
slack?: { connected: boolean; configured: boolean }
|
||||
} = {},
|
||||
) {
|
||||
mockEndpoint("GET", "/api/documents/tree", () => options.tree ?? [])
|
||||
mockEndpoint("GET", "/api/tags/tree", () => options.tags ?? [])
|
||||
mockEndpoint("GET", "/api/notifications/count", () => ({
|
||||
count: options.unread ?? 0,
|
||||
}))
|
||||
|
|
@ -77,6 +104,85 @@ function sidebar(wrapper: Awaited<ReturnType<typeof mountSidebar>>) {
|
|||
return wrapper.findComponent(AppSidebar)
|
||||
}
|
||||
|
||||
// each section mounts its own root <SidebarNestedGroup> and every expanded
|
||||
// row mounts another one below it, so a section's group is the first one
|
||||
// rendering a row that belongs to it
|
||||
function groupIndexShowing(
|
||||
wrapper: Awaited<ReturnType<typeof mountSidebar>>,
|
||||
text: string,
|
||||
) {
|
||||
const index = wrapper
|
||||
.findAllComponents({ name: "SidebarNestedGroup" })
|
||||
.findIndex((group) => group.text().includes(text))
|
||||
if (index === -1) {
|
||||
throw new Error(`no sidebar group rendering "${text}"`)
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
function tagsGroup(wrapper: Awaited<ReturnType<typeof mountSidebar>>) {
|
||||
const group = wrapper.findAll("[data-slot='sidebar-group']").find((el) => {
|
||||
const label = el.find("[data-slot='sidebar-group-label']")
|
||||
|
||||
return label.exists() && label.text() === t("sidebar.sections.tags.heading")
|
||||
})
|
||||
if (!group) {
|
||||
throw new Error("no tags section")
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
// every row starts collapsed in this suite, so the tags section renders
|
||||
// its tags and nothing below them
|
||||
function tagRows(wrapper: Awaited<ReturnType<typeof mountSidebar>>) {
|
||||
return tagsGroup(wrapper).findAll("[data-item-id]")
|
||||
}
|
||||
|
||||
function tagNames(wrapper: Awaited<ReturnType<typeof mountSidebar>>) {
|
||||
return tagRows(wrapper).map((row) => row.text())
|
||||
}
|
||||
|
||||
function tagIds(wrapper: Awaited<ReturnType<typeof mountSidebar>>) {
|
||||
return tagRows(wrapper).map((row) => row.attributes("data-item-id"))
|
||||
}
|
||||
|
||||
// opens the options menu of the row whose own label carries the given
|
||||
// text. A row element wraps the rows nested under it, so the match is made
|
||||
// against its own button rather than its whole subtree.
|
||||
async function openRowMenu(
|
||||
wrapper: Awaited<ReturnType<typeof mountSidebar>>,
|
||||
text: string,
|
||||
) {
|
||||
const row = wrapper
|
||||
.findAll("[data-item-id]")
|
||||
.find((el) =>
|
||||
el.get("[data-slot='sidebar-menu-button']").text().includes(text),
|
||||
)
|
||||
if (!row) {
|
||||
throw new Error(`no sidebar row labelled "${text}"`)
|
||||
}
|
||||
|
||||
await row.get("[data-slot='dropdown-menu-trigger']").trigger("click")
|
||||
}
|
||||
|
||||
// expands the first tag so the documents carrying it render
|
||||
async function openFirstTag(wrapper: Awaited<ReturnType<typeof mountSidebar>>) {
|
||||
await at(
|
||||
at(tagRows(wrapper), 0).findAll("[data-sidebar='menu-action']"),
|
||||
1,
|
||||
).trigger("click")
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
// the rows of whichever options menu is currently open
|
||||
function openMenuRows() {
|
||||
return Array.from(document.body.querySelectorAll("[role^='menuitem']")).map(
|
||||
(el) => el.textContent.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function itemNamed(
|
||||
wrapper: Awaited<ReturnType<typeof mountSidebar>>,
|
||||
name: string,
|
||||
|
|
@ -101,6 +207,11 @@ describe("<AppSidebar>", { concurrent: false }, () => {
|
|||
// the sidebar renders into a sheet on narrow viewports; the wide
|
||||
// layout is the one these tests read
|
||||
stubViewportMatches(false)
|
||||
// the collapse state is a persisted singleton every mount in the file
|
||||
// shares, and an empty one makes the first section that mounts with
|
||||
// expandable rows open them — seeding it non-empty keeps every row
|
||||
// closed instead, whatever the tree behind it looks like
|
||||
seedPersistentState("sidebar-item-collapse", { seeded: 1 })
|
||||
seedAuthOrganization({
|
||||
id: "org-1",
|
||||
name: "Acme Corp",
|
||||
|
|
@ -111,7 +222,12 @@ describe("<AppSidebar>", { concurrent: false }, () => {
|
|||
})
|
||||
})
|
||||
|
||||
afterEach(disposeMockEndpoints)
|
||||
afterEach(() => {
|
||||
disposeMockEndpoints()
|
||||
// the options menus teleport into the shared <body> and their ids
|
||||
// repeat across mounts, so a leftover would answer the next lookup
|
||||
clearTeleportedOverlays()
|
||||
})
|
||||
|
||||
it("stays blank until the initial sections have loaded", async ({
|
||||
expect,
|
||||
|
|
@ -502,6 +618,257 @@ describe("<AppSidebar>", { concurrent: false }, () => {
|
|||
})
|
||||
})
|
||||
|
||||
// the section only renders once core answers with tags, so every test
|
||||
// in here supplies its own
|
||||
function seededTags() {
|
||||
return [
|
||||
tagElement(TAG_A, "Production", "#22c55e"),
|
||||
tagElement(TAG_B, "Staging", "#f97316"),
|
||||
]
|
||||
}
|
||||
|
||||
describe("tags", { concurrent: false }, () => {
|
||||
it("lists the tags under their own heading", async ({ expect }) => {
|
||||
stubQueries({ tags: seededTags() })
|
||||
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
expect(wrapper.text()).toContain(t("sidebar.sections.tags.heading"))
|
||||
expect(tagNames(wrapper).length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it("marks every tag with its own colour", async ({ expect }) => {
|
||||
stubQueries({ tags: seededTags() })
|
||||
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
const colors = tagRows(wrapper).map(
|
||||
(row) => row.find("span[style]").attributes("style") ?? "",
|
||||
)
|
||||
|
||||
expect(colors.every((color) => color.includes("background-color"))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(new Set(colors).size).toBe(colors.length)
|
||||
})
|
||||
|
||||
it("lists the documents carrying a tag once it is opened", async ({
|
||||
expect,
|
||||
}) => {
|
||||
stubQueries({ tags: seededTags() })
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
const closed = tagRows(wrapper).length
|
||||
|
||||
await at(
|
||||
at(tagRows(wrapper), 0).findAll("[data-sidebar='menu-action']"),
|
||||
1,
|
||||
).trigger("click")
|
||||
await nextTick()
|
||||
|
||||
expect(tagRows(wrapper).length).toBeGreaterThan(closed)
|
||||
})
|
||||
|
||||
it("reorders the tags without moving anything in the page tree", async ({
|
||||
expect,
|
||||
}) => {
|
||||
stubQueries({ tree: [treeElement()], tags: seededTags() })
|
||||
const tagCalls = mockEndpoint("PUT", "/api/tags/tree", () => ({}))
|
||||
const treeCalls = mockEndpoint("PUT", "/api/documents/tree", () => ({}))
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
const names = tagNames(wrapper)
|
||||
|
||||
emitFromNth(
|
||||
wrapper,
|
||||
"SidebarNestedGroup",
|
||||
groupIndexShowing(wrapper, at(names, 0)),
|
||||
"update-location",
|
||||
{
|
||||
id: at(tagIds(wrapper), names.length - 1),
|
||||
parentId: null,
|
||||
insertBeforeId: at(tagIds(wrapper), 0),
|
||||
},
|
||||
)
|
||||
await settleMutations()
|
||||
|
||||
// the move goes to the tag endpoint, resolved to the index the
|
||||
// dropped-on tag held, and the page tree is left alone
|
||||
expect(tagCalls).toHaveLength(1)
|
||||
expect(tagCalls[0]?.body).toEqual({ id: TAG_B, sortIndex: 0 })
|
||||
expect(treeCalls).toHaveLength(0)
|
||||
expect(toast.custom).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("hides a tag from its options menu", async ({ expect }) => {
|
||||
stubQueries({ tags: seededTags() })
|
||||
const calls = mockEndpoint(
|
||||
"PUT",
|
||||
`/api/tags/${TAG_A}/visibility`,
|
||||
() => ({}),
|
||||
)
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
await openRowMenu(wrapper, "Production")
|
||||
menuItem(t("sidebar.item-dropdown-menu-buttons.hide-tag")).click()
|
||||
await settleMutations()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.body).toEqual({ hidden: true })
|
||||
})
|
||||
|
||||
it("deletes a tag from its options menu", async ({ expect }) => {
|
||||
stubQueries({ tags: seededTags() })
|
||||
const calls = mockEndpoint("DELETE", `/api/tags/${TAG_A}`, () => ({}))
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
await openRowMenu(wrapper, "Production")
|
||||
menuItem(t("sidebar.item-dropdown-menu-buttons.delete-tag")).click()
|
||||
await settleMutations()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("detaches a document from the tag listing it", async ({ expect }) => {
|
||||
stubQueries({ tags: seededTags() })
|
||||
const calls = mockEndpoint(
|
||||
"DELETE",
|
||||
`/api/documents/${DOC_ID}/tags/${TAG_A}`,
|
||||
() => ({}),
|
||||
)
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
await openFirstTag(wrapper)
|
||||
await openRowMenu(wrapper, "Runbook")
|
||||
menuItem(t("sidebar.item-dropdown-menu-buttons.remove-tag")).click()
|
||||
await settleMutations()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("offers a tagged document only its detach action", async ({
|
||||
expect,
|
||||
}) => {
|
||||
stubQueries({ tags: seededTags() })
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
await openFirstTag(wrapper)
|
||||
await openRowMenu(wrapper, "Runbook")
|
||||
|
||||
expect(openMenuRows()).toEqual([
|
||||
t("sidebar.item-dropdown-menu-buttons.remove-tag"),
|
||||
])
|
||||
})
|
||||
|
||||
it("offers a tag its own two actions", async ({ expect }) => {
|
||||
stubQueries({ tags: seededTags() })
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
await openRowMenu(wrapper, "Production")
|
||||
|
||||
expect(openMenuRows()).toEqual([
|
||||
t("sidebar.item-dropdown-menu-buttons.hide-tag"),
|
||||
t("sidebar.item-dropdown-menu-buttons.delete-tag"),
|
||||
])
|
||||
})
|
||||
|
||||
it("toggles a tag's visibility from the section heading", async ({
|
||||
expect,
|
||||
}) => {
|
||||
stubQueries({
|
||||
tags: [
|
||||
tagElement(TAG_A, "Production", "#22c55e"),
|
||||
{ ...tagElement(TAG_B, "Staging", "#f97316"), hidden: true },
|
||||
],
|
||||
})
|
||||
const calls = mockEndpoint(
|
||||
"PUT",
|
||||
`/api/tags/${TAG_B}/visibility`,
|
||||
() => ({}),
|
||||
)
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
// the section's own heading control, not a row's options menu
|
||||
await tagsGroup(wrapper)
|
||||
.get("[data-sidebar='group-action']")
|
||||
.trigger("click")
|
||||
menuItem("Staging").click()
|
||||
await settleMutations()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.body).toEqual({ hidden: false })
|
||||
})
|
||||
|
||||
it("keeps the section while every tag is hidden", async ({ expect }) => {
|
||||
stubQueries({
|
||||
tags: [
|
||||
{ ...tagElement(TAG_A, "Production", "#22c55e"), hidden: true },
|
||||
{ ...tagElement(TAG_B, "Staging", "#f97316"), hidden: true },
|
||||
],
|
||||
})
|
||||
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
// no rows are left, but the section says so and keeps the control
|
||||
// that brings one back
|
||||
expect(tagNames(wrapper)).toEqual([])
|
||||
expect(tagsGroup(wrapper).text()).toContain(
|
||||
t("sidebar.sections.tags.all-hidden"),
|
||||
)
|
||||
expect(
|
||||
tagsGroup(wrapper).find("[data-sidebar='group-action']").exists(),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps a hidden tag out of the section", async ({ expect }) => {
|
||||
stubQueries({
|
||||
tags: [
|
||||
{ ...tagElement(TAG_A, "Production", "#22c55e"), hidden: true },
|
||||
tagElement(TAG_B, "Staging", "#f97316"),
|
||||
],
|
||||
})
|
||||
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
const names = tagNames(wrapper)
|
||||
expect(names).toHaveLength(1)
|
||||
expect(at(names, 0)).toContain("Staging")
|
||||
})
|
||||
|
||||
it("warns when a tag cannot be moved", async ({ expect }) => {
|
||||
stubQueries({ tags: seededTags() })
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
const names = tagNames(wrapper)
|
||||
|
||||
emitFromNth(
|
||||
wrapper,
|
||||
"SidebarNestedGroup",
|
||||
groupIndexShowing(wrapper, at(names, 0)),
|
||||
"update-location",
|
||||
{
|
||||
id: "nosuchtag00000000001",
|
||||
parentId: null,
|
||||
insertBeforeId: null,
|
||||
},
|
||||
)
|
||||
await settleMutations()
|
||||
|
||||
expect(toast.custom).toHaveBeenCalledTimes(1)
|
||||
expect(tagNames(wrapper)).toEqual(names)
|
||||
})
|
||||
})
|
||||
|
||||
describe("page moves", { concurrent: false }, () => {
|
||||
it("persists a move requested by the page tree", async ({ expect }) => {
|
||||
stubQueries({ tree: [treeElement()] })
|
||||
|
|
@ -509,11 +876,13 @@ describe("<AppSidebar>", { concurrent: false }, () => {
|
|||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
emitFrom(wrapper, "SidebarNestedGroup", "update-location", {
|
||||
id: DOC_ID,
|
||||
parentId: null,
|
||||
insertBeforeId: null,
|
||||
})
|
||||
emitFromNth(
|
||||
wrapper,
|
||||
"SidebarNestedGroup",
|
||||
groupIndexShowing(wrapper, "Runbook"),
|
||||
"update-location",
|
||||
{ id: DOC_ID, parentId: null, insertBeforeId: null },
|
||||
)
|
||||
await settleMutations()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
|
|
@ -528,36 +897,52 @@ describe("<AppSidebar>", { concurrent: false }, () => {
|
|||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
emitFrom(wrapper, "SidebarNestedGroup", "update-location", {
|
||||
id: DOC_ID,
|
||||
parentId: null,
|
||||
insertBeforeId: null,
|
||||
})
|
||||
emitFromNth(
|
||||
wrapper,
|
||||
"SidebarNestedGroup",
|
||||
groupIndexShowing(wrapper, "Runbook"),
|
||||
"update-location",
|
||||
{ id: DOC_ID, parentId: null, insertBeforeId: null },
|
||||
)
|
||||
await settleMutations()
|
||||
|
||||
expect(toast.custom).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("passes a deletion request on to its parent", async ({ expect }) => {
|
||||
stubQueries()
|
||||
stubQueries({ tree: [treeElement()] })
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
emitFrom(wrapper, "SidebarNestedGroup", "delete", { id: DOC_ID })
|
||||
await openRowMenu(wrapper, "Runbook")
|
||||
menuItem(t("sidebar.item-dropdown-menu-buttons.delete-page")).click()
|
||||
await nextTick()
|
||||
|
||||
expect(sidebar(wrapper).emitted("delete-document")).toEqual([[DOC_ID]])
|
||||
})
|
||||
|
||||
it("passes a duplication request on to its parent", async ({ expect }) => {
|
||||
stubQueries()
|
||||
stubQueries({ tree: [treeElement()] })
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
emitFrom(wrapper, "SidebarNestedGroup", "duplicate", { id: DOC_ID })
|
||||
await openRowMenu(wrapper, "Runbook")
|
||||
menuItem(t("sidebar.item-dropdown-menu-buttons.duplicate-page")).click()
|
||||
await nextTick()
|
||||
|
||||
expect(sidebar(wrapper).emitted("duplicate-document")).toEqual([[DOC_ID]])
|
||||
})
|
||||
|
||||
it("asks for a sub page from the options menu", async ({ expect }) => {
|
||||
stubQueries({ tree: [treeElement()] })
|
||||
const wrapper = await mountSidebar()
|
||||
await settleMutations()
|
||||
|
||||
await openRowMenu(wrapper, "Runbook")
|
||||
menuItem(t("sidebar.item-dropdown-menu-buttons.add-sub-page")).click()
|
||||
await nextTick()
|
||||
|
||||
expect(sidebar(wrapper).emitted("create-document")).toEqual([[DOC_ID]])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
import { extractInitials } from "~/utils/object"
|
||||
import {
|
||||
processDocumentTree,
|
||||
processTagTree,
|
||||
type PlainActionFn,
|
||||
type SidebarItem,
|
||||
type SidebarItemAction,
|
||||
type SidebarItemCreate,
|
||||
type SidebarItemDelete,
|
||||
type SidebarItemDuplicate,
|
||||
type SidebarItemLocationUpdate,
|
||||
} from "./sidebar"
|
||||
import SidebarNestedGroup from "./SidebarNestedGroup.vue"
|
||||
|
|
@ -26,6 +26,13 @@ const emit = defineEmits<{
|
|||
|
||||
const { t } = useI18n({ useScope: "global" })
|
||||
const { fetchDocumentTree, updateDocumentTree } = useDocumentAPI()
|
||||
const {
|
||||
fetchTagTree,
|
||||
updateTagTree,
|
||||
updateTagVisibility,
|
||||
deleteTag,
|
||||
unassignDocumentTag,
|
||||
} = useTagAPI()
|
||||
const { fetchOrganization, safeSignOut } = useAuthSession()
|
||||
const { fetchGitHubConnectionStatus, gitHubConfigured, fetchGitHubInstallURL } =
|
||||
useGitHubAPI()
|
||||
|
|
@ -38,6 +45,7 @@ const { openExternalLink } = useExternalLinks()
|
|||
const isSearchModalOpen = ref(false)
|
||||
const wsState = useWebSocketStateStore()
|
||||
let unsubWsTreeChange: (() => void) | null | undefined = null
|
||||
let unsubWsTagTreeChange: (() => void) | null | undefined = null
|
||||
let initialLoadComplete = false
|
||||
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
|
@ -58,6 +66,7 @@ useShortcut(SHORTCUT_ACTIONS.toggleSettings.keyboardKey, () => {
|
|||
})
|
||||
|
||||
interface Section {
|
||||
id: string
|
||||
heading?: string
|
||||
headingAction?: {
|
||||
title: string
|
||||
|
|
@ -69,17 +78,22 @@ interface Section {
|
|||
fn: PlainActionFn
|
||||
}
|
||||
isEmptyAfterLoad?: boolean
|
||||
// shown in place of the rows while the section has none
|
||||
emptyMessage?: string
|
||||
items: SidebarItem[]
|
||||
onUpdateLocation?: (data: SidebarItemLocationUpdate) => void | Promise<void>
|
||||
}
|
||||
|
||||
const sections = computed<Section[]>(() => {
|
||||
const topSection: Section = {
|
||||
id: "top",
|
||||
// no group heading
|
||||
items: [],
|
||||
}
|
||||
const rootItems: Section[] = [
|
||||
topSection,
|
||||
{
|
||||
id: "workspace",
|
||||
heading: t("sidebar.sections.main-workspace.heading"),
|
||||
headingAction: {
|
||||
title: t("sidebar.sections.main-workspace.heading-action-title"),
|
||||
|
|
@ -94,12 +108,35 @@ const sections = computed<Section[]>(() => {
|
|||
editorStore.activeDocumentId,
|
||||
t("sidebar.item-placeholder-name"),
|
||||
fetchOrganization.data.value?.data?.name || "",
|
||||
documentActions,
|
||||
),
|
||||
isEmptyAfterLoad:
|
||||
!fetchDocumentTree.data.value?.length &&
|
||||
!fetchDocumentTree.isPending.value,
|
||||
onUpdateLocation: handleDocumentLocationUpdate,
|
||||
},
|
||||
]
|
||||
const tagItems = processTagTree(
|
||||
fetchTagTree.data.value ?? [],
|
||||
editorStore.activeDocumentId,
|
||||
fetchOrganization.data.value?.data?.name || "",
|
||||
tagActions,
|
||||
taggedDocumentActions,
|
||||
)
|
||||
|
||||
// the section stays as long as the organization has any tag at all: it
|
||||
// carries the control that brings a hidden one back, and hiding the
|
||||
// last visible tag would otherwise take that control with it.
|
||||
if (fetchTagTree.data.value?.length) {
|
||||
rootItems.push({
|
||||
id: "tags",
|
||||
heading: t("sidebar.sections.tags.heading"),
|
||||
emptyMessage: t("sidebar.sections.tags.all-hidden"),
|
||||
items: tagItems,
|
||||
onUpdateLocation: handleTagLocationUpdate,
|
||||
})
|
||||
}
|
||||
|
||||
const topSectionItems: SidebarItem[] = [
|
||||
{
|
||||
id: "search-button",
|
||||
|
|
@ -108,9 +145,10 @@ const sections = computed<Section[]>(() => {
|
|||
onClick: () => {
|
||||
isSearchModalOpen.value = true
|
||||
},
|
||||
partOfDocumentTree: false,
|
||||
acceptsChildren: false,
|
||||
active: false,
|
||||
draggable: false,
|
||||
actions: [],
|
||||
shortcutTooltip: SHORTCUT_ACTIONS.searchForDocuments,
|
||||
children: null,
|
||||
},
|
||||
|
|
@ -122,9 +160,10 @@ const sections = computed<Section[]>(() => {
|
|||
onClick: () => {
|
||||
emit("toggle-notifications")
|
||||
},
|
||||
partOfDocumentTree: false,
|
||||
acceptsChildren: false,
|
||||
active: props.notificationSidebarOpen,
|
||||
draggable: false,
|
||||
actions: [],
|
||||
shortcutTooltip: SHORTCUT_ACTIONS.toggleInbox,
|
||||
children: null,
|
||||
},
|
||||
|
|
@ -144,9 +183,10 @@ const sections = computed<Section[]>(() => {
|
|||
onClick: () => {
|
||||
emit("open-settings", "org-members")
|
||||
},
|
||||
partOfDocumentTree: false,
|
||||
acceptsChildren: false,
|
||||
active: false,
|
||||
draggable: false,
|
||||
actions: [],
|
||||
prefetchUrlOnInteraction: false,
|
||||
localOptimisticInsert: false,
|
||||
children: null,
|
||||
|
|
@ -164,9 +204,10 @@ const sections = computed<Section[]>(() => {
|
|||
onClick: async () => {
|
||||
await installGitHub()
|
||||
},
|
||||
partOfDocumentTree: false,
|
||||
acceptsChildren: false,
|
||||
active: false,
|
||||
draggable: false,
|
||||
actions: [],
|
||||
prefetchUrlOnInteraction: false,
|
||||
localOptimisticInsert: false,
|
||||
children: null,
|
||||
|
|
@ -184,9 +225,10 @@ const sections = computed<Section[]>(() => {
|
|||
onClick: async () => {
|
||||
await installSlack()
|
||||
},
|
||||
partOfDocumentTree: false,
|
||||
acceptsChildren: false,
|
||||
active: false,
|
||||
draggable: false,
|
||||
actions: [],
|
||||
prefetchUrlOnInteraction: false,
|
||||
localOptimisticInsert: false,
|
||||
children: null,
|
||||
|
|
@ -195,6 +237,7 @@ const sections = computed<Section[]>(() => {
|
|||
|
||||
if (nextStepsItems.length) {
|
||||
rootItems.push({
|
||||
id: "next-steps",
|
||||
heading: t("sidebar.sections.next-steps.heading"),
|
||||
items: nextStepsItems,
|
||||
})
|
||||
|
|
@ -205,6 +248,7 @@ const sections = computed<Section[]>(() => {
|
|||
|
||||
onBeforeMount(() => {
|
||||
void fetchDocumentTree.refresh()
|
||||
void fetchTagTree.refresh()
|
||||
void fetchOrganization.refresh()
|
||||
void fetchNotificationCount.refresh()
|
||||
})
|
||||
|
|
@ -215,9 +259,16 @@ onMounted(() => {
|
|||
void fetchDocumentTree.refetch()
|
||||
},
|
||||
)
|
||||
unsubWsTagTreeChange = wsState.state?.subscribe(
|
||||
WS_TAG_TREE_CHANGE_TOPIC,
|
||||
() => {
|
||||
void fetchTagTree.refetch()
|
||||
},
|
||||
)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
unsubWsTreeChange?.()
|
||||
unsubWsTagTreeChange?.()
|
||||
})
|
||||
|
||||
if (import.meta.client) {
|
||||
|
|
@ -253,11 +304,104 @@ function handleCreate(data: SidebarItemCreate) {
|
|||
emit("create-document", data.parentId)
|
||||
}
|
||||
|
||||
function handleDuplicate(data: SidebarItemDuplicate) {
|
||||
emit("duplicate-document", data.id)
|
||||
function documentActions(documentId: string): SidebarItemAction[] {
|
||||
return [
|
||||
{
|
||||
id: "duplicate-page",
|
||||
name: t("sidebar.item-dropdown-menu-buttons.duplicate-page"),
|
||||
icon: "mingcute:copy-2-line",
|
||||
fn: () => {
|
||||
emit("duplicate-document", documentId)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "add-sub-page",
|
||||
name: t("sidebar.item-dropdown-menu-buttons.add-sub-page"),
|
||||
icon: "lucide:file-plus",
|
||||
fn: () => {
|
||||
emit("create-document", documentId)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete-page",
|
||||
name: t("sidebar.item-dropdown-menu-buttons.delete-page"),
|
||||
icon: "lucide:trash-2",
|
||||
fn: () => {
|
||||
emit("delete-document", documentId)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async function handleLocationUpdate(data: SidebarItemLocationUpdate) {
|
||||
function handleTagVisibilityToggle(tag: TagTreeElement) {
|
||||
void handleTagVisibilityUpdate(tag.id, { hidden: !tag.hidden })
|
||||
}
|
||||
|
||||
function tagActions(tag: TagTreeElement): SidebarItemAction[] {
|
||||
return [
|
||||
{
|
||||
id: "hide-tag",
|
||||
name: t("sidebar.item-dropdown-menu-buttons.hide-tag"),
|
||||
icon: "lucide:eye-off",
|
||||
fn: async () => {
|
||||
await handleTagVisibilityUpdate(tag.id, { hidden: true })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete-tag",
|
||||
name: t("sidebar.item-dropdown-menu-buttons.delete-tag"),
|
||||
icon: "lucide:trash-2",
|
||||
fn: async () => {
|
||||
await handleTagDelete(tag.id)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function taggedDocumentActions(
|
||||
documentId: string,
|
||||
tagId: string,
|
||||
): SidebarItemAction[] {
|
||||
return [
|
||||
{
|
||||
id: "remove-tag",
|
||||
name: t("sidebar.item-dropdown-menu-buttons.remove-tag"),
|
||||
icon: "lucide:x",
|
||||
fn: async () => {
|
||||
await handleDocumentTagRemoval(documentId, tagId)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async function handleTagVisibilityUpdate(id: string, req: { hidden: boolean }) {
|
||||
try {
|
||||
await updateTagVisibility.mutateAsync({ id: id, req: req })
|
||||
} catch {
|
||||
showToastMessage("error", t("sidebar.errors.update-tag-failed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTagDelete(id: string) {
|
||||
try {
|
||||
await deleteTag.mutateAsync(id)
|
||||
} catch {
|
||||
showToastMessage("error", t("sidebar.errors.delete-tag-failed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDocumentTagRemoval(documentId: string, tagId: string) {
|
||||
try {
|
||||
await unassignDocumentTag.mutateAsync({
|
||||
documentId: documentId,
|
||||
tagId: tagId,
|
||||
})
|
||||
} catch {
|
||||
showToastMessage("error", t("sidebar.errors.remove-document-tag-failed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDocumentLocationUpdate(data: SidebarItemLocationUpdate) {
|
||||
try {
|
||||
await updateDocumentTree.mutateAsync({
|
||||
id: data.id,
|
||||
|
|
@ -269,8 +413,15 @@ async function handleLocationUpdate(data: SidebarItemLocationUpdate) {
|
|||
}
|
||||
}
|
||||
|
||||
function handleDelete(data: SidebarItemDelete) {
|
||||
emit("delete-document", data.id)
|
||||
async function handleTagLocationUpdate(data: SidebarItemLocationUpdate) {
|
||||
try {
|
||||
await updateTagTree.mutateAsync({
|
||||
id: data.id,
|
||||
insertBeforeId: data.insertBeforeId,
|
||||
})
|
||||
} catch {
|
||||
showToastMessage("error", t("sidebar.errors.update-tags-failed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function installGitHub() {
|
||||
|
|
@ -325,10 +476,7 @@ async function installSlack() {
|
|||
</ShadcnUiSidebarMenu>
|
||||
</ShadcnUiSidebarHeader>
|
||||
<ShadcnUiSidebarContent>
|
||||
<ShadcnUiSidebarGroup
|
||||
v-for="(section, index) in sections"
|
||||
:key="index"
|
||||
>
|
||||
<ShadcnUiSidebarGroup v-for="section in sections" :key="section.id">
|
||||
<ShadcnUiSidebarGroupLabel v-if="section.heading">
|
||||
{{ section.heading }}
|
||||
</ShadcnUiSidebarGroupLabel>
|
||||
|
|
@ -342,15 +490,24 @@ async function installSlack() {
|
|||
<span class="sr-only">{{ section.headingAction.title }}</span>
|
||||
</ShadcnUiSidebarGroupAction>
|
||||
</ShortcutTooltip>
|
||||
<SidebarTagVisibility
|
||||
v-if="section.id === 'tags'"
|
||||
:tags="fetchTagTree.data.value ?? []"
|
||||
@toggle="handleTagVisibilityToggle"
|
||||
/>
|
||||
<ShadcnUiSidebarGroupContent>
|
||||
<ShadcnUiSidebarMenu>
|
||||
<div
|
||||
v-if="section.emptyMessage && !section.items.length"
|
||||
class="px-2 py-1.25 text-2sm text-sidebar-foreground/60"
|
||||
>
|
||||
{{ section.emptyMessage }}
|
||||
</div>
|
||||
<ShadcnUiSidebarMenu v-else>
|
||||
<SidebarNestedGroup
|
||||
v-model="section.items"
|
||||
:item-id="null"
|
||||
@create="handleCreate"
|
||||
@duplicate="handleDuplicate"
|
||||
@update-location="handleLocationUpdate"
|
||||
@delete="handleDelete"
|
||||
@update-location="section.onUpdateLocation"
|
||||
/>
|
||||
</ShadcnUiSidebarMenu>
|
||||
</ShadcnUiSidebarGroupContent>
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ import SidebarItem from "./SidebarItem.vue"
|
|||
import {
|
||||
SIDEBAR_ITEM_PLACEHOLDER_ID,
|
||||
type SidebarItem as Item,
|
||||
type SidebarItemAction,
|
||||
} from "./sidebar"
|
||||
import {
|
||||
at,
|
||||
menuItem,
|
||||
mountUnderSidebarProvider,
|
||||
renderedIconNames,
|
||||
t,
|
||||
} from "./test-helpers"
|
||||
|
||||
interface CapturedDragOptions {
|
||||
|
|
@ -67,15 +67,28 @@ function makeItem(overrides: Partial<Item> = {}): Item {
|
|||
id: "doc-1",
|
||||
name: "Runbook",
|
||||
url: "/acme/runbook-doc-1",
|
||||
partOfDocumentTree: true,
|
||||
acceptsChildren: true,
|
||||
icon: "lucide:file",
|
||||
active: false,
|
||||
draggable: true,
|
||||
actions: [],
|
||||
children: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeAction(
|
||||
overrides: Partial<SidebarItemAction> = {},
|
||||
): SidebarItemAction {
|
||||
return {
|
||||
id: "duplicate-page",
|
||||
name: "Duplicate Page",
|
||||
icon: "mingcute:copy-2-line",
|
||||
fn: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountItem(item: Item, props: Record<string, unknown> = {}) {
|
||||
const wrapper = await mountUnderSidebarProvider(SidebarItem, {
|
||||
props: { parentId: null, item: item, wrapper: null, ...props },
|
||||
|
|
@ -147,6 +160,110 @@ describe("<SidebarItem>", { concurrent: false }, () => {
|
|||
expect(renderedIconNames(wrapper)).toContain("lucide:book")
|
||||
})
|
||||
|
||||
it("shows a coloured dot in place of an icon", async ({ expect }) => {
|
||||
const { wrapper } = await mountItem(
|
||||
makeItem({ icon: null, dotColor: "#22c55e" }),
|
||||
)
|
||||
|
||||
expect(
|
||||
at(wrapper.findAll("[data-sidebar='menu-action']"), 0)
|
||||
.get("span")
|
||||
.attributes("style"),
|
||||
).toContain("background-color: #22c55e")
|
||||
expect(renderedIconNames(wrapper)).not.toContain("lucide:file")
|
||||
})
|
||||
|
||||
it("opens a dotted row's collapse from its own chevron", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { wrapper, item } = await mountItem(
|
||||
makeItem({ icon: null, dotColor: "#22c55e", url: null }),
|
||||
)
|
||||
|
||||
await at(wrapper.findAll("[data-sidebar='menu-action']"), 1).trigger(
|
||||
"click",
|
||||
)
|
||||
|
||||
expect(item.emitted("toggle-collapse")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("toggles the collapse when a row with nowhere to go is pressed", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { wrapper, item } = await mountItem(makeItem({ url: null }))
|
||||
|
||||
await wrapper.get("[data-slot='sidebar-menu-button']").trigger("click")
|
||||
|
||||
expect(item.emitted("toggle-collapse")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("leaves a linked row's collapse alone when it is pressed", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { wrapper, item } = await mountItem(makeItem())
|
||||
|
||||
await wrapper.get("[data-slot='sidebar-menu-button']").trigger("click")
|
||||
|
||||
expect(item.emitted("toggle-collapse")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("drops the collapse chevron from a row with no child list", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { wrapper } = await mountItem(
|
||||
makeItem({ children: null, actions: [makeAction()] }),
|
||||
)
|
||||
|
||||
// the icon and the options trigger remain; the chevron does not
|
||||
expect(wrapper.findAll("[data-sidebar='menu-action']")).toHaveLength(2)
|
||||
expect(renderedIconNames(wrapper)).not.toContain("lucide:chevron-right")
|
||||
})
|
||||
|
||||
// the hover fade is pure css, which happy-dom loads none of, so the
|
||||
// marker class is the only trace a test can read: the leading visual is
|
||||
// faded out on hover only where a chevron takes its place
|
||||
it("fades the leading icon out on hover when it has a chevron", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { wrapper } = await mountItem(makeItem())
|
||||
|
||||
expect(
|
||||
at(wrapper.findAll("[data-sidebar='menu-action']"), 0).classes(),
|
||||
).toContain("hide-on-parent-hover")
|
||||
})
|
||||
|
||||
it("keeps the leading icon on hover when nothing replaces it", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { wrapper } = await mountItem(makeItem({ children: null }))
|
||||
|
||||
expect(
|
||||
at(wrapper.findAll("[data-sidebar='menu-action']"), 0).classes(),
|
||||
).not.toContain("hide-on-parent-hover")
|
||||
})
|
||||
|
||||
// the hover swap only runs on a row marked with the drag state, so a row
|
||||
// with an actions menu needs the mark even when it has no chevron
|
||||
it("keeps a childless row with an actions menu in the hover swap", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { item } = await mountItem(
|
||||
makeItem({ children: null, actions: [makeAction()] }),
|
||||
)
|
||||
|
||||
expect(item.attributes("data-dragging-global")).toBe("false")
|
||||
})
|
||||
|
||||
it("leaves a row with neither chevron nor menu out of the hover swap", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { item } = await mountItem(
|
||||
makeItem({ children: null, actions: [], url: null }),
|
||||
)
|
||||
|
||||
expect(item.attributes("data-dragging-global")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("shows the unread count when the item carries one", async ({ expect }) => {
|
||||
const { wrapper } = await mountItem(makeItem({ url: null, count: 3 }))
|
||||
|
||||
|
|
@ -230,43 +347,47 @@ describe("<SidebarItem>", { concurrent: false }, () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("hides the options menu on items outside the document tree", async ({
|
||||
it("hides the options menu on a row carrying no actions", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { wrapper } = await mountItem(
|
||||
makeItem({ partOfDocumentTree: false, url: null }),
|
||||
)
|
||||
const { wrapper } = await mountItem(makeItem({ actions: [], url: null }))
|
||||
|
||||
expect(wrapper.find("[data-slot='dropdown-menu-trigger']").exists()).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
label: "sidebar.item-dropdown-menu-buttons.duplicate-page",
|
||||
event: "duplicate",
|
||||
},
|
||||
{
|
||||
label: "sidebar.item-dropdown-menu-buttons.add-sub-page",
|
||||
event: "create",
|
||||
},
|
||||
{
|
||||
label: "sidebar.item-dropdown-menu-buttons.delete-page",
|
||||
event: "delete",
|
||||
},
|
||||
] as const)(
|
||||
"asks to $event the page from the options menu",
|
||||
async ({ label, event }, { expect }) => {
|
||||
const { wrapper, item } = await mountItem(makeItem())
|
||||
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
|
||||
it("lists the actions the item carries, in order", async ({ expect }) => {
|
||||
const { wrapper } = await mountItem(
|
||||
makeItem({
|
||||
actions: [
|
||||
makeAction({ id: "a", name: "First", icon: "lucide:trash-2" }),
|
||||
makeAction({ id: "b", name: "Second", icon: "lucide:eye-off" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
|
||||
|
||||
menuItem(t(label)).click()
|
||||
await nextTick()
|
||||
const rows = Array.from(
|
||||
document.body.querySelectorAll("[role^='menuitem']"),
|
||||
).map((el) => el.textContent.trim())
|
||||
|
||||
expect(item.emitted(event)).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
expect(rows).toEqual(["First", "Second"])
|
||||
expect(renderedIconNames(wrapper)).not.toContain("lucide:eye-off")
|
||||
})
|
||||
|
||||
it("runs an action when its menu row is pressed", async ({ expect }) => {
|
||||
const fn = vi.fn()
|
||||
const { wrapper } = await mountItem(
|
||||
makeItem({ actions: [makeAction({ name: "Duplicate Page", fn: fn })] }),
|
||||
)
|
||||
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
|
||||
|
||||
menuItem("Duplicate Page").click()
|
||||
await nextTick()
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("does not duplicate itself as a drag ghost while at rest", async ({
|
||||
expect,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { SIDEBAR_ITEM_PLACEHOLDER_ID, type SidebarItem } from "./sidebar"
|
||||
import {
|
||||
SIDEBAR_ITEM_PLACEHOLDER_ID,
|
||||
type SidebarItem,
|
||||
type SidebarItemAction,
|
||||
} from "./sidebar"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -13,7 +17,7 @@ const props = defineProps<{
|
|||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle-collapse" | "delete" | "create" | "duplicate"): void
|
||||
(e: "toggle-collapse" | "create"): void
|
||||
(
|
||||
e: "update-location",
|
||||
data: {
|
||||
|
|
@ -52,17 +56,43 @@ const {
|
|||
minDistance: 5,
|
||||
},
|
||||
)
|
||||
|
||||
// hovering a row swaps its icon or dot for the collapse chevron and fades
|
||||
// the actions menu in. Only a row with a child list to open has the first,
|
||||
// and only one that can hold children has the second; a row with neither
|
||||
// takes no part in the swap.
|
||||
const collapseChevron = computed(
|
||||
() => !!(props.item.icon || props.item.dotColor) && !!props.item.children,
|
||||
)
|
||||
const actionsMenu = computed(() => props.item.actions.length > 0)
|
||||
const swapsOnHover = computed(() => collapseChevron.value || actionsMenu.value)
|
||||
|
||||
function handleClick() {
|
||||
if (props.item.id === SIDEBAR_ITEM_PLACEHOLDER_ID) {
|
||||
emit("create")
|
||||
return
|
||||
}
|
||||
|
||||
if (props.item.onClick) {
|
||||
void props.item.onClick()
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.item.url && collapseChevron.value) {
|
||||
emit("toggle-collapse")
|
||||
}
|
||||
}
|
||||
|
||||
function runAction(action: SidebarItemAction) {
|
||||
void action.fn()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="sidebar-item"
|
||||
:data-dragged-on="isItemDraggedOn ? '' : undefined"
|
||||
:data-dragging-global="
|
||||
props.item.draggable && props.item.partOfDocumentTree
|
||||
? isDraggingGlobal
|
||||
: undefined
|
||||
"
|
||||
:data-dragging-global="swapsOnHover ? isDraggingGlobal : undefined"
|
||||
:data-ghost="props.ghost ? '' : undefined"
|
||||
:class="cn('group/sidebar-item', props.class)"
|
||||
>
|
||||
|
|
@ -97,18 +127,23 @@ const {
|
|||
/>
|
||||
</div>
|
||||
<ShadcnUiSidebarMenuAction
|
||||
v-if="props.item.icon"
|
||||
v-if="props.item.icon || props.item.dotColor"
|
||||
:class="[
|
||||
'hide-on-parent-hover pointer-events-auto',
|
||||
'opacity-100 transition-opacity duration-50',
|
||||
collapseChevron ? 'hide-on-parent-hover' : undefined,
|
||||
'pointer-events-auto opacity-100 transition-opacity duration-50',
|
||||
]"
|
||||
side="left"
|
||||
:disable-direct-interaction="!props.item.partOfDocumentTree"
|
||||
:disable-direct-interaction="!collapseChevron"
|
||||
>
|
||||
<Icon :name="props.item.icon" />
|
||||
<Icon v-if="props.item.icon" :name="props.item.icon" />
|
||||
<span
|
||||
v-else
|
||||
class="size-2.5 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: props.item.dotColor }"
|
||||
/>
|
||||
</ShadcnUiSidebarMenuAction>
|
||||
<ShadcnUiSidebarMenuAction
|
||||
v-if="props.item.icon && props.item.partOfDocumentTree"
|
||||
v-if="collapseChevron"
|
||||
class="show-on-parent-hover pointer-events-none opacity-0 transition-opacity duration-50"
|
||||
side="left"
|
||||
@click="emit('toggle-collapse')"
|
||||
|
|
@ -148,11 +183,7 @@ const {
|
|||
'group-data-ghost/sidebar-item:data-[active=true]:bg-sidebar-accent group-data-ghost/sidebar-item:data-[active=true]:text-sidebar-accent-foreground',
|
||||
)
|
||||
"
|
||||
@click="
|
||||
props.item.id === SIDEBAR_ITEM_PLACEHOLDER_ID
|
||||
? emit('create')
|
||||
: props.item.onClick?.()
|
||||
"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div v-if="!props.item.url" class="min-w-0 cursor-pointer">
|
||||
<div class="flex w-full items-center justify-between gap-1">
|
||||
|
|
@ -191,12 +222,7 @@ const {
|
|||
<span>{{ props.item.name }}</span>
|
||||
</NuxtLink>
|
||||
</ShadcnUiSidebarMenuButton>
|
||||
<ShadcnUiDropdownMenu
|
||||
v-if="
|
||||
props.item.id !== SIDEBAR_ITEM_PLACEHOLDER_ID &&
|
||||
props.item.partOfDocumentTree
|
||||
"
|
||||
>
|
||||
<ShadcnUiDropdownMenu v-if="actionsMenu">
|
||||
<ShadcnUiDropdownMenuTrigger as-child>
|
||||
<ShadcnUiSidebarMenuAction class="show-on-parent-hover" show-on-hover>
|
||||
<Icon name="lucide:ellipsis" />
|
||||
|
|
@ -215,23 +241,13 @@ const {
|
|||
loop
|
||||
inside-sheet
|
||||
>
|
||||
<ShadcnUiDropdownMenuItem @click="emit('duplicate')">
|
||||
<Icon name="mingcute:copy-2-line" />
|
||||
<span>
|
||||
{{ $t("sidebar.item-dropdown-menu-buttons.duplicate-page") }}
|
||||
</span>
|
||||
</ShadcnUiDropdownMenuItem>
|
||||
<ShadcnUiDropdownMenuItem @click="emit('create')">
|
||||
<Icon name="lucide:file-plus" />
|
||||
<span>
|
||||
{{ $t("sidebar.item-dropdown-menu-buttons.add-sub-page") }}
|
||||
</span>
|
||||
</ShadcnUiDropdownMenuItem>
|
||||
<ShadcnUiDropdownMenuItem @click="emit('delete')">
|
||||
<Icon name="lucide:trash-2" />
|
||||
<span>
|
||||
{{ $t("sidebar.item-dropdown-menu-buttons.delete-page") }}
|
||||
</span>
|
||||
<ShadcnUiDropdownMenuItem
|
||||
v-for="action in props.item.actions"
|
||||
:key="action.id"
|
||||
@click="runAction(action)"
|
||||
>
|
||||
<Icon :name="action.icon" />
|
||||
<span>{{ action.name }}</span>
|
||||
</ShadcnUiDropdownMenuItem>
|
||||
</ShadcnUiDropdownMenuContent>
|
||||
</ShadcnUiDropdownMenu>
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ function makeItem(id: string, overrides: Partial<Item> = {}): Item {
|
|||
id: id,
|
||||
name: id,
|
||||
url: `/acme/${id}`,
|
||||
partOfDocumentTree: true,
|
||||
acceptsChildren: true,
|
||||
icon: "lucide:file",
|
||||
active: false,
|
||||
draggable: true,
|
||||
actions: [],
|
||||
children: [],
|
||||
...overrides,
|
||||
}
|
||||
|
|
@ -31,10 +32,11 @@ function placeholder(): Item {
|
|||
id: SIDEBAR_ITEM_PLACEHOLDER_ID,
|
||||
name: "Add a page",
|
||||
url: null,
|
||||
partOfDocumentTree: true,
|
||||
acceptsChildren: false,
|
||||
icon: null,
|
||||
active: false,
|
||||
draggable: false,
|
||||
actions: [],
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
|
@ -145,24 +147,6 @@ describe("<SidebarNestedGroup>", { concurrent: false }, () => {
|
|||
expect(wrapper.findComponent(SidebarItem).props("open")).toBe(false)
|
||||
})
|
||||
|
||||
it("reports a deletion with the row's own id", async ({ expect }) => {
|
||||
const { wrapper, group } = await mountGroup([makeItem("a")])
|
||||
|
||||
emitFrom(wrapper, "SidebarItem", "delete")
|
||||
await nextTick()
|
||||
|
||||
expect(group.emitted("delete")).toEqual([[{ id: "a" }]])
|
||||
})
|
||||
|
||||
it("reports a duplication with the row's own id", async ({ expect }) => {
|
||||
const { wrapper, group } = await mountGroup([makeItem("a")])
|
||||
|
||||
emitFrom(wrapper, "SidebarItem", "duplicate")
|
||||
await nextTick()
|
||||
|
||||
expect(group.emitted("duplicate")).toEqual([[{ id: "a" }]])
|
||||
})
|
||||
|
||||
it("makes a creation from a normal row a child of that row", async ({
|
||||
expect,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import {
|
|||
SIDEBAR_ITEM_PLACEHOLDER_ID,
|
||||
type SidebarItem,
|
||||
type SidebarItemCreate,
|
||||
type SidebarItemDelete,
|
||||
type SidebarItemDuplicate,
|
||||
type SidebarItemLocationUpdate,
|
||||
} from "./sidebar"
|
||||
|
||||
|
|
@ -14,8 +12,6 @@ const props = defineProps<{
|
|||
const emit = defineEmits<{
|
||||
(e: "create", data: SidebarItemCreate): void
|
||||
(e: "update-location", data: SidebarItemLocationUpdate): void
|
||||
(e: "delete", data: SidebarItemDelete): void
|
||||
(e: "duplicate", data: SidebarItemDuplicate): void
|
||||
}>()
|
||||
const items = defineModel<SidebarItem[]>()
|
||||
|
||||
|
|
@ -94,16 +90,6 @@ function toggleCollapseOpen(id: string) {
|
|||
:active="item.active"
|
||||
:open="collapseOpen[item.id] === 1"
|
||||
@toggle-collapse="toggleCollapseOpen(item.id)"
|
||||
@delete="
|
||||
emit('delete', {
|
||||
id: item.id,
|
||||
})
|
||||
"
|
||||
@duplicate="
|
||||
emit('duplicate', {
|
||||
id: item.id,
|
||||
})
|
||||
"
|
||||
@create="
|
||||
emit('create', {
|
||||
parentId:
|
||||
|
|
@ -136,8 +122,6 @@ function toggleCollapseOpen(id: string) {
|
|||
v-model="item.children"
|
||||
:item-id="item.id"
|
||||
@update-location="(data) => emit('update-location', data)"
|
||||
@duplicate="(data) => emit('duplicate', data)"
|
||||
@delete="(data) => emit('delete', data)"
|
||||
@create="(data) => emit('create', data)"
|
||||
/>
|
||||
</ShadcnUiSidebarMenuSub>
|
||||
|
|
|
|||
133
web/app/components/SidebarTagVisibility.nuxt.test.ts
Normal file
133
web/app/components/SidebarTagVisibility.nuxt.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { beforeEach, describe, it } from "vitest"
|
||||
import SidebarTagVisibility from "./SidebarTagVisibility.vue"
|
||||
import {
|
||||
clearTeleportedOverlays,
|
||||
menuItem,
|
||||
mountUnderSidebarProvider,
|
||||
t,
|
||||
} from "./test-helpers"
|
||||
|
||||
function makeTag(id: string, tagName: string, hidden: boolean, color: string) {
|
||||
return { id: id, tagName: tagName, color: color, hidden: hidden }
|
||||
}
|
||||
|
||||
function mountControl(tags: ReturnType<typeof makeTag>[]) {
|
||||
return mountUnderSidebarProvider(SidebarTagVisibility, { props: { tags } })
|
||||
}
|
||||
|
||||
async function openControl(tags: ReturnType<typeof makeTag>[]) {
|
||||
const wrapper = await mountControl(tags)
|
||||
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
|
||||
|
||||
return { wrapper, control: wrapper.findComponent(SidebarTagVisibility) }
|
||||
}
|
||||
|
||||
// the menu is teleported into the shared <body> and reka-ui reuses its
|
||||
// generated ids across mounts, so a leftover would answer the next lookup
|
||||
describe("<SidebarTagVisibility>", { concurrent: false }, () => {
|
||||
beforeEach(clearTeleportedOverlays)
|
||||
|
||||
it("names the control for screen readers", async ({ expect }) => {
|
||||
const wrapper = await mountControl([])
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
t("sidebar.sections.tags.visibility-action-title"),
|
||||
)
|
||||
})
|
||||
|
||||
it("lists every tag, hidden ones included", async ({ expect }) => {
|
||||
await openControl([
|
||||
makeTag("a", "Production", false, "#22c55e"),
|
||||
makeTag("b", "Staging", true, "#f97316"),
|
||||
])
|
||||
|
||||
const rows = Array.from(
|
||||
document.body.querySelectorAll("[role^='menuitem']"),
|
||||
).map((el) => el.textContent.trim())
|
||||
|
||||
expect(rows).toEqual(["Production", "Staging"])
|
||||
})
|
||||
|
||||
it("shows each tag's own colour beside its name", async ({ expect }) => {
|
||||
await openControl([
|
||||
makeTag("a", "Production", false, "#22c55e"),
|
||||
makeTag("b", "Staging", true, "#f97316"),
|
||||
])
|
||||
|
||||
const dots = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>(
|
||||
"[role^='menuitem'] span[style]",
|
||||
),
|
||||
).map((el) => el.getAttribute("style"))
|
||||
|
||||
expect(dots).toEqual([
|
||||
expect.stringContaining("#22c55e"),
|
||||
expect.stringContaining("#f97316"),
|
||||
])
|
||||
})
|
||||
|
||||
it("ticks a visible tag and leaves a hidden one unticked", async ({
|
||||
expect,
|
||||
}) => {
|
||||
await openControl([
|
||||
makeTag("a", "Production", false, "#22c55e"),
|
||||
makeTag("b", "Staging", true, "#f97316"),
|
||||
])
|
||||
|
||||
// the tick is the menu item's own active marker, which renders after
|
||||
// the row's content rather than in a gutter before it
|
||||
const ticks = Array.from(
|
||||
document.body.querySelectorAll("[role^='menuitem']"),
|
||||
).map((el) =>
|
||||
Array.from(el.querySelectorAll(".iconify")).some((icon) =>
|
||||
icon.classList.contains("i-lucide:check"),
|
||||
),
|
||||
)
|
||||
|
||||
expect(ticks).toEqual([true, false])
|
||||
})
|
||||
|
||||
it("leaves every row usable, including the only visible tag", async ({
|
||||
expect,
|
||||
}) => {
|
||||
await openControl([
|
||||
makeTag("a", "Production", false, "#22c55e"),
|
||||
makeTag("b", "Staging", true, "#f97316"),
|
||||
])
|
||||
|
||||
const states = Array.from(
|
||||
document.body.querySelectorAll("[role^='menuitem']"),
|
||||
).map((el) => el.getAttribute("data-disabled"))
|
||||
|
||||
expect(states).toEqual([null, null])
|
||||
})
|
||||
|
||||
it("reports the tag whose row was pressed", async ({ expect }) => {
|
||||
const hidden = makeTag("b", "Staging", true, "#f97316")
|
||||
const { control } = await openControl([
|
||||
makeTag("a", "Production", false, "#22c55e"),
|
||||
hidden,
|
||||
])
|
||||
|
||||
menuItem("Staging").click()
|
||||
await nextTick()
|
||||
|
||||
expect(control.emitted("toggle")).toEqual([[hidden]])
|
||||
})
|
||||
|
||||
it("keeps the menu open so several tags can be toggled", async ({
|
||||
expect,
|
||||
}) => {
|
||||
await openControl([
|
||||
makeTag("a", "Production", false, "#22c55e"),
|
||||
makeTag("b", "Staging", true, "#f97316"),
|
||||
])
|
||||
|
||||
menuItem("Staging").click()
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
document.body.querySelectorAll("[role^='menuitem']").length,
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
43
web/app/components/SidebarTagVisibility.vue
Normal file
43
web/app/components/SidebarTagVisibility.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts" setup>
|
||||
import type { TagTreeElement } from "~/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
tags: TagTreeElement[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle", tag: TagTreeElement): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ShadcnUiDropdownMenu>
|
||||
<ShadcnUiDropdownMenuTrigger as-child>
|
||||
<ShadcnUiSidebarGroupAction>
|
||||
<Icon name="lucide:list-filter" />
|
||||
<span class="sr-only">
|
||||
{{ $t("sidebar.sections.tags.visibility-action-title") }}
|
||||
</span>
|
||||
</ShadcnUiSidebarGroupAction>
|
||||
</ShadcnUiDropdownMenuTrigger>
|
||||
<ShadcnUiDropdownMenuContent side="bottom" align="start" loop inside-sheet>
|
||||
<ShadcnUiDropdownMenuItem
|
||||
v-for="tag in props.tags"
|
||||
:key="tag.id"
|
||||
:value="tag.id"
|
||||
:active="!tag.hidden"
|
||||
@select="(event: Event) => event.preventDefault()"
|
||||
@click="emit('toggle', tag)"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
class="size-2.5 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
/>
|
||||
<span class="min-w-0 truncate whitespace-nowrap">
|
||||
{{ tag.tagName }}
|
||||
</span>
|
||||
</div>
|
||||
</ShadcnUiDropdownMenuItem>
|
||||
</ShadcnUiDropdownMenuContent>
|
||||
</ShadcnUiDropdownMenu>
|
||||
</template>
|
||||
|
|
@ -2,7 +2,10 @@ import { mountSuspended } from "@nuxt/test-utils/runtime"
|
|||
import { beforeEach, describe, it } from "vitest"
|
||||
import ColorSelect from "./ColorSelect.vue"
|
||||
import { at } from "~/components/test-helpers"
|
||||
import { stubChartColorContext, stubThresholdPalette } from "../test-helpers"
|
||||
import {
|
||||
stubThemeColorContext,
|
||||
stubSelectableColors,
|
||||
} from "./test-helpers/theme"
|
||||
|
||||
let palette: string[] = []
|
||||
|
||||
|
|
@ -14,8 +17,8 @@ function mountSelect(color?: string) {
|
|||
// these tests cannot interleave
|
||||
describe("<ColorSelect>", { concurrent: false }, () => {
|
||||
beforeEach(() => {
|
||||
stubChartColorContext()
|
||||
palette = stubThresholdPalette()
|
||||
stubThemeColorContext()
|
||||
palette = stubSelectableColors()
|
||||
})
|
||||
|
||||
it("offers every colour of the threshold palette", async ({ expect }) => {
|
||||
|
|
@ -9,7 +9,7 @@ const st = chartStyles()
|
|||
<template>
|
||||
<div class="grid w-fit grid-cols-4 gap-0.25">
|
||||
<ShadcnUiButton
|
||||
v-for="c in st.thresholdColors.available"
|
||||
v-for="c in st.selectableColors.available"
|
||||
:key="c"
|
||||
size="icon"
|
||||
variant="outline-transparent"
|
||||
359
web/app/components/editor/DocumentTagList.nuxt.test.ts
Normal file
359
web/app/components/editor/DocumentTagList.nuxt.test.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import { afterEach, beforeEach, describe, it } from "vitest"
|
||||
import { enableAutoUnmount } from "@vue/test-utils"
|
||||
import {
|
||||
clearQueryCache,
|
||||
disposeMockEndpoints,
|
||||
makeXid,
|
||||
mockEndpoint,
|
||||
} from "~/composables/api/test-helpers"
|
||||
import DocumentTagList from "./DocumentTagList.vue"
|
||||
import TagPill from "./TagPill.vue"
|
||||
import ColorSelect from "./ColorSelect.vue"
|
||||
import {
|
||||
stubThemeColorContext,
|
||||
stubSelectableColors,
|
||||
} from "./test-helpers/theme"
|
||||
import {
|
||||
clearTeleportedOverlays,
|
||||
mountUnderSidebarProvider,
|
||||
renderedIconNames,
|
||||
settleMutations,
|
||||
t,
|
||||
} from "../test-helpers"
|
||||
|
||||
let palette: string[] = []
|
||||
|
||||
const DOC_ID = makeXid("doc")
|
||||
const OTHER_DOC_ID = makeXid("odc")
|
||||
const TAG_A = makeXid("taga")
|
||||
const TAG_B = makeXid("tagb")
|
||||
|
||||
type TestDocument = ReturnType<typeof makeDoc>
|
||||
|
||||
function makeDoc(id: string) {
|
||||
return {
|
||||
id: id,
|
||||
documentName: "Runbook",
|
||||
icon: "lucide:file",
|
||||
protected: false,
|
||||
children: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeTag(
|
||||
id: string,
|
||||
tagName: string,
|
||||
color: string,
|
||||
documents: TestDocument[] = [],
|
||||
) {
|
||||
return {
|
||||
id: id,
|
||||
tagName: tagName,
|
||||
color: color,
|
||||
hidden: false,
|
||||
documents: documents,
|
||||
}
|
||||
}
|
||||
|
||||
// the tags a document carries and the ones it does not both come from one
|
||||
// tree, so each test states the whole thing
|
||||
function stubTags(tags: unknown[]) {
|
||||
return mockEndpoint("GET", "/api/tags/tree", () => tags)
|
||||
}
|
||||
|
||||
async function mountTags() {
|
||||
const wrapper = await mountUnderSidebarProvider(DocumentTagList)
|
||||
await settleMutations()
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function openPicker(wrapper: Awaited<ReturnType<typeof mountTags>>) {
|
||||
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
|
||||
await settleMutations()
|
||||
}
|
||||
|
||||
// the picker is portalled out of the wrapper, so everything inside it is
|
||||
// reached through the document rather than through the mount
|
||||
function pickerItems() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>(
|
||||
"[data-slot='dropdown-menu-content'] [data-slot='dropdown-menu-item']",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function pickerRows() {
|
||||
return pickerItems().map((el) => el.textContent.trim())
|
||||
}
|
||||
|
||||
// the only button in the menu is the swatch opening the colour picker
|
||||
function colorTrigger(): HTMLElement {
|
||||
const button = document.body.querySelector<HTMLElement>(
|
||||
"[data-slot='dropdown-menu-content'] button",
|
||||
)
|
||||
if (!button) {
|
||||
throw new Error("the picker has no colour trigger")
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
function pickerInput(): HTMLInputElement {
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
"[data-slot='dropdown-menu-content'] input",
|
||||
)
|
||||
if (!input) {
|
||||
throw new Error("the picker has no search input")
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
async function search(text: string) {
|
||||
const input = pickerInput()
|
||||
input.value = text
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
// the picker is teleported into the shared <body> and the editor store is
|
||||
// an app-wide singleton every mount in the file shares
|
||||
describe("<DocumentTagList>", { concurrent: false }, () => {
|
||||
// the picker keeps a portalled body alive past its test, which would
|
||||
// answer the next test's lookups
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
beforeEach(() => {
|
||||
clearQueryCache()
|
||||
clearTeleportedOverlays()
|
||||
// the picker reads its swatches off the root element and converts
|
||||
// them through a canvas, neither of which happy-dom provides
|
||||
stubThemeColorContext()
|
||||
palette = stubSelectableColors()
|
||||
useEditorStore().activeDocumentId = DOC_ID
|
||||
})
|
||||
|
||||
afterEach(disposeMockEndpoints)
|
||||
|
||||
it("labels the row", async ({ expect }) => {
|
||||
stubTags([])
|
||||
|
||||
const wrapper = await mountTags()
|
||||
|
||||
expect(wrapper.text()).toContain(t("editor.tags.label"))
|
||||
})
|
||||
|
||||
it("shows a pill for every tag the document carries", async ({ expect }) => {
|
||||
stubTags([
|
||||
makeTag(TAG_A, "Production", "#1a9e4a", [makeDoc(DOC_ID)]),
|
||||
makeTag(TAG_B, "Staging", "#e8760c", [makeDoc(OTHER_DOC_ID)]),
|
||||
])
|
||||
|
||||
const wrapper = await mountTags()
|
||||
|
||||
expect(wrapper.findAllComponents(TagPill).map((p) => p.text())).toEqual([
|
||||
"Production",
|
||||
])
|
||||
})
|
||||
|
||||
it("offers a plus button while the document carries none", async ({
|
||||
expect,
|
||||
}) => {
|
||||
stubTags([makeTag(TAG_A, "Production", "#1a9e4a")])
|
||||
|
||||
const wrapper = await mountTags()
|
||||
|
||||
expect(wrapper.findAllComponents(TagPill)).toHaveLength(0)
|
||||
expect(renderedIconNames(wrapper)).toContain("lucide:plus")
|
||||
})
|
||||
|
||||
it("collapses past the fourth tag into a counter", async ({ expect }) => {
|
||||
stubTags(
|
||||
["a", "b", "c", "d", "e"].map((n) =>
|
||||
makeTag(makeXid(`t${n}`), `Tag ${n}`, "#1a9e4a", [makeDoc(DOC_ID)]),
|
||||
),
|
||||
)
|
||||
|
||||
const wrapper = await mountTags()
|
||||
|
||||
expect(wrapper.findAllComponents(TagPill).map((p) => p.text())).toEqual([
|
||||
"Tag a",
|
||||
"Tag b",
|
||||
"Tag c",
|
||||
"Tag d",
|
||||
t("editor.tags.overflow", { count: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it("ticks the tags the document already carries", async ({ expect }) => {
|
||||
stubTags([
|
||||
makeTag(TAG_A, "Production", "#1a9e4a", [makeDoc(DOC_ID)]),
|
||||
makeTag(TAG_B, "Staging", "#e8760c"),
|
||||
])
|
||||
const wrapper = await mountTags()
|
||||
|
||||
await openPicker(wrapper)
|
||||
|
||||
const ticks = pickerItems().map(
|
||||
(el) => !!el.querySelector(".i-lucide\\:check"),
|
||||
)
|
||||
|
||||
expect(ticks).toEqual([true, false])
|
||||
})
|
||||
|
||||
it("attaches a tag the document does not carry", async ({ expect }) => {
|
||||
stubTags([makeTag(TAG_A, "Production", "#1a9e4a")])
|
||||
const calls = mockEndpoint(
|
||||
"POST",
|
||||
`/api/documents/${DOC_ID}/tags`,
|
||||
() => ({}),
|
||||
)
|
||||
const wrapper = await mountTags()
|
||||
await openPicker(wrapper)
|
||||
|
||||
pickerItems()[0]?.click()
|
||||
await settleMutations()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.body).toEqual({ tagId: TAG_A })
|
||||
})
|
||||
|
||||
it("keeps one trigger element across the first attachment", async ({
|
||||
expect,
|
||||
}) => {
|
||||
// the open popover anchors to the trigger element, so swapping the
|
||||
// plus button out for the pills strands the panel in the page corner
|
||||
const tree = [makeTag(TAG_A, "Production", "#1a9e4a")]
|
||||
stubTags(tree)
|
||||
mockEndpoint("POST", `/api/documents/${DOC_ID}/tags`, () => {
|
||||
tree[0] = makeTag(TAG_A, "Production", "#1a9e4a", [makeDoc(DOC_ID)])
|
||||
|
||||
return {}
|
||||
})
|
||||
const wrapper = await mountTags()
|
||||
await openPicker(wrapper)
|
||||
const trigger = wrapper.get("[data-slot='dropdown-menu-trigger']").element
|
||||
|
||||
pickerItems()[0]?.click()
|
||||
await settleMutations()
|
||||
|
||||
expect(wrapper.findAllComponents(TagPill)).toHaveLength(1)
|
||||
expect(wrapper.get("[data-slot='dropdown-menu-trigger']").element).toBe(
|
||||
trigger,
|
||||
)
|
||||
})
|
||||
|
||||
it("detaches a tag the document already carries", async ({ expect }) => {
|
||||
stubTags([makeTag(TAG_A, "Production", "#1a9e4a", [makeDoc(DOC_ID)])])
|
||||
const calls = mockEndpoint(
|
||||
"DELETE",
|
||||
`/api/documents/${DOC_ID}/tags/${TAG_A}`,
|
||||
() => ({}),
|
||||
)
|
||||
const wrapper = await mountTags()
|
||||
await openPicker(wrapper)
|
||||
|
||||
pickerItems()[0]?.click()
|
||||
await settleMutations()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("narrows the list to what was typed", async ({ expect }) => {
|
||||
stubTags([
|
||||
makeTag(TAG_A, "Production", "#1a9e4a"),
|
||||
makeTag(TAG_B, "Staging", "#e8760c"),
|
||||
])
|
||||
const wrapper = await mountTags()
|
||||
await openPicker(wrapper)
|
||||
|
||||
await search("stag")
|
||||
|
||||
expect(pickerRows().filter((row) => row.includes("Staging"))).toHaveLength(
|
||||
1,
|
||||
)
|
||||
expect(pickerRows().filter((row) => row.includes("Production"))).toEqual([])
|
||||
})
|
||||
|
||||
it("hints at creation while nothing is typed", async ({ expect }) => {
|
||||
stubTags([makeTag(TAG_A, "Production", "#1a9e4a")])
|
||||
const wrapper = await mountTags()
|
||||
|
||||
await openPicker(wrapper)
|
||||
|
||||
expect(
|
||||
document.body.querySelector("[data-slot='dropdown-menu-content']")
|
||||
?.textContent,
|
||||
).toContain(t("editor.tags.create-hint"))
|
||||
})
|
||||
|
||||
it("creates the typed tag and attaches it in one go", async ({ expect }) => {
|
||||
// core answers the refetch that follows a creation with the new tag,
|
||||
// which is what the assignment then looks itself up in
|
||||
const tree = [makeTag(TAG_A, "Production", "#1a9e4a")]
|
||||
stubTags(tree)
|
||||
const created = mockEndpoint("POST", "/api/tags", () => {
|
||||
tree.push(makeTag(TAG_B, "Rollout", "#000000"))
|
||||
|
||||
return { id: TAG_B }
|
||||
})
|
||||
const assigned = mockEndpoint(
|
||||
"POST",
|
||||
`/api/documents/${DOC_ID}/tags`,
|
||||
() => ({}),
|
||||
)
|
||||
const wrapper = await mountTags()
|
||||
await openPicker(wrapper)
|
||||
|
||||
await search("Rollout")
|
||||
|
||||
const createRow = pickerItems().find((el) =>
|
||||
el.textContent.includes(t("editor.tags.create")),
|
||||
)
|
||||
createRow?.click()
|
||||
await settleMutations()
|
||||
|
||||
// the stubbed canvas reports one flat colour whatever swatch is set
|
||||
expect(created[0]?.body).toEqual({
|
||||
tagName: "Rollout",
|
||||
color: "#000000",
|
||||
})
|
||||
expect(assigned[0]?.body).toEqual({ tagId: TAG_B })
|
||||
})
|
||||
|
||||
it("offers the shared colour picker on the create row", async ({
|
||||
expect,
|
||||
}) => {
|
||||
stubTags([makeTag(TAG_A, "Production", "#1a9e4a")])
|
||||
const wrapper = await mountTags()
|
||||
await openPicker(wrapper)
|
||||
|
||||
await search("Rollout")
|
||||
|
||||
const swatches = wrapper.findComponent(ColorSelect)
|
||||
expect(swatches.exists()).toBe(false)
|
||||
|
||||
colorTrigger().click()
|
||||
await nextTick()
|
||||
|
||||
// the swatch popover holds one button per selectable colour
|
||||
expect(
|
||||
document.body.querySelectorAll("[data-slot='popover-content'] button"),
|
||||
).toHaveLength(palette.length)
|
||||
})
|
||||
|
||||
it("offers no create row for a name that already exists", async ({
|
||||
expect,
|
||||
}) => {
|
||||
stubTags([makeTag(TAG_A, "Production", "#1a9e4a")])
|
||||
const wrapper = await mountTags()
|
||||
await openPicker(wrapper)
|
||||
|
||||
await search("production")
|
||||
|
||||
expect(
|
||||
pickerRows().filter((row) => row.includes(t("editor.tags.create"))),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
247
web/app/components/editor/DocumentTagList.vue
Normal file
247
web/app/components/editor/DocumentTagList.vue
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<script lang="ts" setup>
|
||||
import TagPill from "./TagPill.vue"
|
||||
import ColorSelect from "./ColorSelect.vue"
|
||||
import { chartStyles, colorToHex } from "~/assets/css"
|
||||
import { showToastMessage } from "../toast"
|
||||
|
||||
// beyond this the pills collapse into a "+N" counter rather than pushing
|
||||
// the rest of the metadata row off screen
|
||||
const MAX_VISIBLE_TAGS = 4
|
||||
|
||||
const { t } = useI18n({ useScope: "global" })
|
||||
const { fetchTagTree, createTag, assignDocumentTag, unassignDocumentTag } =
|
||||
useTagAPI()
|
||||
const editorStore = useEditorStore()
|
||||
const { isEditable } = useEditorMeta()
|
||||
|
||||
const open = ref(false)
|
||||
// a read only document still shows its pills, but the picker behind them
|
||||
// stays shut
|
||||
const pickerOpen = computed(() => isEditable.value && open.value)
|
||||
const query = ref("")
|
||||
// the picker paints its swatches from the theme, so a colour only resolves
|
||||
// once the popover is on screen
|
||||
const newColor = ref<string | undefined>(undefined)
|
||||
|
||||
const allTags = computed(() => fetchTagTree.data.value ?? [])
|
||||
|
||||
// a document carries a tag when it sits at the top of that tag's list; the
|
||||
// documents nested below are its own subtree and carry nothing themselves
|
||||
const documentTags = computed(() =>
|
||||
allTags.value.filter((tag) =>
|
||||
tag.documents?.some((doc) => doc.id === editorStore.activeDocumentId),
|
||||
),
|
||||
)
|
||||
|
||||
const visibleTags = computed(() =>
|
||||
documentTags.value.slice(0, MAX_VISIBLE_TAGS),
|
||||
)
|
||||
const overflowCount = computed(
|
||||
() => documentTags.value.length - visibleTags.value.length,
|
||||
)
|
||||
|
||||
const trimmedQuery = computed(() => query.value.trim())
|
||||
|
||||
const matchingTags = computed(() => {
|
||||
const q = trimmedQuery.value.toLowerCase()
|
||||
|
||||
return allTags.value.filter(
|
||||
(tag) => !q || tag.tagName.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
const showCreate = computed(() => {
|
||||
const q = trimmedQuery.value.toLowerCase()
|
||||
|
||||
return (
|
||||
!!q && !matchingTags.value.some((tag) => tag.tagName.toLowerCase() === q)
|
||||
)
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
void fetchTagTree.refresh()
|
||||
})
|
||||
|
||||
watch(pickerOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
newColor.value ??= chartStyles().selectableColors.default
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
query.value = ""
|
||||
})
|
||||
|
||||
function carriesTag(tagId: string): boolean {
|
||||
return documentTags.value.some((tag) => tag.id === tagId)
|
||||
}
|
||||
|
||||
async function toggleTag(tag: TagTreeElement) {
|
||||
const documentId = editorStore.activeDocumentId
|
||||
if (!documentId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (carriesTag(tag.id)) {
|
||||
await unassignDocumentTag.mutateAsync({
|
||||
documentId: documentId,
|
||||
tagId: tag.id,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await assignDocumentTag.mutateAsync({
|
||||
documentId: documentId,
|
||||
tagId: tag.id,
|
||||
})
|
||||
} catch {
|
||||
showToastMessage("error", t("editor.tags.errors.toggle-failed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndAssign() {
|
||||
const documentId = editorStore.activeDocumentId
|
||||
if (!documentId || !trimmedQuery.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createTag.mutateAsync({
|
||||
tagName: trimmedQuery.value,
|
||||
// the swatches resolve from theme variables, which are oklch; the
|
||||
// stored colour is hex so it survives a theme change
|
||||
color: colorToHex(
|
||||
newColor.value ?? chartStyles().selectableColors.default,
|
||||
),
|
||||
})
|
||||
|
||||
query.value = ""
|
||||
|
||||
await assignDocumentTag.mutateAsync({
|
||||
documentId: documentId,
|
||||
tagId: created.id,
|
||||
})
|
||||
} catch {
|
||||
showToastMessage("error", t("editor.tags.errors.create-failed"))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-sm font-medium text-muted-foreground">
|
||||
{{ $t("editor.tags.label") }}
|
||||
</span>
|
||||
<ShadcnUiDropdownMenu
|
||||
:open="pickerOpen"
|
||||
@update:open="(v: boolean) => (open = v)"
|
||||
>
|
||||
<ShadcnUiDropdownMenuTrigger as-child>
|
||||
<!--
|
||||
one element across both states: replacing the trigger leaves
|
||||
the menu anchored to the removed node, which drops the open
|
||||
panel into the top left corner
|
||||
-->
|
||||
<div
|
||||
:data-disabled="!isEditable ? '' : undefined"
|
||||
:title="$t('editor.tags.trigger-title')"
|
||||
class="flex cursor-pointer items-center gap-1.25 data-disabled:cursor-default"
|
||||
>
|
||||
<template v-if="documentTags.length">
|
||||
<TagPill
|
||||
v-for="tag in visibleTags"
|
||||
:key="tag.id"
|
||||
:name="tag.tagName"
|
||||
:color="tag.color"
|
||||
/>
|
||||
<TagPill
|
||||
v-if="overflowCount > 0"
|
||||
:name="$t('editor.tags.overflow', { count: overflowCount })"
|
||||
/>
|
||||
</template>
|
||||
<ShadcnUiAvatar v-else class="size-6 border">
|
||||
<ShadcnUiAvatarFallback>
|
||||
<Icon name="lucide:plus" class="size-3.5" />
|
||||
</ShadcnUiAvatarFallback>
|
||||
</ShadcnUiAvatar>
|
||||
</div>
|
||||
</ShadcnUiDropdownMenuTrigger>
|
||||
<ShadcnUiDropdownMenuContent side="bottom" align="start" class="w-63">
|
||||
<ShadcnUiInput
|
||||
v-model="query"
|
||||
:placeholder="$t('editor.tags.search-placeholder')"
|
||||
class="h-[1.775rem] border-none bg-muted px-2 text-2sm md:text-2sm"
|
||||
disable-focus-effect
|
||||
/>
|
||||
<ShadcnUiDropdownMenuSeparator />
|
||||
<template v-if="matchingTags.length">
|
||||
<div class="max-h-47.5 overflow-y-auto">
|
||||
<ShadcnUiDropdownMenuItem
|
||||
v-for="tag in matchingTags"
|
||||
:key="tag.id"
|
||||
:value="tag.id"
|
||||
:active="carriesTag(tag.id)"
|
||||
@select="(event: Event) => event.preventDefault()"
|
||||
@click="toggleTag(tag)"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
class="size-2.5 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
/>
|
||||
<span class="min-w-0 truncate whitespace-nowrap">
|
||||
{{ tag.tagName }}
|
||||
</span>
|
||||
</div>
|
||||
</ShadcnUiDropdownMenuItem>
|
||||
</div>
|
||||
<ShadcnUiDropdownMenuSeparator />
|
||||
</template>
|
||||
<div v-if="showCreate" class="flex items-stretch gap-1">
|
||||
<ShadcnUiDropdownMenuItem
|
||||
class="min-w-0 flex-1 py-0"
|
||||
@select="(event: Event) => event.preventDefault()"
|
||||
@click="createAndAssign"
|
||||
>
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t("editor.tags.create") }}
|
||||
</span>
|
||||
<TagPill
|
||||
v-if="newColor"
|
||||
:name="trimmedQuery"
|
||||
:color="newColor"
|
||||
class="max-w-35"
|
||||
/>
|
||||
</ShadcnUiDropdownMenuItem>
|
||||
<ShadcnUiPopover>
|
||||
<ShadcnUiPopoverTrigger as-child>
|
||||
<ShadcnUiButton
|
||||
size="icon"
|
||||
variant="outline-transparent"
|
||||
class="size-[1.775rem]!"
|
||||
:title="$t('editor.tags.color-title')"
|
||||
>
|
||||
<div
|
||||
class="size-3.5 rounded-full transition-colors"
|
||||
:style="{ backgroundColor: newColor }"
|
||||
/>
|
||||
</ShadcnUiButton>
|
||||
</ShadcnUiPopoverTrigger>
|
||||
<ShadcnUiPopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
class="w-fit min-w-0"
|
||||
>
|
||||
<ColorSelect v-model="newColor" />
|
||||
</ShadcnUiPopoverContent>
|
||||
</ShadcnUiPopover>
|
||||
</div>
|
||||
<div v-else class="px-2 py-1.25 text-xs text-muted-foreground">
|
||||
{{ $t("editor.tags.create-hint") }}
|
||||
</div>
|
||||
</ShadcnUiDropdownMenuContent>
|
||||
</ShadcnUiDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -17,6 +17,7 @@ import { renderCollaborationCaret } from "./collaboration"
|
|||
import IconStack, { type IconMetadata } from "./IconStack.vue"
|
||||
import HooksMenuContent from "./hooks/HookMenuContent.vue"
|
||||
import MaintainerList from "./MaintainerList.vue"
|
||||
import DocumentTagList from "./DocumentTagList.vue"
|
||||
import ReviewerList from "./ReviewerList.vue"
|
||||
import { prepareHookMetadata } from "./hooks"
|
||||
import { showToastMessage } from "../toast"
|
||||
|
|
@ -479,6 +480,7 @@ async function executeReviewableAction() {
|
|||
</div>
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<DocumentTagList />
|
||||
<ShadcnUiDropdownMenu
|
||||
:open="safeHookMenuOpen"
|
||||
@update:open="(v: boolean) => (hookMenuOpen = v)"
|
||||
|
|
|
|||
48
web/app/components/editor/TagPill.nuxt.test.ts
Normal file
48
web/app/components/editor/TagPill.nuxt.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it } from "vitest"
|
||||
import { mountSuspended } from "@nuxt/test-utils/runtime"
|
||||
import TagPill from "./TagPill.vue"
|
||||
|
||||
function mountPill(name: string, color?: string) {
|
||||
return mountSuspended(TagPill, { props: { name: name, color: color } })
|
||||
}
|
||||
|
||||
// the two modes are pure css, which happy-dom loads none of, so the custom
|
||||
// properties the variants read are the only trace a test can follow
|
||||
describe("<TagPill>", () => {
|
||||
it("shows the tag's name", async ({ expect }) => {
|
||||
const wrapper = await mountPill("Production", "#1a9e4a")
|
||||
|
||||
expect(wrapper.text()).toBe("Production")
|
||||
})
|
||||
|
||||
it("carries a light and a dark treatment of its colour", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const wrapper = await mountPill("Production", "#1a9e4a")
|
||||
const style = wrapper.get("span").attributes("style") ?? ""
|
||||
|
||||
expect(style).toContain(
|
||||
"--tag-bg: color-mix(in srgb, #1a9e4a 13%, transparent)",
|
||||
)
|
||||
expect(style).toContain("--tag-fg: color-mix(in srgb, #1a9e4a 80%, black)")
|
||||
expect(style).toContain(
|
||||
"--tag-dark-bg: color-mix(in srgb, #1a9e4a 18%, transparent)",
|
||||
)
|
||||
expect(style).toContain(
|
||||
"--tag-dark-border: color-mix(in srgb, #1a9e4a 40%, transparent)",
|
||||
)
|
||||
expect(style).toContain(
|
||||
"--tag-dark-fg: color-mix(in srgb, #1a9e4a 60%, white)",
|
||||
)
|
||||
})
|
||||
|
||||
it("falls back to the neutral treatment without a colour", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const wrapper = await mountPill("+2")
|
||||
const pill = wrapper.get("span")
|
||||
|
||||
expect(pill.attributes("style")).toBeUndefined()
|
||||
expect(pill.classes()).toContain("bg-muted")
|
||||
})
|
||||
})
|
||||
51
web/app/components/editor/TagPill.vue
Normal file
51
web/app/components/editor/TagPill.vue
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
// a pill without a colour is neutral: the counter standing in for the
|
||||
// tags that did not fit
|
||||
color?: string
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
// the two modes get different tints, so both are handed to css as custom
|
||||
// properties and the dark variant picks its own
|
||||
const treatment = computed(() =>
|
||||
props.color ? tagColorFor(props.color) : null,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
// note that the dark mode has a border, which means the
|
||||
// light mode has to account for that in its padding to keep
|
||||
// the pill the same size
|
||||
'flex max-w-45 items-center rounded-full px-1.75 py-0.5 text-xs leading-4 font-semibold dark:border dark:py-px',
|
||||
treatment
|
||||
? [
|
||||
'bg-(--tag-bg) text-(--tag-fg)',
|
||||
'dark:border-(--tag-dark-border) dark:bg-(--tag-dark-bg) dark:text-(--tag-dark-fg)',
|
||||
]
|
||||
: 'bg-muted text-muted-foreground dark:border-border',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
:style="
|
||||
treatment
|
||||
? {
|
||||
'--tag-bg': treatment.lightBg,
|
||||
'--tag-fg': treatment.lightFg,
|
||||
'--tag-dark-bg': treatment.darkBg,
|
||||
'--tag-dark-border': treatment.darkBorder,
|
||||
'--tag-dark-fg': treatment.darkFg,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<span class="truncate">{{ props.name }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
|
@ -6,7 +6,7 @@ import ConfigField from "../ConfigField.vue"
|
|||
import ThresholdInput from "./ThresholdInput.vue"
|
||||
import DataSourceSelect from "./DataSourceSelect.vue"
|
||||
import SimulationPresetSelect from "./SimulationPresetSelect.vue"
|
||||
import { stubChartColorContext, stubThresholdPalette } from "../test-helpers"
|
||||
import { stubChartColorContext, stubSelectableColors } from "../test-helpers"
|
||||
import {
|
||||
defaultMetricConfig,
|
||||
MetricSimulationPreset,
|
||||
|
|
@ -66,7 +66,7 @@ describe("<ConfigSidebar>", { concurrent: false }, () => {
|
|||
clearTeleportedOverlays()
|
||||
clearQueryCache()
|
||||
stubChartColorContext()
|
||||
stubThresholdPalette()
|
||||
stubSelectableColors()
|
||||
mockEndpoint("GET", "/api/data-sources", () => [])
|
||||
useEditorMeta().setEditable(true)
|
||||
useEditorStore().setReviewableDiffActive(false)
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ function addThreshold() {
|
|||
{
|
||||
value: undefined,
|
||||
label: undefined,
|
||||
color: chartStyles().thresholdColors.default,
|
||||
color: chartStyles().selectableColors.default,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { mountSuspended } from "@nuxt/test-utils/runtime"
|
|||
import type { VueWrapper } from "@vue/test-utils"
|
||||
import { beforeEach, describe, it } from "vitest"
|
||||
import ThresholdInput from "./ThresholdInput.vue"
|
||||
import { stubChartColorContext, stubThresholdPalette } from "../test-helpers"
|
||||
import { stubChartColorContext, stubSelectableColors } from "../test-helpers"
|
||||
import { at, clearTeleportedOverlays, t } from "~/components/test-helpers"
|
||||
import { DiffStatus } from "~/components/editor/diff/position-map"
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ describe("<ThresholdInput>", { concurrent: false }, () => {
|
|||
beforeEach(() => {
|
||||
clearTeleportedOverlays()
|
||||
stubChartColorContext()
|
||||
stubThresholdPalette()
|
||||
stubSelectableColors()
|
||||
useEditorMeta().setEditable(true)
|
||||
useEditorStore().setReviewableDiffActive(false)
|
||||
})
|
||||
|
|
@ -158,7 +158,7 @@ describe("<ThresholdInput>", { concurrent: false }, () => {
|
|||
)
|
||||
|
||||
it("picks a colour from the palette popover", async ({ expect }) => {
|
||||
const palette = stubThresholdPalette()
|
||||
const palette = stubSelectableColors()
|
||||
const wrapper = await mountThreshold()
|
||||
await at(wrapper.findAll("button"), 0).trigger("click")
|
||||
await nextTick()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts" setup>
|
||||
import { cn } from "~/lib/utils"
|
||||
import ColorSelect from "./ColorSelect.vue"
|
||||
import ColorSelect from "../../../ColorSelect.vue"
|
||||
import { DiffStatus } from "~/components/editor/diff/position-map"
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { MetricBlockWidth, RefreshInterval, TimeRangePreset } from "./utils"
|
|||
// the block attribute defaults come from defaultMetricConfig, which
|
||||
// resolves CSS variables through a canvas — unavailable in node
|
||||
vi.mock("~/assets/css", () => ({
|
||||
chartStyles: () => ({ thresholdColors: { default: "#8a3ffc" } }),
|
||||
chartStyles: () => ({ selectableColors: { default: "#8a3ffc" } }),
|
||||
}))
|
||||
|
||||
// a nesting context standing in for the real containers that accept a
|
||||
|
|
|
|||
|
|
@ -1,23 +1,7 @@
|
|||
// shared helpers for the metric block component suites. Test-only: the
|
||||
// app/**/test-helpers.ts coverage exclude keeps this out of the
|
||||
// denominator, and nothing here is imported by app code.
|
||||
export { stubThemeColorContext as stubChartColorContext } from "../../test-helpers/theme"
|
||||
|
||||
// the threshold palette is read straight off the root element's CSS
|
||||
// variables, which no stylesheet defines under happy-dom. Distinct values
|
||||
// let a suite tell one swatch from another.
|
||||
export function stubThresholdPalette(): string[] {
|
||||
const colors = Array.from(
|
||||
{ length: 16 },
|
||||
(_value, index) => `#${(index + 1).toString(16).padStart(2, "0")}0000`,
|
||||
)
|
||||
|
||||
colors.forEach((color, index) => {
|
||||
document.documentElement.style.setProperty(
|
||||
`--chart-threshold-${index + 1}`,
|
||||
color,
|
||||
)
|
||||
})
|
||||
|
||||
return colors
|
||||
}
|
||||
export {
|
||||
stubThemeColorContext as stubChartColorContext,
|
||||
stubSelectableColors,
|
||||
} from "../../test-helpers/theme"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
// chartStyles resolves CSS variables through a canvas, which needs a real
|
||||
// DOM — the default threshold color is all defaultMetricConfig reads
|
||||
vi.mock("~/assets/css", () => ({
|
||||
chartStyles: () => ({ thresholdColors: { default: "#8a3ffc" } }),
|
||||
chartStyles: () => ({ selectableColors: { default: "#8a3ffc" } }),
|
||||
}))
|
||||
|
||||
// a wednesday in the middle of a month, with sub-second precision so
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export function defaultMetricConfig(): MetricConfig {
|
|||
timeRange: TimeRangePreset.Last5Minutes,
|
||||
refreshInterval: RefreshInterval.M5,
|
||||
thresholds: null,
|
||||
baseThresholdColor: chartStyles().thresholdColors.default,
|
||||
baseThresholdColor: chartStyles().selectableColors.default,
|
||||
decimals: null,
|
||||
unit: {
|
||||
type: null,
|
||||
|
|
|
|||
|
|
@ -19,3 +19,22 @@ export function stubThemeColorContext() {
|
|||
context as unknown as CanvasRenderingContext2D,
|
||||
)
|
||||
}
|
||||
|
||||
// the selectable colour palette is read straight off the root element's
|
||||
// CSS variables, which no stylesheet defines under happy-dom. Distinct
|
||||
// values let a suite tell one swatch from another.
|
||||
export function stubSelectableColors(): string[] {
|
||||
const colors = Array.from(
|
||||
{ length: 16 },
|
||||
(_value, index) => `#${(index + 1).toString(16).padStart(2, "0")}0000`,
|
||||
)
|
||||
|
||||
colors.forEach((color, index) => {
|
||||
document.documentElement.style.setProperty(
|
||||
`--selectable-color-${index + 1}`,
|
||||
color,
|
||||
)
|
||||
})
|
||||
|
||||
return colors
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,34 @@ import {
|
|||
documentTreeBreadcrumbs,
|
||||
extractDocumentTreeElement,
|
||||
processDocumentTree,
|
||||
processTagTree,
|
||||
type SidebarItemAction,
|
||||
} from "./sidebar"
|
||||
|
||||
// the tree builders take their menus from the caller, so the tests hand
|
||||
// them a recognisable one instead of the app's real handlers. One object
|
||||
// per id: a fresh closure per call would never compare equal to itself.
|
||||
const _actions = new Map<string, SidebarItemAction>()
|
||||
|
||||
function action(id: string): SidebarItemAction {
|
||||
const existing = _actions.get(id)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const made: SidebarItemAction = {
|
||||
id: id,
|
||||
name: id,
|
||||
icon: `lucide:${id}`,
|
||||
fn: () => undefined,
|
||||
}
|
||||
_actions.set(id, made)
|
||||
|
||||
return made
|
||||
}
|
||||
|
||||
const noActions = () => []
|
||||
|
||||
// isXid() only checks for a 20-character length, and
|
||||
// createNameSlugWithId() rejects anything shorter
|
||||
const ID_A = "a".padEnd(20, "0")
|
||||
|
|
@ -35,6 +61,7 @@ describe("processDocumentTree", () => {
|
|||
null,
|
||||
"placeholder",
|
||||
"Acme Corp",
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(items).toEqual([
|
||||
|
|
@ -42,12 +69,13 @@ describe("processDocumentTree", () => {
|
|||
id: ID_A,
|
||||
name: "Runbook",
|
||||
icon: "lucide:file",
|
||||
partOfDocumentTree: true,
|
||||
acceptsChildren: true,
|
||||
active: false,
|
||||
draggable: true,
|
||||
url: `/Acme-Corp/Runbook-${ID_A}`,
|
||||
prefetchUrlOnInteraction: true,
|
||||
localOptimisticInsert: undefined,
|
||||
actions: [],
|
||||
children: [
|
||||
expect.objectContaining({ id: SIDEBAR_ITEM_PLACEHOLDER_ID }),
|
||||
],
|
||||
|
|
@ -61,6 +89,7 @@ describe("processDocumentTree", () => {
|
|||
ID_B,
|
||||
"placeholder",
|
||||
"Acme",
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(items.map((item) => item.active)).toEqual([false, true])
|
||||
|
|
@ -74,6 +103,7 @@ describe("processDocumentTree", () => {
|
|||
null,
|
||||
"placeholder",
|
||||
"Acme",
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(item?.url).toBe("#")
|
||||
|
|
@ -86,6 +116,7 @@ describe("processDocumentTree", () => {
|
|||
null,
|
||||
"placeholder",
|
||||
"Acme",
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(items[0]?.children).toEqual([
|
||||
|
|
@ -101,6 +132,7 @@ describe("processDocumentTree", () => {
|
|||
null,
|
||||
"Add a page",
|
||||
"Acme",
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(items[0]?.children).toEqual([
|
||||
|
|
@ -108,10 +140,11 @@ describe("processDocumentTree", () => {
|
|||
id: SIDEBAR_ITEM_PLACEHOLDER_ID,
|
||||
name: "Add a page",
|
||||
url: null,
|
||||
partOfDocumentTree: true,
|
||||
acceptsChildren: false,
|
||||
icon: null,
|
||||
active: false,
|
||||
draggable: false,
|
||||
actions: [],
|
||||
children: [],
|
||||
},
|
||||
])
|
||||
|
|
@ -125,6 +158,7 @@ describe("processDocumentTree", () => {
|
|||
null,
|
||||
"Add a page",
|
||||
"Acme",
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(items[0]?.children).toEqual([
|
||||
|
|
@ -133,7 +167,7 @@ describe("processDocumentTree", () => {
|
|||
})
|
||||
|
||||
it("returns a lone placeholder for an empty tree", ({ expect }) => {
|
||||
const items = processDocumentTree([], null, "Add a page", "Acme")
|
||||
const items = processDocumentTree([], null, "Add a page", "Acme", noActions)
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
|
|
@ -144,6 +178,158 @@ describe("processDocumentTree", () => {
|
|||
})
|
||||
})
|
||||
|
||||
function tagElement(
|
||||
id: string,
|
||||
tagName: string,
|
||||
documents?: DocumentTreeElement[] | null,
|
||||
): TagTreeElement {
|
||||
return {
|
||||
id: id,
|
||||
tagName: tagName,
|
||||
color: "#22c55e",
|
||||
hidden: false,
|
||||
documents: documents,
|
||||
}
|
||||
}
|
||||
|
||||
describe("processTagTree", () => {
|
||||
it("maps a tag onto a draggable row carrying its colour", ({ expect }) => {
|
||||
const items = processTagTree(
|
||||
[tagElement(ID_A, "Production")],
|
||||
null,
|
||||
"Acme",
|
||||
() => [action("hide-tag")],
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
id: ID_A,
|
||||
name: "Production",
|
||||
dotColor: "#22c55e",
|
||||
acceptsChildren: false,
|
||||
active: false,
|
||||
draggable: true,
|
||||
dragGroup: "tags",
|
||||
actions: [action("hide-tag")],
|
||||
children: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("lists a tag's documents as links into the organization", ({ expect }) => {
|
||||
const items = processTagTree(
|
||||
[tagElement(ID_A, "Production", [treeElement(ID_B, "Runbook")])],
|
||||
null,
|
||||
"Acme Corp",
|
||||
noActions,
|
||||
(documentId, tagId) => [action(`remove-${documentId}-from-${tagId}`)],
|
||||
)
|
||||
|
||||
expect(items[0]?.children).toEqual([
|
||||
{
|
||||
id: ID_B,
|
||||
name: "Runbook",
|
||||
icon: "lucide:file",
|
||||
acceptsChildren: true,
|
||||
active: false,
|
||||
draggable: false,
|
||||
dragGroup: "tag-documents",
|
||||
url: `/Acme-Corp/Runbook-${ID_B}`,
|
||||
prefetchUrlOnInteraction: true,
|
||||
actions: [action(`remove-${ID_B}-from-${ID_A}`)],
|
||||
children: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("recurses into a document's own children", ({ expect }) => {
|
||||
const items = processTagTree(
|
||||
[
|
||||
tagElement(ID_A, "Production", [
|
||||
treeElement(ID_B, "Parent", [treeElement(ID_C, "Child")]),
|
||||
]),
|
||||
],
|
||||
null,
|
||||
"Acme",
|
||||
noActions,
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(items[0]?.children?.[0]?.children).toEqual([
|
||||
expect.objectContaining({ id: ID_C, name: "Child", draggable: false }),
|
||||
])
|
||||
})
|
||||
|
||||
it("marks the document that is currently open", ({ expect }) => {
|
||||
const items = processTagTree(
|
||||
[
|
||||
tagElement(ID_A, "Production", [
|
||||
treeElement(ID_B, "First"),
|
||||
treeElement(ID_C, "Second"),
|
||||
]),
|
||||
],
|
||||
ID_C,
|
||||
"Acme",
|
||||
noActions,
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(items[0]?.children?.map((child) => child.active)).toEqual([
|
||||
false,
|
||||
true,
|
||||
])
|
||||
})
|
||||
|
||||
it("offers the detach action only to the documents carrying the tag", ({
|
||||
expect,
|
||||
}) => {
|
||||
const items = processTagTree(
|
||||
[
|
||||
tagElement(ID_A, "Production", [
|
||||
treeElement(ID_B, "Parent", [treeElement(ID_C, "Child")]),
|
||||
]),
|
||||
],
|
||||
null,
|
||||
"Acme",
|
||||
noActions,
|
||||
(documentId) => [action(`remove-${documentId}`)],
|
||||
)
|
||||
|
||||
const parent = items[0]?.children?.[0]
|
||||
|
||||
expect(parent?.actions).toEqual([action(`remove-${ID_B}`)])
|
||||
expect(parent?.children?.[0]?.actions).toEqual([])
|
||||
})
|
||||
|
||||
it("keeps a hidden tag out of the sidebar", ({ expect }) => {
|
||||
const items = processTagTree(
|
||||
[
|
||||
{ ...tagElement(ID_A, "Production"), hidden: true },
|
||||
tagElement(ID_B, "Staging"),
|
||||
],
|
||||
null,
|
||||
"Acme",
|
||||
noActions,
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(items.map((item) => item.name)).toEqual(["Staging"])
|
||||
})
|
||||
|
||||
it("leaves a tag holding no documents without a child list", ({ expect }) => {
|
||||
const items = processTagTree(
|
||||
[tagElement(ID_A, "Empty", [])],
|
||||
null,
|
||||
"Acme",
|
||||
noActions,
|
||||
noActions,
|
||||
)
|
||||
|
||||
expect(items[0]?.children).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractDocumentTreeElement", () => {
|
||||
it("returns the matching top-level element", ({ expect }) => {
|
||||
const target = treeElement(ID_B, "Second", [])
|
||||
|
|
|
|||
|
|
@ -1,16 +1,28 @@
|
|||
export type PlainActionFn = (() => void) | (() => Promise<void>)
|
||||
|
||||
// SidebarItemAction is one entry of a row's dropdown menu. A row with no
|
||||
// actions renders no menu at all.
|
||||
export interface SidebarItemAction {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
fn: PlainActionFn
|
||||
}
|
||||
|
||||
export const SIDEBAR_ITEM_PLACEHOLDER_ID = "placeholder"
|
||||
const TAG_DRAG_GROUP = "tags"
|
||||
const TAG_DOCUMENT_DRAG_GROUP = "tag-documents"
|
||||
|
||||
function sidebarItemPlaceholder(name: string): SidebarItem {
|
||||
return {
|
||||
id: SIDEBAR_ITEM_PLACEHOLDER_ID,
|
||||
name: name,
|
||||
url: null,
|
||||
partOfDocumentTree: true,
|
||||
acceptsChildren: false,
|
||||
icon: null,
|
||||
active: false,
|
||||
draggable: false,
|
||||
actions: [],
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
|
@ -20,11 +32,18 @@ export interface SidebarItem {
|
|||
name: string
|
||||
url?: string | null
|
||||
onClick?: (() => void) | (() => Promise<void>) // URL takes precedence over onClick
|
||||
partOfDocumentTree: boolean
|
||||
// an item dropped onto this one becomes its child; an item that takes
|
||||
// no children can still be reordered against its siblings by edge drop
|
||||
acceptsChildren: boolean
|
||||
icon?: string | null
|
||||
dotColor?: string // a coloured dot rendered in place of the icon
|
||||
count?: number | null
|
||||
active: boolean
|
||||
draggable: boolean
|
||||
// items may only be dropped onto or next to items sharing this group,
|
||||
// which keeps each section's rows out of every other section
|
||||
dragGroup?: string
|
||||
actions: SidebarItemAction[]
|
||||
prefetchUrlOnInteraction?: boolean
|
||||
localOptimisticInsert?: boolean // used to indicate an item that is being optimistically inserted on the client
|
||||
shortcutTooltip?: {
|
||||
|
|
@ -44,14 +63,6 @@ export interface SidebarItemLocationUpdate {
|
|||
insertBeforeId: string | null
|
||||
}
|
||||
|
||||
export interface SidebarItemDelete {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface SidebarItemDuplicate {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface DocumentBreadcrumb {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -64,6 +75,7 @@ export function processDocumentTree(
|
|||
activeDocId: string | null,
|
||||
placeholderName: string,
|
||||
orgName: string,
|
||||
actions: (documentId: string) => SidebarItemAction[],
|
||||
): SidebarItem[] {
|
||||
const res: SidebarItem[] = []
|
||||
|
||||
|
|
@ -72,7 +84,7 @@ export function processDocumentTree(
|
|||
id: v.id,
|
||||
name: v.documentName,
|
||||
icon: v.icon,
|
||||
partOfDocumentTree: true,
|
||||
acceptsChildren: true,
|
||||
active: v.id === activeDocId,
|
||||
draggable: true,
|
||||
url: !v.localOptimisticInsert
|
||||
|
|
@ -80,8 +92,15 @@ export function processDocumentTree(
|
|||
: "#",
|
||||
prefetchUrlOnInteraction: true,
|
||||
localOptimisticInsert: v.localOptimisticInsert,
|
||||
actions: actions(v.id),
|
||||
children: v.children
|
||||
? processDocumentTree(v.children, activeDocId, placeholderName, orgName)
|
||||
? processDocumentTree(
|
||||
v.children,
|
||||
activeDocId,
|
||||
placeholderName,
|
||||
orgName,
|
||||
actions,
|
||||
)
|
||||
: [sidebarItemPlaceholder(placeholderName)],
|
||||
})
|
||||
})
|
||||
|
|
@ -93,6 +112,74 @@ export function processDocumentTree(
|
|||
return res
|
||||
}
|
||||
|
||||
export function processTagTree(
|
||||
data: TagTreeResponse,
|
||||
activeDocId: string | null,
|
||||
orgName: string,
|
||||
tagActions: (tag: TagTreeElement) => SidebarItemAction[],
|
||||
documentActions: (documentId: string, tagId: string) => SidebarItemAction[],
|
||||
): SidebarItem[] {
|
||||
return data
|
||||
.filter((v) => !v.hidden)
|
||||
.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.tagName,
|
||||
dotColor: v.color,
|
||||
acceptsChildren: false,
|
||||
active: false,
|
||||
draggable: true,
|
||||
dragGroup: TAG_DRAG_GROUP,
|
||||
actions: tagActions(v),
|
||||
children: v.documents?.length
|
||||
? processTagDocuments(
|
||||
v.documents,
|
||||
activeDocId,
|
||||
orgName,
|
||||
v.id,
|
||||
documentActions,
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
|
||||
// documents listed under a tag stay put: they are a view of the tag's
|
||||
// membership, not the tree that decides where a document lives.
|
||||
//
|
||||
// Only the documents at the top of that list carry the tag. The ones below
|
||||
// them are a document's own subtree, which the tag says nothing about, so
|
||||
// the menu that detaches a document from the tag stops at the first level.
|
||||
function processTagDocuments(
|
||||
data: DocumentTreeElement[],
|
||||
activeDocId: string | null,
|
||||
orgName: string,
|
||||
tagId: string,
|
||||
documentActions: (documentId: string, tagId: string) => SidebarItemAction[],
|
||||
carriesTag = true,
|
||||
): SidebarItem[] {
|
||||
return data.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.documentName,
|
||||
icon: v.icon,
|
||||
acceptsChildren: true,
|
||||
active: v.id === activeDocId,
|
||||
draggable: false,
|
||||
dragGroup: TAG_DOCUMENT_DRAG_GROUP,
|
||||
url: `/${createNameSlug(orgName)}/${createNameSlugWithId(v.documentName, v.id)}`,
|
||||
prefetchUrlOnInteraction: true,
|
||||
actions: carriesTag ? documentActions(v.id, tagId) : [],
|
||||
children: v.children?.length
|
||||
? processTagDocuments(
|
||||
v.children,
|
||||
activeDocId,
|
||||
orgName,
|
||||
tagId,
|
||||
documentActions,
|
||||
false,
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
|
||||
export function extractDocumentTreeElement(
|
||||
tree: DocumentTreeElement[],
|
||||
targetID: string,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import type {
|
|||
} from "~/utils"
|
||||
import isDeepEqual from "fast-deep-equal"
|
||||
|
||||
const DOCUMENT_QUERY_KEYS = {
|
||||
// exported because the tag tree carries document summaries: useTagAPI reads
|
||||
// this entry to build the row it optimistically inserts under a tag.
|
||||
export const DOCUMENT_QUERY_KEYS = {
|
||||
root: ["documents", "tree"] as const,
|
||||
branches: (docId: string) => ["documents", docId, "branches"] as const,
|
||||
documentMaintainers: (docId: string) =>
|
||||
|
|
|
|||
641
web/app/composables/api/useTagAPI.nuxt.test.ts
Normal file
641
web/app/composables/api/useTagAPI.nuxt.test.ts
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
import { flushPromises } from "@vue/test-utils"
|
||||
import { afterEach, beforeEach, describe, it } from "vitest"
|
||||
import {
|
||||
clearQueryCache,
|
||||
disposeMockEndpoints,
|
||||
makeXid,
|
||||
mockDeferredEndpoint,
|
||||
mockEndpoint,
|
||||
readQueryData,
|
||||
runInApp,
|
||||
seedQueryData,
|
||||
} from "./test-helpers"
|
||||
import useTagAPI from "./useTagAPI"
|
||||
|
||||
const TREE_KEY = ["tags", "tree"] as const
|
||||
const TREE_URL = "/api/tags/tree"
|
||||
const DOCUMENT_TREE_KEY = ["documents", "tree"] as const
|
||||
|
||||
const TAG_A = makeXid("taga")
|
||||
const TAG_B = makeXid("tagb")
|
||||
const TAG_C = makeXid("tagc")
|
||||
const DOC_A = makeXid("doca")
|
||||
const DOC_CHILD = makeXid("docc")
|
||||
const SHORT_ID = "local-1"
|
||||
|
||||
type TagAPI = ReturnType<typeof useTagAPI>
|
||||
|
||||
interface TestTag {
|
||||
id: string
|
||||
tagName: string
|
||||
color: string
|
||||
hidden: boolean
|
||||
documents: TestDocument[]
|
||||
}
|
||||
|
||||
interface TestDocument {
|
||||
id: string
|
||||
documentName: string
|
||||
icon: string
|
||||
protected: boolean
|
||||
children: TestDocument[] | null
|
||||
}
|
||||
|
||||
function makeTagAPI(): TagAPI {
|
||||
return runInApp(() => useTagAPI())
|
||||
}
|
||||
|
||||
function makeTag(id: string, tagName: string, documents: TestDocument[] = []) {
|
||||
return { id, tagName, color: "#22c55e", hidden: false, documents }
|
||||
}
|
||||
|
||||
function makeDoc(
|
||||
id: string,
|
||||
documentName: string,
|
||||
children: TestDocument[] | null = null,
|
||||
): TestDocument {
|
||||
return { id, documentName, icon: "lucide:file", protected: false, children }
|
||||
}
|
||||
|
||||
function readTree() {
|
||||
return readQueryData(TREE_KEY) as TestTag[] | undefined
|
||||
}
|
||||
|
||||
function tagIds() {
|
||||
return (readTree() ?? []).map((tag) => tag.id)
|
||||
}
|
||||
|
||||
function documentIds(tagIndex = 0) {
|
||||
return (readTree()?.[tagIndex]?.documents ?? []).map((doc) => doc.id)
|
||||
}
|
||||
|
||||
// every mutation writes the tree through one cache entry and invalidates it
|
||||
// on success, so the tests share a cache and cannot interleave
|
||||
describe("useTagAPI", { concurrent: false }, () => {
|
||||
beforeEach(clearQueryCache)
|
||||
afterEach(disposeMockEndpoints)
|
||||
|
||||
describe("fetchTagTree", () => {
|
||||
it("fetches the tags with the documents carrying them", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const calls = mockEndpoint("GET", TREE_URL, () => [
|
||||
makeTag(TAG_A, "Production", [makeDoc(DOC_A, "Runbook")]),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await api.fetchTagTree.refresh()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(api.fetchTagTree.data.value?.[0]?.tagName).toBe("Production")
|
||||
expect(api.fetchTagTree.data.value?.[0]?.documents?.[0]?.id).toBe(DOC_A)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateTagTree", () => {
|
||||
it("bails out for a non-xid tag id without a request", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const treeCalls = mockEndpoint("GET", TREE_URL, () => [])
|
||||
const putCalls = mockEndpoint("PUT", TREE_URL, () => ({}))
|
||||
seedQueryData(TREE_KEY, [makeTag(SHORT_ID, "Draft")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await api.updateTagTree.mutateAsync({
|
||||
id: SHORT_ID,
|
||||
insertBeforeId: null,
|
||||
})
|
||||
|
||||
expect(tagIds()).toEqual([SHORT_ID])
|
||||
expect(putCalls).toHaveLength(0)
|
||||
expect(treeCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("rejects when the tag is not in the tree", async ({ expect }) => {
|
||||
const putCalls = mockEndpoint("PUT", TREE_URL, () => ({}))
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await expect(
|
||||
api.updateTagTree.mutateAsync({ id: TAG_B, insertBeforeId: null }),
|
||||
).rejects.toThrow("invalid tag tree update data")
|
||||
|
||||
expect(tagIds()).toEqual([TAG_A])
|
||||
expect(putCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("rejects when the tag it should precede is not in the tree", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const putCalls = mockEndpoint("PUT", TREE_URL, () => ({}))
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await expect(
|
||||
api.updateTagTree.mutateAsync({ id: TAG_A, insertBeforeId: TAG_B }),
|
||||
).rejects.toThrow("invalid tag tree update data")
|
||||
|
||||
expect(putCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("moves a tag before a sibling and refreshes the tree", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const serverTree = [
|
||||
makeTag(TAG_C, "Incidents"),
|
||||
makeTag(TAG_A, "Production"),
|
||||
]
|
||||
const treeCalls = mockEndpoint("GET", TREE_URL, () => serverTree)
|
||||
const put = mockDeferredEndpoint("PUT", TREE_URL)
|
||||
seedQueryData(TREE_KEY, [
|
||||
makeTag(TAG_A, "Production"),
|
||||
makeTag(TAG_B, "Staging"),
|
||||
makeTag(TAG_C, "Incidents"),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
const pending = api.updateTagTree.mutateAsync({
|
||||
id: TAG_C,
|
||||
insertBeforeId: TAG_B,
|
||||
})
|
||||
await put.reached
|
||||
|
||||
// the move shows before the request answers
|
||||
expect(tagIds()).toEqual([TAG_A, TAG_C, TAG_B])
|
||||
expect(put.calls[0]?.body).toEqual({ id: TAG_C, sortIndex: 1 })
|
||||
|
||||
put.resolve({})
|
||||
await pending
|
||||
await flushPromises()
|
||||
|
||||
expect(treeCalls).toHaveLength(1)
|
||||
expect(tagIds()).toEqual([TAG_C, TAG_A])
|
||||
})
|
||||
|
||||
it("moves a tag to the very top when nothing follows it", async ({
|
||||
expect,
|
||||
}) => {
|
||||
mockEndpoint("GET", TREE_URL, () => [])
|
||||
const putCalls = mockEndpoint("PUT", TREE_URL, () => ({}))
|
||||
seedQueryData(TREE_KEY, [
|
||||
makeTag(TAG_A, "Production"),
|
||||
makeTag(TAG_B, "Staging"),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await api.updateTagTree.mutateAsync({ id: TAG_B, insertBeforeId: null })
|
||||
|
||||
expect(putCalls[0]?.body).toEqual({ id: TAG_B, sortIndex: 0 })
|
||||
})
|
||||
|
||||
it("puts the old order back when the move is rejected", async ({
|
||||
expect,
|
||||
}) => {
|
||||
mockEndpoint("PUT", TREE_URL, () => {
|
||||
throw createError({ statusCode: 500 })
|
||||
})
|
||||
seedQueryData(TREE_KEY, [
|
||||
makeTag(TAG_A, "Production"),
|
||||
makeTag(TAG_B, "Staging"),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await expect(
|
||||
api.updateTagTree.mutateAsync({ id: TAG_B, insertBeforeId: TAG_A }),
|
||||
).rejects.toThrow()
|
||||
await flushPromises()
|
||||
|
||||
expect(tagIds()).toEqual([TAG_A, TAG_B])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createTag", () => {
|
||||
it("shows the new tag at the end and refreshes the tree", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const serverTree = [
|
||||
makeTag(TAG_A, "Production"),
|
||||
makeTag(TAG_B, "Staging"),
|
||||
]
|
||||
const treeCalls = mockEndpoint("GET", TREE_URL, () => serverTree)
|
||||
const post = mockDeferredEndpoint("POST", "/api/tags")
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
const pending = api.createTag.mutateAsync({
|
||||
tagName: "Staging",
|
||||
color: "#f97316",
|
||||
})
|
||||
await post.reached
|
||||
|
||||
const optimistic = readTree()
|
||||
expect(optimistic?.map((tag) => tag.tagName)).toEqual([
|
||||
"Production",
|
||||
"Staging",
|
||||
])
|
||||
expect(post.calls[0]?.body).toEqual({
|
||||
tagName: "Staging",
|
||||
color: "#f97316",
|
||||
})
|
||||
|
||||
post.resolve({ id: TAG_B })
|
||||
await pending
|
||||
await flushPromises()
|
||||
|
||||
expect(treeCalls).toHaveLength(1)
|
||||
expect(tagIds()).toEqual([TAG_A, TAG_B])
|
||||
})
|
||||
|
||||
it("drops the new tag again when the request fails", async ({ expect }) => {
|
||||
mockEndpoint("POST", "/api/tags", () => {
|
||||
throw createError({ statusCode: 500 })
|
||||
})
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await expect(
|
||||
api.createTag.mutateAsync({ tagName: "Staging", color: "#f97316" }),
|
||||
).rejects.toThrow()
|
||||
await flushPromises()
|
||||
|
||||
expect(tagIds()).toEqual([TAG_A])
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateTagVisibility", () => {
|
||||
it("bails out for a non-xid tag id without a request", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const treeCalls = mockEndpoint("GET", TREE_URL, () => [])
|
||||
const putCalls = mockEndpoint(
|
||||
"PUT",
|
||||
`/api/tags/${SHORT_ID}/visibility`,
|
||||
() => ({}),
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeTag(SHORT_ID, "Draft")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await api.updateTagVisibility.mutateAsync({
|
||||
id: SHORT_ID,
|
||||
req: { hidden: true },
|
||||
})
|
||||
|
||||
expect(putCalls).toHaveLength(0)
|
||||
expect(treeCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("rejects when the tag is not in the tree", async ({ expect }) => {
|
||||
const putCalls = mockEndpoint(
|
||||
"PUT",
|
||||
`/api/tags/${TAG_B}/visibility`,
|
||||
() => ({}),
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await expect(
|
||||
api.updateTagVisibility.mutateAsync({
|
||||
id: TAG_B,
|
||||
req: { hidden: true },
|
||||
}),
|
||||
).rejects.toThrow("invalid tag visibility data")
|
||||
|
||||
expect(putCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("hides the tag before the request answers", async ({ expect }) => {
|
||||
mockEndpoint("GET", TREE_URL, () => [
|
||||
{ ...makeTag(TAG_A, "Production"), hidden: true },
|
||||
])
|
||||
const put = mockDeferredEndpoint("PUT", `/api/tags/${TAG_A}/visibility`)
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
const pending = api.updateTagVisibility.mutateAsync({
|
||||
id: TAG_A,
|
||||
req: { hidden: true },
|
||||
})
|
||||
await put.reached
|
||||
|
||||
expect(readTree()?.[0]?.hidden).toBe(true)
|
||||
expect(put.calls[0]?.body).toEqual({ hidden: true })
|
||||
|
||||
put.resolve({})
|
||||
await pending
|
||||
await flushPromises()
|
||||
|
||||
expect(readTree()?.[0]?.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it("shows the tag again when the request fails", async ({ expect }) => {
|
||||
mockEndpoint("PUT", `/api/tags/${TAG_A}/visibility`, () => {
|
||||
throw createError({ statusCode: 500 })
|
||||
})
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await expect(
|
||||
api.updateTagVisibility.mutateAsync({
|
||||
id: TAG_A,
|
||||
req: { hidden: true },
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
await flushPromises()
|
||||
|
||||
expect(readTree()?.[0]?.hidden).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteTag", () => {
|
||||
it("bails out for a non-xid tag id without a request", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const treeCalls = mockEndpoint("GET", TREE_URL, () => [])
|
||||
const delCalls = mockEndpoint(
|
||||
"DELETE",
|
||||
`/api/tags/${SHORT_ID}/visibility`,
|
||||
() => ({}),
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeTag(SHORT_ID, "Draft")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await api.deleteTag.mutateAsync(SHORT_ID)
|
||||
|
||||
expect(tagIds()).toEqual([SHORT_ID])
|
||||
expect(delCalls).toHaveLength(0)
|
||||
expect(treeCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("removes the tag and refreshes the tree", async ({ expect }) => {
|
||||
const treeCalls = mockEndpoint("GET", TREE_URL, () => [
|
||||
makeTag(TAG_B, "Staging"),
|
||||
])
|
||||
const del = mockDeferredEndpoint("DELETE", `/api/tags/${TAG_A}`)
|
||||
seedQueryData(TREE_KEY, [
|
||||
makeTag(TAG_A, "Production"),
|
||||
makeTag(TAG_B, "Staging"),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
const pending = api.deleteTag.mutateAsync(TAG_A)
|
||||
await del.reached
|
||||
|
||||
expect(tagIds()).toEqual([TAG_B])
|
||||
|
||||
del.resolve({})
|
||||
await pending
|
||||
await flushPromises()
|
||||
|
||||
expect(treeCalls).toHaveLength(1)
|
||||
expect(tagIds()).toEqual([TAG_B])
|
||||
})
|
||||
|
||||
it("puts the tag back when the deletion fails", async ({ expect }) => {
|
||||
mockEndpoint("DELETE", `/api/tags/${TAG_A}`, () => {
|
||||
throw createError({ statusCode: 500 })
|
||||
})
|
||||
seedQueryData(TREE_KEY, [
|
||||
makeTag(TAG_A, "Production"),
|
||||
makeTag(TAG_B, "Staging"),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await expect(api.deleteTag.mutateAsync(TAG_A)).rejects.toThrow()
|
||||
await flushPromises()
|
||||
|
||||
expect(tagIds()).toEqual([TAG_A, TAG_B])
|
||||
})
|
||||
})
|
||||
|
||||
describe("assignDocumentTag", () => {
|
||||
it("bails out for a non-xid id without a request", async ({ expect }) => {
|
||||
const postCalls = mockEndpoint(
|
||||
"POST",
|
||||
`/api/documents/${DOC_A}/tags`,
|
||||
() => ({}),
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await api.assignDocumentTag.mutateAsync({
|
||||
documentId: DOC_A,
|
||||
tagId: SHORT_ID,
|
||||
})
|
||||
|
||||
expect(postCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("rejects when the tag is not in the tree", async ({ expect }) => {
|
||||
const postCalls = mockEndpoint(
|
||||
"POST",
|
||||
`/api/documents/${DOC_A}/tags`,
|
||||
() => ({}),
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
seedQueryData(DOCUMENT_TREE_KEY, [makeDoc(DOC_A, "Runbook")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await expect(
|
||||
api.assignDocumentTag.mutateAsync({
|
||||
documentId: DOC_A,
|
||||
tagId: TAG_B,
|
||||
}),
|
||||
).rejects.toThrow("invalid document tag data")
|
||||
|
||||
expect(postCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("assigns without an optimistic row when the document tree has not loaded", async ({
|
||||
expect,
|
||||
}) => {
|
||||
mockEndpoint("GET", TREE_URL, () => [
|
||||
makeTag(TAG_A, "Production", [makeDoc(DOC_A, "Runbook")]),
|
||||
])
|
||||
const postCalls = mockEndpoint(
|
||||
"POST",
|
||||
`/api/documents/${DOC_A}/tags`,
|
||||
() => ({}),
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
seedQueryData(DOCUMENT_TREE_KEY, [])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await api.assignDocumentTag.mutateAsync({
|
||||
documentId: DOC_A,
|
||||
tagId: TAG_A,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// the request still goes out; the refetch is what draws the row
|
||||
expect(postCalls).toHaveLength(1)
|
||||
expect(documentIds()).toEqual([DOC_A])
|
||||
})
|
||||
|
||||
it("copies the document subtree under the tag and refreshes", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const treeCalls = mockEndpoint("GET", TREE_URL, () => [
|
||||
makeTag(TAG_A, "Production", [makeDoc(DOC_A, "Runbook")]),
|
||||
])
|
||||
const post = mockDeferredEndpoint("POST", `/api/documents/${DOC_A}/tags`)
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
seedQueryData(DOCUMENT_TREE_KEY, [
|
||||
makeDoc(DOC_A, "Runbook", [makeDoc(DOC_CHILD, "Rollback")]),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
const pending = api.assignDocumentTag.mutateAsync({
|
||||
documentId: DOC_A,
|
||||
tagId: TAG_A,
|
||||
})
|
||||
await post.reached
|
||||
|
||||
expect(documentIds()).toEqual([DOC_A])
|
||||
expect(readTree()?.[0]?.documents[0]?.children).toHaveLength(1)
|
||||
expect(post.calls[0]?.body).toEqual({ tagId: TAG_A })
|
||||
|
||||
post.resolve({})
|
||||
await pending
|
||||
await flushPromises()
|
||||
|
||||
expect(treeCalls).toHaveLength(1)
|
||||
expect(documentIds()).toEqual([DOC_A])
|
||||
})
|
||||
|
||||
it("finds a document nested deep in the tree", async ({ expect }) => {
|
||||
mockEndpoint("GET", TREE_URL, () => [])
|
||||
const postCalls = mockEndpoint(
|
||||
"POST",
|
||||
`/api/documents/${DOC_CHILD}/tags`,
|
||||
() => ({}),
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
seedQueryData(DOCUMENT_TREE_KEY, [
|
||||
makeDoc(DOC_A, "Runbook", [makeDoc(DOC_CHILD, "Rollback")]),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await api.assignDocumentTag.mutateAsync({
|
||||
documentId: DOC_CHILD,
|
||||
tagId: TAG_A,
|
||||
})
|
||||
|
||||
expect(postCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("takes the document away again when the request fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
mockEndpoint("POST", `/api/documents/${DOC_A}/tags`, () => {
|
||||
throw createError({ statusCode: 500 })
|
||||
})
|
||||
seedQueryData(TREE_KEY, [makeTag(TAG_A, "Production")])
|
||||
seedQueryData(DOCUMENT_TREE_KEY, [makeDoc(DOC_A, "Runbook")])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await expect(
|
||||
api.assignDocumentTag.mutateAsync({
|
||||
documentId: DOC_A,
|
||||
tagId: TAG_A,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
await flushPromises()
|
||||
|
||||
expect(documentIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("unassignDocumentTag", () => {
|
||||
it("bails out for a non-xid id without a request", async ({ expect }) => {
|
||||
const delCalls = mockEndpoint(
|
||||
"DELETE",
|
||||
`/api/documents/${DOC_A}/tags/${SHORT_ID}`,
|
||||
() => ({}),
|
||||
)
|
||||
seedQueryData(TREE_KEY, [
|
||||
makeTag(TAG_A, "Production", [makeDoc(DOC_A, "Runbook")]),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await api.unassignDocumentTag.mutateAsync({
|
||||
documentId: DOC_A,
|
||||
tagId: SHORT_ID,
|
||||
})
|
||||
|
||||
expect(delCalls).toHaveLength(0)
|
||||
expect(documentIds()).toEqual([DOC_A])
|
||||
})
|
||||
|
||||
it("takes the document out of the tag and refreshes", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const treeCalls = mockEndpoint("GET", TREE_URL, () => [
|
||||
makeTag(TAG_A, "Production"),
|
||||
])
|
||||
const del = mockDeferredEndpoint(
|
||||
"DELETE",
|
||||
`/api/documents/${DOC_A}/tags/${TAG_A}`,
|
||||
)
|
||||
seedQueryData(TREE_KEY, [
|
||||
makeTag(TAG_A, "Production", [makeDoc(DOC_A, "Runbook")]),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
const pending = api.unassignDocumentTag.mutateAsync({
|
||||
documentId: DOC_A,
|
||||
tagId: TAG_A,
|
||||
})
|
||||
await del.reached
|
||||
|
||||
expect(documentIds()).toEqual([])
|
||||
|
||||
del.resolve({})
|
||||
await pending
|
||||
await flushPromises()
|
||||
|
||||
expect(treeCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("still asks core to detach a tag the cached tree does not carry", async ({
|
||||
expect,
|
||||
}) => {
|
||||
mockEndpoint("GET", TREE_URL, () => [])
|
||||
const delCalls = mockEndpoint(
|
||||
"DELETE",
|
||||
`/api/documents/${DOC_A}/tags/${TAG_B}`,
|
||||
() => ({}),
|
||||
)
|
||||
seedQueryData(TREE_KEY, [
|
||||
makeTag(TAG_A, "Production", [makeDoc(DOC_A, "Runbook")]),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await api.unassignDocumentTag.mutateAsync({
|
||||
documentId: DOC_A,
|
||||
tagId: TAG_B,
|
||||
})
|
||||
|
||||
expect(delCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("puts the document back when the request fails", async ({ expect }) => {
|
||||
mockEndpoint("DELETE", `/api/documents/${DOC_A}/tags/${TAG_A}`, () => {
|
||||
throw createError({ statusCode: 500 })
|
||||
})
|
||||
seedQueryData(TREE_KEY, [
|
||||
makeTag(TAG_A, "Production", [makeDoc(DOC_A, "Runbook")]),
|
||||
])
|
||||
const api = makeTagAPI()
|
||||
|
||||
await expect(
|
||||
api.unassignDocumentTag.mutateAsync({
|
||||
documentId: DOC_A,
|
||||
tagId: TAG_A,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
await flushPromises()
|
||||
|
||||
expect(documentIds()).toEqual([DOC_A])
|
||||
})
|
||||
})
|
||||
})
|
||||
424
web/app/composables/api/useTagAPI.ts
Normal file
424
web/app/composables/api/useTagAPI.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
import { nanoid } from "nanoid"
|
||||
import type {
|
||||
DocumentTreeElement,
|
||||
DocumentTreeResponse,
|
||||
TagCreateRequest,
|
||||
TagCreateResponse,
|
||||
TagTreeElement,
|
||||
TagTreeResponse,
|
||||
TagTreeUpdateRequest,
|
||||
TagVisibilityRequest,
|
||||
UnprocessedTagTreeUpdateRequest,
|
||||
DocumentTagRequest,
|
||||
} from "~/utils"
|
||||
import isDeepEqual from "fast-deep-equal"
|
||||
import { DOCUMENT_QUERY_KEYS } from "./useDocumentAPI"
|
||||
|
||||
const TAG_QUERY_KEYS = {
|
||||
root: ["tags", "tree"] as const,
|
||||
}
|
||||
|
||||
export default function () {
|
||||
const { $coreAPIClient } = useNuxtApp()
|
||||
const queryCache = useQueryCache()
|
||||
|
||||
const fetchTagTree = useQuery({
|
||||
key: TAG_QUERY_KEYS.root,
|
||||
query: async () => {
|
||||
return await $coreAPIClient<TagTreeResponse>(`/api/tags/tree`, {
|
||||
method: "GET",
|
||||
})
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 3 * 60 * 1000, // 3mins
|
||||
autoRefetch: true,
|
||||
})
|
||||
|
||||
const updateTagTree = useMutation<
|
||||
any,
|
||||
UnprocessedTagTreeUpdateRequest,
|
||||
Error,
|
||||
{
|
||||
newTree: TagTreeResponse
|
||||
oldTree: TagTreeResponse | undefined
|
||||
finalReq: TagTreeUpdateRequest
|
||||
}
|
||||
>({
|
||||
onMutate(req) {
|
||||
if (!isXid(req.id)) {
|
||||
// optimisticInserts use nanoid
|
||||
return
|
||||
}
|
||||
|
||||
const oldTree = clone(
|
||||
queryCache.getQueryData<TagTreeResponse>(TAG_QUERY_KEYS.root),
|
||||
)
|
||||
const newTree = clone(oldTree) ?? []
|
||||
|
||||
const oldIndex = newTree.findIndex((tag) => tag.id === req.id)
|
||||
if (oldIndex === -1) {
|
||||
throw new Error("invalid tag tree update data")
|
||||
}
|
||||
|
||||
const [movedTag] = newTree.splice(oldIndex, 1)
|
||||
if (!movedTag) {
|
||||
throw new Error("invalid tag tree update data")
|
||||
}
|
||||
|
||||
// a null insertBeforeId means the very top; an unknown one would
|
||||
// otherwise resolve to -1 and splice the tag in from the end
|
||||
let newIndex = 0
|
||||
|
||||
if (req.insertBeforeId) {
|
||||
const insertBefore = newTree.findIndex(
|
||||
(tag) => tag.id === req.insertBeforeId,
|
||||
)
|
||||
if (insertBefore === -1) {
|
||||
throw new Error("invalid tag tree update data")
|
||||
}
|
||||
|
||||
newIndex = insertBefore
|
||||
}
|
||||
|
||||
newTree.splice(newIndex, 0, movedTag)
|
||||
|
||||
const finalReq: TagTreeUpdateRequest = {
|
||||
id: req.id,
|
||||
sortIndex: newIndex,
|
||||
}
|
||||
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, newTree)
|
||||
queryCache.cancelQueries({ key: TAG_QUERY_KEYS.root })
|
||||
|
||||
return { newTree, oldTree, finalReq }
|
||||
},
|
||||
mutation: async (req, ctx) => {
|
||||
if (!isXid(req.id)) {
|
||||
// optimisticInserts use nanoid — onMutate bails out without a
|
||||
// context for them, so the guard runs before touching it
|
||||
return
|
||||
}
|
||||
|
||||
await $coreAPIClient(`/api/tags/tree`, {
|
||||
method: "PUT",
|
||||
body: ctx.finalReq,
|
||||
})
|
||||
},
|
||||
async onSuccess(_data, req) {
|
||||
if (!isXid(req.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
await queryCache.invalidateQueries({ key: TAG_QUERY_KEYS.root })
|
||||
},
|
||||
onError(_err, _req, { oldTree, newTree }) {
|
||||
const cachedTree = queryCache.getQueryData(TAG_QUERY_KEYS.root)
|
||||
if (!isDeepEqual(newTree, cachedTree)) {
|
||||
return
|
||||
}
|
||||
|
||||
// rollback
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, oldTree)
|
||||
},
|
||||
})
|
||||
|
||||
const createTag = useMutation({
|
||||
onMutate(req: TagCreateRequest) {
|
||||
const oldTree = clone(
|
||||
queryCache.getQueryData<TagTreeResponse>(TAG_QUERY_KEYS.root),
|
||||
)
|
||||
const newTreeElem: TagTreeElement = {
|
||||
id: nanoid(),
|
||||
tagName: req.tagName,
|
||||
color: req.color,
|
||||
hidden: false,
|
||||
documents: null,
|
||||
localOptimisticInsert: true,
|
||||
}
|
||||
|
||||
// a new tag lands at the end, which is where core appends it
|
||||
const newTree = [...(clone(oldTree) ?? []), newTreeElem]
|
||||
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, newTree)
|
||||
queryCache.cancelQueries({ key: TAG_QUERY_KEYS.root })
|
||||
|
||||
return { newTree, oldTree, newTreeElem }
|
||||
},
|
||||
mutation: async (req: TagCreateRequest) => {
|
||||
return await $coreAPIClient<TagCreateResponse>(`/api/tags`, {
|
||||
method: "POST",
|
||||
body: req,
|
||||
})
|
||||
},
|
||||
async onSuccess() {
|
||||
await queryCache.invalidateQueries({ key: TAG_QUERY_KEYS.root })
|
||||
},
|
||||
onError(_err, _req, { oldTree, newTree }) {
|
||||
const cachedTree = queryCache.getQueryData(TAG_QUERY_KEYS.root)
|
||||
if (!isDeepEqual(newTree, cachedTree)) {
|
||||
return
|
||||
}
|
||||
|
||||
// rollback
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, oldTree)
|
||||
},
|
||||
})
|
||||
|
||||
const updateTagVisibility = useMutation({
|
||||
onMutate({ id, req }: { id: string; req: TagVisibilityRequest }) {
|
||||
if (!isXid(id)) {
|
||||
// optimisticInserts use nanoid
|
||||
return
|
||||
}
|
||||
|
||||
const oldTree = clone(
|
||||
queryCache.getQueryData<TagTreeResponse>(TAG_QUERY_KEYS.root),
|
||||
)
|
||||
const newTree = clone(oldTree) ?? []
|
||||
|
||||
const tag = newTree.find((t) => t.id === id)
|
||||
if (!tag) {
|
||||
throw new Error("invalid tag visibility data")
|
||||
}
|
||||
|
||||
tag.hidden = req.hidden
|
||||
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, newTree)
|
||||
queryCache.cancelQueries({ key: TAG_QUERY_KEYS.root })
|
||||
|
||||
return { newTree, oldTree }
|
||||
},
|
||||
mutation: async ({
|
||||
id,
|
||||
req,
|
||||
}: {
|
||||
id: string
|
||||
req: TagVisibilityRequest
|
||||
}) => {
|
||||
if (!isXid(id)) {
|
||||
// optimisticInserts use nanoid
|
||||
return
|
||||
}
|
||||
|
||||
await $coreAPIClient(`/api/tags/${id}/visibility`, {
|
||||
method: "PUT",
|
||||
body: req,
|
||||
})
|
||||
},
|
||||
async onSuccess(_data, { id }) {
|
||||
if (!isXid(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
await queryCache.invalidateQueries({ key: TAG_QUERY_KEYS.root })
|
||||
},
|
||||
onError(_err, _req, { oldTree, newTree }) {
|
||||
const cachedTree = queryCache.getQueryData(TAG_QUERY_KEYS.root)
|
||||
if (!isDeepEqual(newTree, cachedTree)) {
|
||||
return
|
||||
}
|
||||
|
||||
// rollback
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, oldTree)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteTag = useMutation({
|
||||
onMutate(id: string) {
|
||||
if (!isXid(id)) {
|
||||
// optimisticInserts use nanoid
|
||||
return
|
||||
}
|
||||
|
||||
const oldTree = clone(
|
||||
queryCache.getQueryData<TagTreeResponse>(TAG_QUERY_KEYS.root),
|
||||
)
|
||||
const newTree = (clone(oldTree) ?? []).filter((tag) => tag.id !== id)
|
||||
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, newTree)
|
||||
queryCache.cancelQueries({ key: TAG_QUERY_KEYS.root })
|
||||
|
||||
return { newTree, oldTree }
|
||||
},
|
||||
mutation: async (id: string) => {
|
||||
if (!isXid(id)) {
|
||||
// optimisticInserts use nanoid
|
||||
return
|
||||
}
|
||||
|
||||
await $coreAPIClient(`/api/tags/${id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
},
|
||||
async onSuccess(_data, id) {
|
||||
if (!isXid(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
await queryCache.invalidateQueries({ key: TAG_QUERY_KEYS.root })
|
||||
},
|
||||
onError(_err, _id, { oldTree, newTree }) {
|
||||
const cachedTree = queryCache.getQueryData(TAG_QUERY_KEYS.root)
|
||||
if (!isDeepEqual(newTree, cachedTree)) {
|
||||
return
|
||||
}
|
||||
|
||||
// rollback
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, oldTree)
|
||||
},
|
||||
})
|
||||
|
||||
const assignDocumentTag = useMutation({
|
||||
onMutate(req: DocumentTagRequest) {
|
||||
if (!isXid(req.documentId) || !isXid(req.tagId)) {
|
||||
// optimisticInserts use nanoid
|
||||
return
|
||||
}
|
||||
|
||||
const oldTree = clone(
|
||||
queryCache.getQueryData<TagTreeResponse>(TAG_QUERY_KEYS.root),
|
||||
)
|
||||
const newTree = clone(oldTree) ?? []
|
||||
|
||||
const tag = newTree.find((t) => t.id === req.tagId)
|
||||
if (!tag) {
|
||||
throw new Error("invalid document tag data")
|
||||
}
|
||||
|
||||
// the tag tree carries whole document subtrees, so the row is
|
||||
// copied out of the document tree rather than invented here. A
|
||||
// caller the document tree has not reached yet — the editor, on a
|
||||
// document opened before the sidebar loaded — still assigns; it
|
||||
// just waits for the refetch to draw the row.
|
||||
const doc = findDocument(
|
||||
queryCache.getQueryData<DocumentTreeResponse>(
|
||||
DOCUMENT_QUERY_KEYS.root,
|
||||
) ?? [],
|
||||
req.documentId,
|
||||
)
|
||||
|
||||
if (doc) {
|
||||
tag.documents = [...(tag.documents ?? []), clone(doc)]
|
||||
}
|
||||
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, newTree)
|
||||
queryCache.cancelQueries({ key: TAG_QUERY_KEYS.root })
|
||||
|
||||
return { newTree, oldTree }
|
||||
},
|
||||
mutation: async (req: DocumentTagRequest) => {
|
||||
if (!isXid(req.documentId) || !isXid(req.tagId)) {
|
||||
// optimisticInserts use nanoid
|
||||
return
|
||||
}
|
||||
|
||||
await $coreAPIClient(`/api/documents/${req.documentId}/tags`, {
|
||||
method: "POST",
|
||||
body: { tagId: req.tagId },
|
||||
})
|
||||
},
|
||||
async onSuccess(_data, req) {
|
||||
if (!isXid(req.documentId) || !isXid(req.tagId)) {
|
||||
return
|
||||
}
|
||||
|
||||
await queryCache.invalidateQueries({ key: TAG_QUERY_KEYS.root })
|
||||
},
|
||||
onError(_err, _req, { oldTree, newTree }) {
|
||||
const cachedTree = queryCache.getQueryData(TAG_QUERY_KEYS.root)
|
||||
if (!isDeepEqual(newTree, cachedTree)) {
|
||||
return
|
||||
}
|
||||
|
||||
// rollback
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, oldTree)
|
||||
},
|
||||
})
|
||||
|
||||
const unassignDocumentTag = useMutation({
|
||||
onMutate(req: DocumentTagRequest) {
|
||||
if (!isXid(req.documentId) || !isXid(req.tagId)) {
|
||||
// optimisticInserts use nanoid
|
||||
return
|
||||
}
|
||||
|
||||
const oldTree = clone(
|
||||
queryCache.getQueryData<TagTreeResponse>(TAG_QUERY_KEYS.root),
|
||||
)
|
||||
const newTree = clone(oldTree) ?? []
|
||||
|
||||
const tag = newTree.find((t) => t.id === req.tagId)
|
||||
if (tag?.documents) {
|
||||
tag.documents = tag.documents.filter((doc) => doc.id !== req.documentId)
|
||||
}
|
||||
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, newTree)
|
||||
queryCache.cancelQueries({ key: TAG_QUERY_KEYS.root })
|
||||
|
||||
return { newTree, oldTree }
|
||||
},
|
||||
mutation: async (req: DocumentTagRequest) => {
|
||||
if (!isXid(req.documentId) || !isXid(req.tagId)) {
|
||||
// optimisticInserts use nanoid
|
||||
return
|
||||
}
|
||||
|
||||
await $coreAPIClient(
|
||||
`/api/documents/${req.documentId}/tags/${req.tagId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
)
|
||||
},
|
||||
async onSuccess(_data, req) {
|
||||
if (!isXid(req.documentId) || !isXid(req.tagId)) {
|
||||
return
|
||||
}
|
||||
|
||||
await queryCache.invalidateQueries({ key: TAG_QUERY_KEYS.root })
|
||||
},
|
||||
onError(_err, _req, { oldTree, newTree }) {
|
||||
const cachedTree = queryCache.getQueryData(TAG_QUERY_KEYS.root)
|
||||
if (!isDeepEqual(newTree, cachedTree)) {
|
||||
return
|
||||
}
|
||||
|
||||
// rollback
|
||||
queryCache.setQueryData(TAG_QUERY_KEYS.root, oldTree)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
fetchTagTree,
|
||||
updateTagTree,
|
||||
createTag,
|
||||
updateTagVisibility,
|
||||
deleteTag,
|
||||
assignDocumentTag,
|
||||
unassignDocumentTag,
|
||||
}
|
||||
}
|
||||
|
||||
// findDocument walks a document tree depth-first and returns the element
|
||||
// carrying the given id.
|
||||
function findDocument(
|
||||
elems: DocumentTreeElement[],
|
||||
id: string,
|
||||
): DocumentTreeElement | null {
|
||||
for (const elem of elems) {
|
||||
if (elem.id === id) {
|
||||
return elem
|
||||
}
|
||||
|
||||
if (elem.children) {
|
||||
const found = findDocument(elem.children, id)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ export * from "./api/useAuthAPI"
|
|||
export * from "./api/useCapabilitiesAPI"
|
||||
export * from "./api/useMCPAPI"
|
||||
export * from "./api/useDocumentAPI"
|
||||
export * from "./api/useTagAPI"
|
||||
export * from "./api/useDocumentHookAPI"
|
||||
export * from "./api/useDocumentCommentAPI"
|
||||
export * from "./api/useDocumentFileAPI"
|
||||
|
|
|
|||
|
|
@ -40,15 +40,18 @@ mockNuxtImport("useElementBounding", () => useElementBoundingMock)
|
|||
function sidebarItem(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
overrides: Partial<SidebarItem> = {},
|
||||
): SidebarItem & { parentId: string | null } {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
partOfDocumentTree: true,
|
||||
acceptsChildren: true,
|
||||
active: false,
|
||||
draggable: true,
|
||||
actions: [],
|
||||
children: [],
|
||||
parentId,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +60,7 @@ function makeInstance(
|
|||
opts?: {
|
||||
parentId?: string | null
|
||||
insideWrapperOf?: { wrapper: HTMLElement }
|
||||
item?: Partial<SidebarItem>
|
||||
},
|
||||
) {
|
||||
const dragOptions: CapturedDragOptions = {}
|
||||
|
|
@ -100,7 +104,7 @@ function makeInstance(
|
|||
scope.run(() => {
|
||||
api = useSidebarDraggable(
|
||||
elem,
|
||||
() => sidebarItem(id, opts?.parentId ?? null),
|
||||
() => sidebarItem(id, opts?.parentId ?? null, opts?.item),
|
||||
document.createElement("div"),
|
||||
() => wrapper,
|
||||
onConfirm,
|
||||
|
|
@ -246,6 +250,48 @@ describe("useSidebarDraggable", { concurrent: false }, () => {
|
|||
expect(dragged.onConfirm).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("does not target an item from another drag group", async ({ expect }) => {
|
||||
const dragged = makeInstance("a", { item: { dragGroup: "tags" } })
|
||||
const target = makeInstance("b", { parentId: "root" })
|
||||
startDrag(dragged)
|
||||
|
||||
target.mouse.isOutside.value = false
|
||||
target.topEdgeMouse.isOutside.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(target.api.isItemDraggedOn.value).toBe(false)
|
||||
expect(target.api.isTopEdgeDraggedOn.value).toBe(false)
|
||||
|
||||
dragged.dragOptions.onEnd?.()
|
||||
|
||||
expect(dragged.onConfirm).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("takes an edge drop but no nesting drop on a row taking no children", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const dragged = makeInstance("a", { item: { acceptsChildren: false } })
|
||||
const target = makeInstance("b", { item: { acceptsChildren: false } })
|
||||
startDrag(dragged)
|
||||
|
||||
target.mouse.isOutside.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(target.api.isItemDraggedOn.value).toBe(false)
|
||||
|
||||
target.topEdgeMouse.isOutside.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(target.api.isTopEdgeDraggedOn.value).toBe(true)
|
||||
|
||||
dragged.dragOptions.onEnd?.()
|
||||
|
||||
expect(dragged.onConfirm).toHaveBeenCalledExactlyOnceWith({
|
||||
targetParentId: null,
|
||||
targetInsertBeforeId: "b",
|
||||
})
|
||||
})
|
||||
|
||||
it("ends without a confirm when nothing was targeted", ({ expect }) => {
|
||||
const item = makeInstance("a")
|
||||
startDrag(item)
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ export function useSidebarDraggable(
|
|||
return (
|
||||
store.draggedElem !== null &&
|
||||
toValue(elemSidebarItem).id !== store.draggedElem.item.id &&
|
||||
toValue(elemSidebarItem).dragGroup === store.draggedElem.item.dragGroup &&
|
||||
!isValidDescendent(store.draggedElem.wrapper, toValue(elem))
|
||||
)
|
||||
})
|
||||
|
|
@ -162,6 +163,10 @@ export function useSidebarDraggable(
|
|||
store.draggedElem !== null &&
|
||||
!elemMouse.isOutside.value &&
|
||||
store.edgeDraggedOn === null &&
|
||||
// a drop on the row itself nests, so a row that takes no children
|
||||
// is no target for one — its edge still is, and a drop there
|
||||
// reorders rather than nests
|
||||
toValue(elemSidebarItem).acceptsChildren &&
|
||||
canDragOn.value
|
||||
)
|
||||
})
|
||||
|
|
|
|||
52
web/app/utils/api/tag.ts
Normal file
52
web/app/utils/api/tag.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export const WS_TAG_TREE_CHANGE_TOPIC = "change@tag-tree"
|
||||
|
||||
export interface Tag {
|
||||
id: string
|
||||
organizationId: string
|
||||
tagName: string
|
||||
color: string
|
||||
sortIndex: number
|
||||
createdAt: Date | string
|
||||
createdBy?: string | null
|
||||
}
|
||||
|
||||
export interface TagTreeElement {
|
||||
id: string
|
||||
tagName: string
|
||||
color: string
|
||||
// whether the signed-in user keeps this tag out of their own sidebar;
|
||||
// every member of the organization has their own answer
|
||||
hidden: boolean
|
||||
documents?: DocumentTreeElement[] | null
|
||||
|
||||
// local-only
|
||||
localOptimisticInsert?: boolean
|
||||
}
|
||||
|
||||
export type TagTreeResponse = TagTreeElement[]
|
||||
|
||||
export interface TagCreateRequest {
|
||||
tagName: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type TagCreateResponse = Tag
|
||||
|
||||
export interface UnprocessedTagTreeUpdateRequest {
|
||||
id: string
|
||||
insertBeforeId: string | null
|
||||
}
|
||||
|
||||
export interface TagTreeUpdateRequest {
|
||||
id: string
|
||||
sortIndex: number
|
||||
}
|
||||
|
||||
export interface TagVisibilityRequest {
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
export interface DocumentTagRequest {
|
||||
documentId: string
|
||||
tagId: string
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ export * from "./api/auth"
|
|||
export * from "./api/capabilities"
|
||||
export * from "./api/mcp"
|
||||
export * from "./api/document"
|
||||
export * from "./api/tag"
|
||||
export * from "./api/comment"
|
||||
export * from "./api/github"
|
||||
export * from "./api/organization"
|
||||
|
|
@ -14,5 +15,6 @@ export * from "./api/data-source/prometheus"
|
|||
export * from "./api/data-source/generic-query"
|
||||
export * from "./api/data-source/sql"
|
||||
export * from "./api/ai"
|
||||
export * from "./tag-color"
|
||||
export * from "./sanitize"
|
||||
export * from "./promql"
|
||||
|
|
|
|||
24
web/app/utils/tag-color.test.ts
Normal file
24
web/app/utils/tag-color.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, it } from "vitest"
|
||||
import { tagColorFor } from "./tag-color"
|
||||
|
||||
describe("tagColorFor", () => {
|
||||
it("tints a hex colour for both modes", ({ expect }) => {
|
||||
expect(tagColorFor("#123456")).toEqual({
|
||||
lightBg: "color-mix(in srgb, #123456 13%, transparent)",
|
||||
lightFg: "color-mix(in srgb, #123456 80%, black)",
|
||||
darkBg: "color-mix(in srgb, #123456 18%, transparent)",
|
||||
darkBorder: "color-mix(in srgb, #123456 40%, transparent)",
|
||||
darkFg: "color-mix(in srgb, #123456 60%, white)",
|
||||
})
|
||||
})
|
||||
|
||||
it("tints a colour the picker resolved from a theme variable", ({
|
||||
expect,
|
||||
}) => {
|
||||
const treatment = tagColorFor("oklch(0.577 0.245 27.325)")
|
||||
|
||||
expect(treatment.lightBg).toBe(
|
||||
"color-mix(in srgb, oklch(0.577 0.245 27.325) 13%, transparent)",
|
||||
)
|
||||
})
|
||||
})
|
||||
24
web/app/utils/tag-color.ts
Normal file
24
web/app/utils/tag-color.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// TagColorTreatment is how a tag's colour is rendered as a pill. A tag
|
||||
// stores one colour; light and dark each tint it differently, so both are
|
||||
// derived here and handed to css together.
|
||||
export interface TagColorTreatment {
|
||||
lightBg: string
|
||||
lightFg: string
|
||||
darkBg: string
|
||||
darkBorder: string
|
||||
darkFg: string
|
||||
}
|
||||
|
||||
// tagColorFor tints a tag's colour into its pill treatment: 13% behind
|
||||
// darkened text in light mode, 18% behind a 40% border in dark. A tag can
|
||||
// carry any colour the picker offers, so the tints are computed rather
|
||||
// than looked up.
|
||||
export function tagColorFor(color: string): TagColorTreatment {
|
||||
return {
|
||||
lightBg: `color-mix(in srgb, ${color} 13%, transparent)`,
|
||||
lightFg: `color-mix(in srgb, ${color} 80%, black)`,
|
||||
darkBg: `color-mix(in srgb, ${color} 18%, transparent)`,
|
||||
darkBorder: `color-mix(in srgb, ${color} 40%, transparent)`,
|
||||
darkFg: `color-mix(in srgb, ${color} 60%, white)`,
|
||||
}
|
||||
}
|
||||
|
|
@ -753,6 +753,19 @@
|
|||
"approve-all": "Approve all",
|
||||
"decline": "Decline"
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"label": "Tags",
|
||||
"trigger-title": "Manage Tags",
|
||||
"search-placeholder": "Search or create tag…",
|
||||
"create": "Create",
|
||||
"create-hint": "Type a name to create a new tag",
|
||||
"color-title": "Color",
|
||||
"errors": {
|
||||
"toggle-failed": "Failed to update the page's tags. Please try again",
|
||||
"create-failed": "Failed to create the tag. Please try again"
|
||||
},
|
||||
"overflow": "+{count}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@
|
|||
"heading": "Workspace",
|
||||
"heading-action-title": "Add Page"
|
||||
},
|
||||
"tags": {
|
||||
"heading": "Tags",
|
||||
"visibility-action-title": "Choose Visible Tags",
|
||||
"all-hidden": "Every tag is hidden"
|
||||
},
|
||||
"next-steps": {
|
||||
"heading": "Next Steps",
|
||||
"items": {
|
||||
|
|
@ -40,13 +45,20 @@
|
|||
"item-dropdown-menu-buttons": {
|
||||
"add-sub-page": "Add Sub Page",
|
||||
"duplicate-page": "Duplicate Page",
|
||||
"delete-page": "Delete Page"
|
||||
"delete-page": "Delete Page",
|
||||
"remove-tag": "Remove Tag",
|
||||
"delete-tag": "Delete Tag",
|
||||
"hide-tag": "Hide Tag"
|
||||
},
|
||||
"item-placeholder-name": "Add Page",
|
||||
"toggle-sidebar": "Toggle Sidebar",
|
||||
"errors": {
|
||||
"create-document-failed": "Failed to create a page. Please try again",
|
||||
"update-tree-failed": "Failed to update the page location. Please try again",
|
||||
"update-tags-failed": "Failed to update the tag order. Please try again",
|
||||
"update-tag-failed": "Failed to update the tag. Please try again",
|
||||
"delete-tag-failed": "Failed to delete the tag. Please try again",
|
||||
"remove-document-tag-failed": "Failed to remove the tag from the page. Please try again",
|
||||
"signout-failed": "Failed to log out. Please try again"
|
||||
},
|
||||
"search": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user