diff --git a/server/core/internal/server/internal/tag/0mock_db_test.go b/server/core/internal/server/internal/tag/0mock_db_test.go new file mode 100644 index 00000000..9f6b1cf4 --- /dev/null +++ b/server/core/internal/server/internal/tag/0mock_db_test.go @@ -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 +} diff --git a/server/core/internal/server/internal/tag/http.go b/server/core/internal/server/internal/tag/http.go new file mode 100644 index 00000000..f65953d9 --- /dev/null +++ b/server/core/internal/server/internal/tag/http.go @@ -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 +} diff --git a/server/core/internal/server/internal/tag/http_test.go b/server/core/internal/server/internal/tag/http_test.go new file mode 100644 index 00000000..018b1141 --- /dev/null +++ b/server/core/internal/server/internal/tag/http_test.go @@ -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) + }) + } +} diff --git a/server/core/internal/server/internal/tag/ws.go b/server/core/internal/server/internal/tag/ws.go new file mode 100644 index 00000000..a279c197 --- /dev/null +++ b/server/core/internal/server/internal/tag/ws.go @@ -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) +} diff --git a/server/core/internal/server/internal/tag/ws_test.go b/server/core/internal/server/internal/tag/ws_test.go new file mode 100644 index 00000000..464d745b --- /dev/null +++ b/server/core/internal/server/internal/tag/ws_test.go @@ -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")) +} diff --git a/server/core/internal/server/router.go b/server/core/internal/server/router.go index aeb36243..936006dd 100644 --- a/server/core/internal/server/router.go +++ b/server/core/internal/server/router.go @@ -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 } diff --git a/server/core/internal/server/server.go b/server/core/internal/server/server.go index de44ef60..02163697 100644 --- a/server/core/internal/server/server.go +++ b/server/core/internal/server/server.go @@ -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) diff --git a/server/core/internal/tag/stub_db.go b/server/core/internal/tag/stub_db.go new file mode 100644 index 00000000..03de9dad --- /dev/null +++ b/server/core/internal/tag/stub_db.go @@ -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 +} diff --git a/server/core/internal/tag/stub_db_test.go b/server/core/internal/tag/stub_db_test.go new file mode 100644 index 00000000..aafb8ddd --- /dev/null +++ b/server/core/internal/tag/stub_db_test.go @@ -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) + }) +} diff --git a/server/core/internal/tag/tag.go b/server/core/internal/tag/tag.go new file mode 100644 index 00000000..dcb3e8cf --- /dev/null +++ b/server/core/internal/tag/tag.go @@ -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"` +} diff --git a/server/core/internal/tag/tag_test.go b/server/core/internal/tag/tag_test.go new file mode 100644 index 00000000..7e02bd2b --- /dev/null +++ b/server/core/internal/tag/tag_test.go @@ -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}) + }) + } +} diff --git a/web/app/assets/css/index.ts b/web/app/assets/css/index.ts index 2a31268f..e035715e 100644 --- a/web/app/assets/css/index.ts +++ b/web/app/assets/css/index.ts @@ -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"), ], }, } diff --git a/web/app/assets/css/main.css b/web/app/assets/css/main.css index d7963a73..3c1ee09b 100644 --- a/web/app/assets/css/main.css +++ b/web/app/assets/css/main.css @@ -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); diff --git a/web/app/components/AppSidebar.nuxt.test.ts b/web/app/components/AppSidebar.nuxt.test.ts index 30b3336a..8adec599 100644 --- a/web/app/components/AppSidebar.nuxt.test.ts +++ b/web/app/components/AppSidebar.nuxt.test.ts @@ -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>) { return wrapper.findComponent(AppSidebar) } +// each section mounts its own root 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>, + 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>) { + 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>) { + return tagsGroup(wrapper).findAll("[data-item-id]") +} + +function tagNames(wrapper: Awaited>) { + return tagRows(wrapper).map((row) => row.text()) +} + +function tagIds(wrapper: Awaited>) { + 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>, + 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>) { + 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>, name: string, @@ -101,6 +207,11 @@ describe("", { 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("", { concurrent: false }, () => { }) }) - afterEach(disposeMockEndpoints) + afterEach(() => { + disposeMockEndpoints() + // the options menus teleport into the shared 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("", { 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("", { 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("", { 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]]) + }) }) }) diff --git a/web/app/components/AppSidebar.vue b/web/app/components/AppSidebar.vue index ddaa36cf..3a81d1fd 100644 --- a/web/app/components/AppSidebar.vue +++ b/web/app/components/AppSidebar.vue @@ -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 } const sections = computed(() => { 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(() => { 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(() => { 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(() => { 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(() => { 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(() => { 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(() => { 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(() => { if (nextStepsItems.length) { rootItems.push({ + id: "next-steps", heading: t("sidebar.sections.next-steps.heading"), items: nextStepsItems, }) @@ -205,6 +248,7 @@ const sections = computed(() => { 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() { - + {{ section.heading }} @@ -342,15 +490,24 @@ async function installSlack() { {{ section.headingAction.title }} + - +
+ {{ section.emptyMessage }} +
+
diff --git a/web/app/components/SidebarItem.nuxt.test.ts b/web/app/components/SidebarItem.nuxt.test.ts index 2a7b6f0b..da2d8adc 100644 --- a/web/app/components/SidebarItem.nuxt.test.ts +++ b/web/app/components/SidebarItem.nuxt.test.ts @@ -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 { 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 { + return { + id: "duplicate-page", + name: "Duplicate Page", + icon: "mingcute:copy-2-line", + fn: vi.fn(), + ...overrides, + } +} + async function mountItem(item: Item, props: Record = {}) { const wrapper = await mountUnderSidebarProvider(SidebarItem, { props: { parentId: null, item: item, wrapper: null, ...props }, @@ -147,6 +160,110 @@ describe("", { 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("", { 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, diff --git a/web/app/components/SidebarItem.vue b/web/app/components/SidebarItem.vue index 9f168fa1..e580dc14 100644 --- a/web/app/components/SidebarItem.vue +++ b/web/app/components/SidebarItem.vue @@ -1,6 +1,10 @@