diff --git a/domain/activity/store.go b/domain/activity/store.go index ea7d1691..6ce53a20 100644 --- a/domain/activity/store.go +++ b/domain/activity/store.go @@ -29,16 +29,16 @@ type Store struct { } // RecordUserActivity logs user initiated data changes. -func (s Store) RecordUserActivity(ctx domain.RequestContext, activity activity.UserActivity) (err error) { +func (s Store) RecordUserActivity(ctx domain.RequestContext, activity activity.UserActivity) { activity.OrgID = ctx.OrgID activity.UserID = ctx.UserID activity.Created = time.Now().UTC() - _, err = ctx.Transaction.Exec(s.Bind("INSERT INTO dmz_user_activity (c_orgid, c_userid, c_spaceid, c_docid, c_sectionid, c_sourcetype, c_activitytype, c_metadata, c_created) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"), + _, err := ctx.Transaction.Exec(s.Bind("INSERT INTO dmz_user_activity (c_orgid, c_userid, c_spaceid, c_docid, c_sectionid, c_sourcetype, c_activitytype, c_metadata, c_created) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"), activity.OrgID, activity.UserID, activity.SpaceID, activity.DocumentID, activity.SectionID, activity.SourceType, activity.ActivityType, activity.Metadata, activity.Created) if err != nil { - err = errors.Wrap(err, "execute record user activity") + s.Runtime.Log.Error("execute record user activity", err) } return diff --git a/domain/backup/backup.go b/domain/backup/backup.go index c2026309..e401fca1 100644 --- a/domain/backup/backup.go +++ b/domain/backup/backup.go @@ -682,7 +682,7 @@ func (b backerHandler) dmzDocument(files *[]backupItem) (err error) { c_job AS job, c_location AS location, c_name AS name, c_desc AS excerpt, c_slug AS slug, c_tags AS tags, c_template AS template, c_protection AS protection, c_approval AS approval, c_lifecycle AS lifecycle, c_versioned AS versioned, c_versionid AS versionid, - c_versionorder AS versionorder, c_groupid AS groupid, c_created AS created, c_revised AS revised + c_versionorder AS versionorder, c_seq AS sequence, c_groupid AS groupid, c_created AS created, c_revised AS revised FROM dmz_doc`+w) if err != nil { return errors.Wrap(err, "select.document") diff --git a/domain/document/endpoint.go b/domain/document/endpoint.go index ae39d091..94380957 100644 --- a/domain/document/endpoint.go +++ b/domain/document/endpoint.go @@ -14,6 +14,7 @@ package document import ( "database/sql" "encoding/json" + "errors" "io/ioutil" "net/http" "sort" @@ -88,18 +89,12 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { return } - err = h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ SpaceID: document.SpaceID, DocumentID: document.RefID, SourceType: activity.SourceTypeDocument, ActivityType: activity.TypeRead}) - if err != nil { - ctx.Transaction.Rollback() - h.Runtime.Log.Error(method, err) - return - } - ctx.Transaction.Commit() } @@ -190,10 +185,26 @@ func (h *Handler) BySpace(w http.ResponseWriter, r *http.Request) { } } - // Sort document list by title. - sort.Sort(doc.ByName(filtered)) + sortedDocs := doc.SortedDocs{} - response.WriteJSON(w, filtered) + for j := range filtered { + if filtered[j].Sequence == doc.Unsequenced { + sortedDocs.Unpinned = append(sortedDocs.Unpinned, filtered[j]) + } else { + sortedDocs.Pinned = append(sortedDocs.Pinned, filtered[j]) + } + } + + // Sort document list by title. + sort.Sort(doc.ByName(sortedDocs.Unpinned)) + + // Sort document list by sequence. + sort.Sort(doc.BySeq(sortedDocs.Pinned)) + + final := sortedDocs.Pinned + final = append(final, sortedDocs.Unpinned...) + + response.WriteJSON(w, final) } // Update updates an existing document using the format described @@ -249,7 +260,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { } if oldDoc.SpaceID != d.SpaceID { - h.Store.Category.RemoveDocumentCategories(ctx, d.RefID) + _, _ = h.Store.Category.RemoveDocumentCategories(ctx, d.RefID) err = h.Store.Document.MoveActivity(ctx, documentID, oldDoc.SpaceID, d.SpaceID) if err != nil { h.Runtime.Rollback(ctx.Transaction) @@ -312,9 +323,9 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { h.Runtime.Commit(ctx.Transaction) - h.Store.Space.SetStats(ctx, d.SpaceID) + _ = h.Store.Space.SetStats(ctx, d.SpaceID) if oldDoc.SpaceID != d.SpaceID { - h.Store.Space.SetStats(ctx, oldDoc.SpaceID) + _ = h.Store.Space.SetStats(ctx, oldDoc.SpaceID) } h.Store.Audit.Record(ctx, audit.EventTypeDocumentUpdate) @@ -480,18 +491,13 @@ func (h *Handler) SearchDocuments(w http.ResponseWriter, r *http.Request) { return } - err = h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ SpaceID: "", DocumentID: "", Metadata: options.Keywords, SourceType: activity.SourceTypeSearch, ActivityType: activity.TypeSearched}) - if err != nil { - ctx.Transaction.Rollback() - h.Runtime.Log.Error(method, err) - } - ctx.Transaction.Commit() } } @@ -526,18 +532,13 @@ func (h *Handler) recordSearchActivity(ctx domain.RequestContext, q []search.Que } if _, isExisting := prev[q[i].DocumentID]; !isExisting { - err = h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ SpaceID: q[i].SpaceID, DocumentID: q[i].DocumentID, Metadata: keywords, SourceType: activity.SourceTypeSearch, ActivityType: activity.TypeSearched}) - if err != nil { - ctx.Transaction.Rollback() - h.Runtime.Log.Error(method, err) - } - prev[q[i].DocumentID] = true } } @@ -713,16 +714,11 @@ func (h *Handler) FetchDocumentData(w http.ResponseWriter, r *http.Request) { } if document.Lifecycle == workflow.LifecycleLive { - err = h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ SpaceID: document.SpaceID, DocumentID: document.RefID, SourceType: activity.SourceTypeDocument, ActivityType: activity.TypeRead}) - - if err != nil { - ctx.Transaction.Rollback() - h.Runtime.Log.Error(method, err) - } } ctx.Transaction.Commit() @@ -786,7 +782,7 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) - w.Write([]byte(export)) + _, _ = w.Write([]byte(export)) } // Duplicate makes a copy of a document. @@ -1014,3 +1010,219 @@ func (h *Handler) Duplicate(w http.ResponseWriter, r *http.Request) { response.WriteEmpty(w) } + +// Pin marks existing document with sequence number so that it +// appears at the top-most space view. +func (h *Handler) Pin(w http.ResponseWriter, r *http.Request) { + method := "document.Pin" + ctx := domain.GetRequestContext(r) + + documentID := request.Param(r, "documentID") + if len(documentID) == 0 { + response.WriteMissingDataError(w, method, "documentID") + return + } + + var ok bool + ctx.Transaction, ok = h.Runtime.StartTx(sql.LevelReadUncommitted) + if !ok { + h.Runtime.Log.Info("unable to start transaction " + method) + response.WriteServerError(w, method, errors.New("unable to start transaction")) + return + } + + d, err := h.Store.Document.Get(ctx, documentID) + if err != nil { + h.Runtime.Rollback(ctx.Transaction) + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + if !permission.CanManageSpace(ctx, *h.Store, d.SpaceID) { + h.Runtime.Rollback(ctx.Transaction) + response.WriteForbiddenError(w) + return + } + + // Calculate the next sequence number for this newly pinned document. + seq, err := h.Store.Document.PinSequence(ctx, d.SpaceID) + if err != nil { + h.Runtime.Rollback(ctx.Transaction) + h.Runtime.Log.Error(method, err) + response.WriteServerError(w, method, err) + return + } + + err = h.Store.Document.Pin(ctx, documentID, seq+1) + if err != nil { + h.Runtime.Rollback(ctx.Transaction) + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + SpaceID: d.SpaceID, + DocumentID: documentID, + SourceType: activity.SourceTypeDocument, + ActivityType: activity.TypePinned}) + + h.Runtime.Commit(ctx.Transaction) + + response.WriteEmpty(w) +} + +// Unpin removes an existing document from the space pinned list. +func (h *Handler) Unpin(w http.ResponseWriter, r *http.Request) { + method := "document.Unpin" + ctx := domain.GetRequestContext(r) + + documentID := request.Param(r, "documentID") + if len(documentID) == 0 { + response.WriteMissingDataError(w, method, "documentID") + return + } + + var ok bool + ctx.Transaction, ok = h.Runtime.StartTx(sql.LevelReadUncommitted) + if !ok { + h.Runtime.Log.Info("unable to start transaction " + method) + response.WriteServerError(w, method, errors.New("unable to start transaction")) + return + } + + d, err := h.Store.Document.Get(ctx, documentID) + if err != nil { + h.Runtime.Rollback(ctx.Transaction) + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + if !permission.CanManageSpace(ctx, *h.Store, d.SpaceID) { + h.Runtime.Rollback(ctx.Transaction) + response.WriteForbiddenError(w) + return + } + + err = h.Store.Document.Unpin(ctx, documentID) + if err != nil { + h.Runtime.Rollback(ctx.Transaction) + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + SpaceID: d.SpaceID, + DocumentID: documentID, + SourceType: activity.SourceTypeDocument, + ActivityType: activity.TypeUnpinned}) + + h.Runtime.Commit(ctx.Transaction) + + response.WriteEmpty(w) +} + +// PinMove moves pinned document up or down in the sequence. +func (h *Handler) PinMove(w http.ResponseWriter, r *http.Request) { + method := "document.PinMove" + ctx := domain.GetRequestContext(r) + + documentID := request.Param(r, "documentID") + if len(documentID) == 0 { + response.WriteMissingDataError(w, method, "documentID") + return + } + + direction := request.Query(r, "direction") + if len(direction) == 0 { + response.WriteMissingDataError(w, method, "direction") + return + } + + var ok bool + ctx.Transaction, ok = h.Runtime.StartTx(sql.LevelReadUncommitted) + if !ok { + h.Runtime.Log.Info("unable to start transaction " + method) + response.WriteServerError(w, method, errors.New("unable to start transaction")) + return + } + + d, err := h.Store.Document.Get(ctx, documentID) + if err != nil { + h.Runtime.Rollback(ctx.Transaction) + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + if !permission.CanManageSpace(ctx, *h.Store, d.SpaceID) { + h.Runtime.Rollback(ctx.Transaction) + response.WriteForbiddenError(w) + return + } + + // Get all pinned documents in the space. + pinnedDocs, err := h.Store.Document.Pinned(ctx, d.SpaceID) + if err != nil { + h.Runtime.Rollback(ctx.Transaction) + h.Runtime.Log.Error(method, err) + response.WriteServerError(w, method, err) + return + } + + // Sort document list by sequence. + sort.Sort(doc.BySeq(pinnedDocs)) + + // Resequence the documents. + for i := range pinnedDocs { + if pinnedDocs[i].RefID == documentID { + if direction == "u" { + if i-1 >= 0 { + me := pinnedDocs[i].Sequence + target := pinnedDocs[i-1].Sequence + + pinnedDocs[i-1].Sequence = me + pinnedDocs[i].Sequence = target + } + } + if direction == "d" { + if i+1 < len(pinnedDocs) { + me := pinnedDocs[i].Sequence + target := pinnedDocs[i+1].Sequence + + pinnedDocs[i+1].Sequence = me + pinnedDocs[i].Sequence = target + } + } + + break + } + } + + // Sort document list by sequence. + sort.Sort(doc.BySeq(pinnedDocs)) + + // Save the resequenced documents. + for i := range pinnedDocs { + err = h.Store.Document.Pin(ctx, pinnedDocs[i].RefID, i+1) + if err != nil { + h.Runtime.Rollback(ctx.Transaction) + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + } + + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + SpaceID: d.SpaceID, + DocumentID: documentID, + SourceType: activity.SourceTypeDocument, + ActivityType: activity.TypePinSequence}) + + h.Runtime.Commit(ctx.Transaction) + + response.WriteEmpty(w) +} diff --git a/domain/document/store.go b/domain/document/store.go index 3e59ac0d..6ded8c09 100644 --- a/domain/document/store.go +++ b/domain/document/store.go @@ -16,10 +16,11 @@ import ( "fmt" "time" + "github.com/pkg/errors" + "github.com/documize/community/domain" "github.com/documize/community/domain/store" "github.com/documize/community/model/doc" - "github.com/pkg/errors" ) // Store provides data access to space category information. @@ -36,10 +37,12 @@ func (s Store) Add(ctx domain.RequestContext, d doc.Document) (err error) { _, err = ctx.Transaction.Exec(s.Bind(` INSERT INTO dmz_doc (c_refid, c_orgid, c_spaceid, c_userid, c_job, c_location, c_name, c_desc, c_slug, c_tags, - c_template, c_protection, c_approval, c_lifecycle, c_versioned, c_versionid, c_versionorder, c_groupid, c_created, c_revised) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + c_template, c_protection, c_approval, c_lifecycle, c_versioned, c_versionid, c_versionorder, c_seq, c_groupid, + c_created, c_revised) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), d.RefID, d.OrgID, d.SpaceID, d.UserID, d.Job, d.Location, d.Name, d.Excerpt, d.Slug, d.Tags, - d.Template, d.Protection, d.Approval, d.Lifecycle, d.Versioned, d.VersionID, d.VersionOrder, d.GroupID, d.Created, d.Revised) + d.Template, d.Protection, d.Approval, d.Lifecycle, d.Versioned, d.VersionID, d.VersionOrder, d.Sequence, + d.GroupID, d.Created, d.Revised) if err != nil { err = errors.Wrap(err, "execute insert document") @@ -55,7 +58,7 @@ func (s Store) Get(ctx domain.RequestContext, id string) (document doc.Document, c_job AS job, c_location AS location, c_name AS name, c_desc AS excerpt, c_slug AS slug, c_tags AS tags, c_template AS template, c_protection AS protection, c_approval AS approval, c_lifecycle AS lifecycle, c_versioned AS versioned, c_versionid AS versionid, - c_versionorder AS versionorder, c_groupid AS groupid, c_created AS created, c_revised AS revised + c_versionorder AS versionorder, c_seq AS sequence, c_groupid AS groupid, c_created AS created, c_revised AS revised FROM dmz_doc WHERE c_orgid=? AND c_refid=?`), ctx.OrgID, id) @@ -78,7 +81,7 @@ func (s Store) GetBySpace(ctx domain.RequestContext, spaceID string) (documents c_job AS job, c_location AS location, c_name AS name, c_desc AS excerpt, c_slug AS slug, c_tags AS tags, c_template AS template, c_protection AS protection, c_approval AS approval, c_lifecycle AS lifecycle, c_versioned AS versioned, c_versionid AS versionid, - c_versionorder AS versionorder, c_groupid AS groupid, c_created AS created, c_revised AS revised + c_versionorder AS versionorder, c_seq AS sequence, c_groupid AS groupid, c_created AS created, c_revised AS revised FROM dmz_doc WHERE c_orgid=? AND c_template=`+s.IsFalse()+` AND c_spaceid IN (SELECT c_refid FROM dmz_permission WHERE c_orgid=? AND c_location='space' AND c_refid=? AND c_refid IN @@ -111,7 +114,7 @@ func (s Store) TemplatesBySpace(ctx domain.RequestContext, spaceID string) (docu c_job AS job, c_location AS location, c_name AS name, c_desc AS excerpt, c_slug AS slug, c_tags AS tags, c_template AS template, c_protection AS protection, c_approval AS approval, c_lifecycle AS lifecycle, c_versioned AS versioned, c_versionid AS versionid, - c_versionorder AS versionorder, c_groupid AS groupid, c_created AS created, c_revised AS revised + c_versionorder AS versionorder, c_seq AS sequence, c_groupid AS groupid, c_created AS created, c_revised AS revised FROM dmz_doc WHERE c_orgid=? AND c_spaceid=? AND c_template=`+s.IsTrue()+` AND c_lifecycle=1 AND c_spaceid IN @@ -167,7 +170,8 @@ func (s Store) Update(ctx domain.RequestContext, document doc.Document) (err err c_spaceid=:spaceid, c_userid=:userid, c_job=:job, c_location=:location, c_name=:name, c_desc=:excerpt, c_slug=:slug, c_tags=:tags, c_template=:template, c_protection=:protection, c_approval=:approval, c_lifecycle=:lifecycle, - c_versioned=:versioned, c_versionid=:versionid, c_versionorder=:versionorder, + c_versioned=:versioned, c_versionid=:versionid, c_versionorder=:versionorder, + c_seq=:sequence, c_groupid=:groupid, c_revised=:revised WHERE c_orgid=:orgid AND c_refid=:refid`), &document) @@ -331,3 +335,78 @@ func (s Store) GetVersions(ctx domain.RequestContext, groupID string) (v []doc.V return } + +// Pin allocates sequence number to specified document so that it appears +// at the documents list. +func (s Store) Pin(ctx domain.RequestContext, documentID string, seq int) (err error) { + _, err = ctx.Transaction.Exec(s.Bind("UPDATE dmz_doc SET c_seq=? WHERE c_orgid=? AND c_refid=?"), + seq, ctx.OrgID, documentID) + + if err != nil { + err = errors.Wrap(err, "document.store.Pin") + } + + return +} + +// Unpin resets sequence number for given document. +func (s Store) Unpin(ctx domain.RequestContext, documentID string) (err error) { + _, err = ctx.Transaction.Exec(s.Bind("UPDATE dmz_doc SET c_seq=? WHERE c_orgid=? AND c_refid=?"), + doc.Unsequenced, ctx.OrgID, documentID) + + if err != nil { + err = errors.Wrap(err, "document.store.Unpin") + } + + return +} + +// PinSequence fectches pinned documents and returns current +// maximum sequence value. +func (s Store) PinSequence(ctx domain.RequestContext, spaceID string) (max int, err error) { + max = 0 + + err = s.Runtime.Db.Get(&max, s.Bind(` + SELECT MAX(c_seq) + FROM dmz_doc + WHERE c_orgid=? AND c_spaceid=? + AND c_seq != 99999`), + ctx.OrgID, spaceID) + + if err == sql.ErrNoRows { + err = nil + } + if err != nil { + max = doc.Unsequenced + err = errors.Wrap(err, "document.store.PinSequence") + } + + return +} + +// Pinned documents for space are fetched. +func (s Store) Pinned(ctx domain.RequestContext, spaceID string) (d []doc.Document, err error) { + d = []doc.Document{} + + err = s.Runtime.Db.Select(&d, s.Bind(` + SELECT id, c_refid AS refid, c_orgid AS orgid, c_spaceid AS spaceid, c_userid AS userid, + c_job AS job, c_location AS location, c_name AS name, c_desc AS excerpt, c_slug AS slug, + c_tags AS tags, c_template AS template, c_protection AS protection, c_approval AS approval, + c_lifecycle AS lifecycle, c_versioned AS versioned, c_versionid AS versionid, + c_versionorder AS versionorder, c_seq AS sequence, c_groupid AS groupid, + c_created AS created, c_revised AS revised + FROM dmz_doc + WHERE c_orgid=? AND c_spaceid=? + AND c_seq != 99999 + ORDER BY c_seq`), + ctx.OrgID, spaceID) + + if err == sql.ErrNoRows { + err = nil + } + if err != nil { + err = errors.Wrap(err, "document.store.Pinned") + } + + return +} diff --git a/domain/page/endpoint.go b/domain/page/endpoint.go index d4744038..ea3fa2e8 100644 --- a/domain/page/endpoint.go +++ b/domain/page/endpoint.go @@ -1504,18 +1504,13 @@ func (h *Handler) FetchPages(w http.ResponseWriter, r *http.Request) { if err != nil { h.Runtime.Log.Error(method, err) } else { - err = h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ SpaceID: doc.SpaceID, DocumentID: doc.RefID, Metadata: source, // deliberate SourceType: activity.SourceTypeSearch, // deliberate ActivityType: activity.TypeRead}) - if err != nil { - ctx.Transaction.Rollback() - h.Runtime.Log.Error(method, err) - } - ctx.Transaction.Commit() } } diff --git a/domain/space/endpoint.go b/domain/space/endpoint.go index c944ae02..c579aaa6 100644 --- a/domain/space/endpoint.go +++ b/domain/space/endpoint.go @@ -138,14 +138,10 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { return } - err = h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ SpaceID: sp.RefID, SourceType: activity.SourceTypeSpace, ActivityType: activity.TypeCreated}) - if err != nil { - ctx.Transaction.Rollback() - h.Runtime.Log.Error(method, err) - } ctx.Transaction.Commit() @@ -444,16 +440,11 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { return } - err = h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ SpaceID: sp.RefID, SourceType: activity.SourceTypeSpace, ActivityType: activity.TypeRead}) - if err != nil { - ctx.Transaction.Rollback() - h.Runtime.Log.Error(method, err) - } - ctx.Transaction.Commit() response.WriteJSON(w, sp) @@ -657,14 +648,10 @@ func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) { return } - err = h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ SpaceID: id, SourceType: activity.SourceTypeSpace, ActivityType: activity.TypeDeleted}) - if err != nil { - ctx.Transaction.Rollback() - h.Runtime.Log.Error(method, err) - } ctx.Transaction.Commit() @@ -764,14 +751,10 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } - err = h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ SpaceID: id, SourceType: activity.SourceTypeSpace, ActivityType: activity.TypeDeleted}) - if err != nil { - h.Runtime.Rollback(ctx.Transaction) - response.WriteServerError(w, method, err) - } h.Runtime.Commit(ctx.Transaction) diff --git a/domain/store/storer.go b/domain/store/storer.go index fe35907c..c7fd93d4 100644 --- a/domain/store/storer.go +++ b/domain/store/storer.go @@ -190,6 +190,10 @@ type DocumentStorer interface { DeleteBySpace(ctx domain.RequestContext, spaceID string) (rows int64, err error) GetVersions(ctx domain.RequestContext, groupID string) (v []doc.Version, err error) MoveActivity(ctx domain.RequestContext, documentID, oldSpaceID, newSpaceID string) (err error) + Pin(ctx domain.RequestContext, documentID string, seq int) (err error) + Unpin(ctx domain.RequestContext, documentID string) (err error) + PinSequence(ctx domain.RequestContext, spaceID string) (max int, err error) + Pinned(ctx domain.RequestContext, spaceID string) (d []doc.Document, err error) } // SettingStorer defines required methods for persisting global and user level settings @@ -228,7 +232,7 @@ type LinkStorer interface { // ActivityStorer defines required methods for persisting document activity type ActivityStorer interface { - RecordUserActivity(ctx domain.RequestContext, activity activity.UserActivity) (err error) + RecordUserActivity(ctx domain.RequestContext, activity activity.UserActivity) GetDocumentActivity(ctx domain.RequestContext, id string) (a []activity.DocumentActivity, err error) DeleteDocumentChangeActivity(ctx domain.RequestContext, id string) (rows int64, err error) } diff --git a/gui/app/components/folder/documents-list.js b/gui/app/components/folder/documents-list.js index 13e07ae6..2a30038d 100644 --- a/gui/app/components/folder/documents-list.js +++ b/gui/app/components/folder/documents-list.js @@ -31,6 +31,9 @@ export default Component.extend({ hasDocumentActions: computed('permissions.{documentDelete,documentMove}', function() { return this.get('permissions.documentDelete') || this.get('permissions.documentMove'); }), + hasCategoryFilter: computed('categoryFilter', function() { + return !_.isEmpty(this.get('categoryFilter')); + }), didReceiveAttrs() { this._super(...arguments); @@ -53,7 +56,7 @@ export default Component.extend({ let viewDensity = this.get('localStorage').getSessionItem('space.density'); if (!_.isNull(viewDensity) && !_.isUndefined(viewDensity)) { this.set('viewDensity', viewDensity); - } + } }, actions: { @@ -86,7 +89,7 @@ export default Component.extend({ }, // eslint-disable-next-line no-unused-vars - onSortBy(attacher) { + onSortBy(attacher) { // attacher.hide(); this.get('onFiltered')(this.get('documents')); }, @@ -161,6 +164,18 @@ export default Component.extend({ this.set('selectedCaption', list.length > 1 ? 'documents' : 'document'); this.set('selectedDocuments', A(list)); - } + }, + + onPin(documentId) { + this.get('onPin')(documentId); + }, + + onUnpin(documentId) { + this.get('onUnpin')(documentId); + }, + + onPinSequence(documentId, direction) { + this.get('onPinSequence')(documentId, direction); + }, } }); diff --git a/gui/app/components/folder/space-sidebar.js b/gui/app/components/folder/space-sidebar.js index 12574aad..98e7aedd 100644 --- a/gui/app/components/folder/space-sidebar.js +++ b/gui/app/components/folder/space-sidebar.js @@ -36,7 +36,7 @@ export default Component.extend(AuthMixin, { this.setup(); }, - didReceiveAttrs() { + didUpdateAttrs() { this._super(...arguments); this.setup(); }, @@ -138,6 +138,7 @@ export default Component.extend(AuthMixin, { this.set('selectedFilter', filter); this.set('categories', categories); + this.get('onFiltered')(filtered); } } diff --git a/gui/app/constants/constants.js b/gui/app/constants/constants.js index a2f49775..05550ce8 100644 --- a/gui/app/constants/constants.js +++ b/gui/app/constants/constants.js @@ -15,6 +15,8 @@ import EmberObject from "@ember/object"; // let constants = this.get('constants'); let constants = EmberObject.extend({ + Unsequenced: 99999, + SpaceType: { // eslint-disable-line ember/avoid-leaking-state-in-ember-objects Public: 1, Private: 2, diff --git a/gui/app/models/document.js b/gui/app/models/document.js index 729ef62f..03d93406 100644 --- a/gui/app/models/document.js +++ b/gui/app/models/document.js @@ -30,6 +30,7 @@ export default Model.extend({ versioned: attr('boolean'), versionId: attr('string'), versionOrder: attr('number', { defaultValue: 0 }), + sequence: attr('number', { defaultValue: 99999 }), groupId: attr('string'), created: attr(), revised: attr(), @@ -46,12 +47,12 @@ export default Model.extend({ isDraft: computed('lifecycle', function () { let constants = this.get('constants'); - return this.get('lifecycle') == constants.Lifecycle.Draft; + return this.get('lifecycle') === constants.Lifecycle.Draft; }), isLive: computed('lifecycle', function () { let constants = this.get('constants'); - return this.get('lifecycle') == constants.Lifecycle.Live; + return this.get('lifecycle') === constants.Lifecycle.Live; }), lifecycleLabel: computed('lifecycle', function () { @@ -77,5 +78,10 @@ export default Model.extend({ let after = moment().subtract(7, 'days'); return moment(this.get('revised')).isSameOrAfter(after) && moment(this.get('created')).isBefore(after); - }) + }), + + isSequenced: computed('sequence', function () { + let constants = this.get('constants'); + return this.get('sequence') !== constants.Unsequenced; + }), }); diff --git a/gui/app/pods/folder/index/controller.js b/gui/app/pods/folder/index/controller.js index bfa6d356..fdf68002 100644 --- a/gui/app/pods/folder/index/controller.js +++ b/gui/app/pods/folder/index/controller.js @@ -95,29 +95,54 @@ export default Controller.extend(NotifierMixin, { onFiltered(docs) { let ls = this.get('localStorage'); let sortBy = this.get('sortBy'); + let constants = this.get('constants'); if (_.isNull(docs)) return; + let pinned = _.filter(docs, function(d) { return d.get('sequence') !== constants.Unsequenced; }) + let unpinned = _.filter(docs, function(d) { return d.get('sequence') === constants.Unsequenced; }) + if (sortBy.name) { - docs = docs.sortBy('name'); + unpinned = unpinned.sortBy('name'); ls.storeSessionItem('space.sortBy', 'name'); } if (sortBy.created) { - docs = docs.sortBy('created'); + unpinned = unpinned.sortBy('created'); ls.storeSessionItem('space.sortBy', 'created'); } if (sortBy.updated) { - docs = docs.sortBy('revised'); + unpinned = unpinned.sortBy('revised'); ls.storeSessionItem('space.sortBy', 'updated'); } if (sortBy.desc) { - docs = docs.reverseObjects(); + unpinned = unpinned.reverseObjects(); ls.storeSessionItem('space.sortOrder', 'desc'); } else { ls.storeSessionItem('space.sortOrder', 'asc'); } - this.set('filteredDocs', docs); - } + this.set('filteredDocs', _.concat(pinned, unpinned)); + }, + + onPin(documentId) { + this.get('documentSvc').pin(documentId).then(() => { + this.notifySuccess('Pinned'); + this.send('onRefresh'); + }); + }, + + onUnpin(documentId) { + this.get('documentSvc').unpin(documentId).then(() => { + this.notifySuccess('Unpinned'); + this.send('onRefresh'); + }); + }, + + onPinSequence(documentId, direction) { + this.get('documentSvc').onPinSequence(documentId, direction).then(() => { + this.notifySuccess('Moved'); + this.send('onRefresh'); + }); + }, } }); diff --git a/gui/app/pods/folder/index/template.hbs b/gui/app/pods/folder/index/template.hbs index ea2d137a..e9b317a5 100644 --- a/gui/app/pods/folder/index/template.hbs +++ b/gui/app/pods/folder/index/template.hbs @@ -71,7 +71,11 @@ space=model.folder templates=model.templates permissions=model.permissions + categoryFilter=category sortBy=sortBy + onPin=(action "onPin") + onUnpin=(action "onUnpin") + onPinSequence=(action "onPinSequence") onFiltered=(action "onFiltered") onExportDocument=(action "onExportDocument") onDeleteDocument=(action "onDeleteDocument") diff --git a/gui/app/services/document.js b/gui/app/services/document.js index f7eab913..5d95294a 100644 --- a/gui/app/services/document.js +++ b/gui/app/services/document.js @@ -567,7 +567,37 @@ export default Service.extend({ }).catch((error) => { return error; }); - } + }, + + //************************************************** + // Pinning documents inside spaces. + //************************************************** + + // Pin document + pin(documentId) { + return this.get('ajax').request(`document/pin/${documentId}`, { + method: 'POST' + }).then((response) => { + return response; + }); + }, + + // Unpin document + unpin(documentId) { + return this.get('ajax').request(`document/unpin/${documentId}`, { + method: 'DELETE' + }).then((response) => { + return response; + }); + }, + + onPinSequence(documentId, direction) { + return this.get('ajax').request(`document/pinmove/${documentId}?direction=${direction}`, { + method: 'POST' + }).then((response) => { + return response; + }); + } }); function isObject(a) { diff --git a/gui/app/styles/core/view/space.scss b/gui/app/styles/core/view/space.scss index dcffcc43..5c8dafee 100644 --- a/gui/app/styles/core/view/space.scss +++ b/gui/app/styles/core/view/space.scss @@ -34,6 +34,10 @@ > .checkbox { display: block; } + + > .sequence { + display: block; + } } > .checkbox { @@ -51,6 +55,21 @@ } } + > .sequence { + position: absolute; + display: none; + top: 10px; + right: 40px; + cursor: pointer; + + > .dicon { + color: map-get($yellow-shades, 700); + cursor: pointer; + font-weight: 600; + font-size: 20px; + } + } + > .actions { position: absolute; display: none; @@ -68,6 +87,17 @@ font-size: 1.3rem; font-weight: 700; color: map-get($gray-shades, 800); + + > .pinned { + display: inline-block; + margin-left: 10px; + + > .dicon { + font-size: 0.9rem; + font-weight: 500; + color: map-get($gray-shades, 600); + } + } } > .desc { @@ -168,6 +198,10 @@ display: block; } + > .sequence { + display: block; + } + > .actions { display: block; } diff --git a/gui/app/templates/components/folder/documents-list.hbs b/gui/app/templates/components/folder/documents-list.hbs index b5d6e52e..4f62778f 100644 --- a/gui/app/templates/components/folder/documents-list.hbs +++ b/gui/app/templates/components/folder/documents-list.hbs @@ -63,13 +63,22 @@ {{#each documents key="id" as |document|}}
  • {{#link-to "document.index" space.id space.slug document.id document.slug class="info"}} -
    {{ document.name }}
    +
    + {{ document.name }} + {{#if document.isSequenced}} +
    + + {{#attach-tooltip showDelay=250}}Pinned{{/attach-tooltip}} + +
    + {{/if}} +
    {{#if (not-eq viewDensity "3")}}
    {{ document.excerpt }}
    {{/if}} {{#if (eq viewDensity "1")}}
    -
    +
    @@ -83,6 +92,25 @@ {{/link-to}} {{#if hasDocumentActions}} +
    + {{#if document.isSequenced}} + {{#unless hasCategoryFilter}} + + {{#attach-tooltip showDelay=250}}Move up{{/attach-tooltip}} + + + {{#attach-tooltip showDelay=250}}Move down{{/attach-tooltip}} + + {{/unless}} + + {{#attach-tooltip showDelay=250}}Unpin{{/attach-tooltip}} + + {{else}} + + {{#attach-tooltip showDelay=250}}Pin{{/attach-tooltip}} + + {{/if}} +
    {{#if document.selected}} diff --git a/model/activity/activity.go b/model/activity/activity.go index a24a6d7e..a21e1d5d 100644 --- a/model/activity/activity.go +++ b/model/activity/activity.go @@ -115,6 +115,15 @@ const ( // TypePublished happens when a document is moved from Draft to Live. TypePublished Type = 16 + + // TypePinned happens when a document is pinned within space. + TypePinned Type = 17 + + // TypeUnpinned happens when a document is no longer pinned inside a space. + TypeUnpinned Type = 18 + + // TypePinSequence is when the order of sequenced documents is changed. + TypePinSequence Type = 19 ) // TypeName returns one-work descriptor for activity type @@ -152,6 +161,12 @@ func TypeName(t Type) string { return "Search" case TypePublished: return "Publish" + case TypePinned: + return "Pinned" + case TypeUnpinned: + return "Unpinned" + case TypePinSequence: + return "Sequence" } return "" diff --git a/model/doc/doc.go b/model/doc/doc.go index 0c6900c7..38dee6d6 100644 --- a/model/doc/doc.go +++ b/model/doc/doc.go @@ -38,6 +38,7 @@ type Document struct { Versioned bool `json:"versioned"` VersionID string `json:"versionId"` VersionOrder int `json:"versionOrder"` + Sequence int `json:"sequence"` GroupID string `json:"groupId"` // Read-only presentation only data @@ -67,6 +68,13 @@ func (a ByID) Len() int { return len(a) } func (a ByID) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByID) Less(i, j int) bool { return a[i].RefID > a[j].RefID } +// BySeq sorts a collection of documents by sequenced number. +type BySeq []Document + +func (a BySeq) Len() int { return len(a) } +func (a BySeq) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a BySeq) Less(i, j int) bool { return a[i].Sequence < a[j].Sequence } + // DocumentMeta details who viewed the document. type DocumentMeta struct { Viewers []DocumentMetaViewer `json:"viewers"` @@ -118,3 +126,15 @@ type DuplicateModel struct { DocumentID string `json:"documentId"` Name string `json:"documentName"` } + +// SortedDocs provides list od pinned and unpinned documents +// sorted by sequence and name respectively. +type SortedDocs struct { + Pinned []Document `json:"pinned"` + Unpinned []Document `json:"unpinned"` +} + +const ( + // Unsequenced tells us if document is pinned or not + Unsequenced int = 99999 +)