add document tags to web

This commit is contained in:
swithek 2026-09-03 09:11:32 +02:00
parent 43e271b144
commit 20f43c3ef6
51 changed files with 5856 additions and 232 deletions

View 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
}

View 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
}

View 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)
})
}
}

View 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)
}

View 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"))
}

View File

@ -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
}

View File

@ -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)

View 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
}

View 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)
})
}

View 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"`
}

View 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})
})
}
}

View File

@ -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"),
],
},
}

View File

@ -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);

View File

@ -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]])
})
})
})

View File

@ -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>

View File

@ -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,

View File

@ -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>

View File

@ -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,
}) => {

View File

@ -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>

View 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)
})
})

View 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>

View File

@ -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 }) => {

View File

@ -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"

View 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([])
})
})

View 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>

View File

@ -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)"

View 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")
})
})

View 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>

View File

@ -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)

View File

@ -240,7 +240,7 @@ function addThreshold() {
{
value: undefined,
label: undefined,
color: chartStyles().thresholdColors.default,
color: chartStyles().selectableColors.default,
},
]
}

View File

@ -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()

View File

@ -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<{

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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,

View File

@ -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
}

View File

@ -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", [])

View File

@ -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,

View File

@ -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) =>

View 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])
})
})
})

View 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
}

View File

@ -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"

View File

@ -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)

View File

@ -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
View 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
}

View File

@ -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"

View 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)",
)
})
})

View 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)`,
}
}

View File

@ -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}"
}
}
}

View File

@ -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": {