mirror of
https://github.com/documize/community.git
synced 2025-07-19 05:09:42 +02:00
Content liking
New per space option that allows users to like/dislike content. The prompt is configurable per space, e.g. "Was this useful?". Enterprise edition gets new Votes report providing insights into most/least liked content.
This commit is contained in:
parent
6c8f23792c
commit
22b6674edb
23 changed files with 1031 additions and 694 deletions
|
@ -52,9 +52,9 @@ Space view.
|
||||||
|
|
||||||
## Latest version
|
## Latest version
|
||||||
|
|
||||||
[Community edition: v1.61.0](https://github.com/documize/community/releases)
|
[Community edition: v1.62.0](https://github.com/documize/community/releases)
|
||||||
|
|
||||||
[Enterprise edition: v1.63.0](https://documize.com/downloads)
|
[Enterprise edition: v1.64.0](https://documize.com/downloads)
|
||||||
|
|
||||||
## OS support
|
## OS support
|
||||||
|
|
||||||
|
|
27
core/database/scripts/autobuild/db_00021.sql
Normal file
27
core/database/scripts/autobuild/db_00021.sql
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
/* community edition */
|
||||||
|
|
||||||
|
-- content likes/feedback
|
||||||
|
DROP TABLE IF EXISTS `vote`;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `vote` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`refid` CHAR(16) NOT NULL COLLATE utf8_bin,
|
||||||
|
`orgid` CHAR(16) NOT NULL COLLATE utf8_bin,
|
||||||
|
`documentid` CHAR(16) NOT NULL COLLATE utf8_bin,
|
||||||
|
`voter` CHAR(16) NOT NULL DEFAULT '' COLLATE utf8_bin,
|
||||||
|
`vote` INT NOT NULL DEFAULT 0,
|
||||||
|
`created` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`revised` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE INDEX `idx_vote_id` (`id` ASC),
|
||||||
|
INDEX `idx_vote_refid` (`refid` ASC),
|
||||||
|
INDEX `idx_vote_documentid` (`documentid` ASC),
|
||||||
|
INDEX `idx_vote_orgid` (`orgid` ASC))
|
||||||
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
ENGINE = InnoDB;
|
||||||
|
|
||||||
|
CREATE INDEX idx_vote_1 ON vote(orgid,documentid);
|
||||||
|
|
||||||
|
-- liking
|
||||||
|
ALTER TABLE label ADD COLUMN `likes` VARCHAR(1000) NOT NULL DEFAULT '' AFTER `type`;
|
||||||
|
|
||||||
|
-- deprecations
|
|
@ -24,7 +24,9 @@ import (
|
||||||
"github.com/documize/community/core/response"
|
"github.com/documize/community/core/response"
|
||||||
"github.com/documize/community/core/streamutil"
|
"github.com/documize/community/core/streamutil"
|
||||||
"github.com/documize/community/core/stringutil"
|
"github.com/documize/community/core/stringutil"
|
||||||
|
"github.com/documize/community/core/uniqueid"
|
||||||
"github.com/documize/community/domain"
|
"github.com/documize/community/domain"
|
||||||
|
"github.com/documize/community/domain/organization"
|
||||||
"github.com/documize/community/domain/permission"
|
"github.com/documize/community/domain/permission"
|
||||||
indexer "github.com/documize/community/domain/search"
|
indexer "github.com/documize/community/domain/search"
|
||||||
"github.com/documize/community/model/activity"
|
"github.com/documize/community/model/activity"
|
||||||
|
@ -626,3 +628,72 @@ type BulkDocumentData struct {
|
||||||
Links []link.Link `json:"links"`
|
Links []link.Link `json:"links"`
|
||||||
Versions []doc.Version `json:"versions"`
|
Versions []doc.Version `json:"versions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vote records document content vote, Yes, No.
|
||||||
|
// Anonymous users should be assigned a temporary ID
|
||||||
|
func (h *Handler) Vote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
method := "document.Vote"
|
||||||
|
ctx := domain.GetRequestContext(r)
|
||||||
|
|
||||||
|
// Deduce ORG because public API call.
|
||||||
|
ctx.Subdomain = organization.GetSubdomainFromHost(r)
|
||||||
|
org, err := h.Store.Organization.GetOrganizationByDomain(ctx.Subdomain)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteServerError(w, method, err)
|
||||||
|
h.Runtime.Log.Error(method, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.OrgID = org.RefID
|
||||||
|
|
||||||
|
documentID := request.Param(r, "documentID")
|
||||||
|
if len(documentID) == 0 {
|
||||||
|
response.WriteMissingDataError(w, method, "documentID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer streamutil.Close(r.Body)
|
||||||
|
body, err := ioutil.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteBadRequestError(w, method, err.Error())
|
||||||
|
h.Runtime.Log.Error(method, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Vote int `json:"vote"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, &payload)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteBadRequestError(w, method, err.Error())
|
||||||
|
h.Runtime.Log.Error(method, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Transaction, err = h.Runtime.Db.Beginx()
|
||||||
|
if err != nil {
|
||||||
|
response.WriteServerError(w, method, err)
|
||||||
|
h.Runtime.Log.Error(method, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := h.Store.Document.Get(ctx, documentID)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteServerError(w, method, err)
|
||||||
|
h.Runtime.Log.Error(method, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.Store.Document.Vote(ctx, uniqueid.Generate(), doc.OrgID, documentID, payload.UserID, payload.Vote)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Transaction.Rollback()
|
||||||
|
response.WriteServerError(w, method, err)
|
||||||
|
h.Runtime.Log.Error(method, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Transaction.Commit()
|
||||||
|
|
||||||
|
response.WriteEmpty(w)
|
||||||
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ func (s Scope) Add(ctx domain.RequestContext, d doc.Document) (err error) {
|
||||||
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.GroupID, d.Created, d.Revised)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = errors.Wrap(err, "execuet insert document")
|
err = errors.Wrap(err, "execute insert document")
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -264,23 +264,28 @@ func (s Scope) MoveActivity(ctx domain.RequestContext, documentID, oldSpaceID, n
|
||||||
// Remove document pages, revisions, attachments, updates the search subsystem.
|
// Remove document pages, revisions, attachments, updates the search subsystem.
|
||||||
func (s Scope) Delete(ctx domain.RequestContext, documentID string) (rows int64, err error) {
|
func (s Scope) Delete(ctx domain.RequestContext, documentID string) (rows int64, err error) {
|
||||||
b := mysql.BaseQuery{}
|
b := mysql.BaseQuery{}
|
||||||
rows, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from page WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
|
rows, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM page WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from revision WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
|
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM revision WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from attachment WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
|
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM attachment WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from categorymember WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
|
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM categorymember WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM vote WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -292,18 +297,23 @@ func (s Scope) Delete(ctx domain.RequestContext, documentID string) (rows int64,
|
||||||
// Remove document pages, revisions, attachments, updates the search subsystem.
|
// Remove document pages, revisions, attachments, updates the search subsystem.
|
||||||
func (s Scope) DeleteBySpace(ctx domain.RequestContext, spaceID string) (rows int64, err error) {
|
func (s Scope) DeleteBySpace(ctx domain.RequestContext, spaceID string) (rows int64, err error) {
|
||||||
b := mysql.BaseQuery{}
|
b := mysql.BaseQuery{}
|
||||||
rows, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from page WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
|
rows, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM page WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from revision WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
|
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM revision WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from attachment WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
|
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM attachment WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM vote WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -336,3 +346,24 @@ func (s Scope) GetVersions(ctx domain.RequestContext, groupID string) (v []doc.V
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vote records document content vote.
|
||||||
|
// Any existing vote by the user is replaced.
|
||||||
|
func (s Scope) Vote(ctx domain.RequestContext, refID, orgID, documentID, userID string, vote int) (err error) {
|
||||||
|
b := mysql.BaseQuery{}
|
||||||
|
|
||||||
|
_, err = b.DeleteWhere(ctx.Transaction,
|
||||||
|
fmt.Sprintf("DELETE FROM vote WHERE orgid=\"%s\" AND documentid=\"%s\" AND voter=\"%s\"",
|
||||||
|
orgID, documentID, userID))
|
||||||
|
if err != nil {
|
||||||
|
s.Runtime.Log.Error("store.Vote", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ctx.Transaction.Exec(`INSERT INTO vote (refid, orgid, documentid, voter, vote) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
refID, orgID, documentID, userID, vote)
|
||||||
|
if err != nil {
|
||||||
|
err = errors.Wrap(err, "execute insert vote")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
|
@ -35,8 +35,8 @@ func (s Scope) Add(ctx domain.RequestContext, sp space.Space) (err error) {
|
||||||
sp.Created = time.Now().UTC()
|
sp.Created = time.Now().UTC()
|
||||||
sp.Revised = time.Now().UTC()
|
sp.Revised = time.Now().UTC()
|
||||||
|
|
||||||
_, err = ctx.Transaction.Exec("INSERT INTO label (refid, label, orgid, userid, type, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
_, err = ctx.Transaction.Exec("INSERT INTO label (refid, label, orgid, userid, type, likes, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
sp.RefID, sp.Name, sp.OrgID, sp.UserID, sp.Type, sp.Created, sp.Revised)
|
sp.RefID, sp.Name, sp.OrgID, sp.UserID, sp.Type, sp.Likes, sp.Created, sp.Revised)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = errors.Wrap(err, "unable to execute insert for label")
|
err = errors.Wrap(err, "unable to execute insert for label")
|
||||||
|
@ -47,7 +47,7 @@ func (s Scope) Add(ctx domain.RequestContext, sp space.Space) (err error) {
|
||||||
|
|
||||||
// Get returns a space from the store.
|
// Get returns a space from the store.
|
||||||
func (s Scope) Get(ctx domain.RequestContext, id string) (sp space.Space, err error) {
|
func (s Scope) Get(ctx domain.RequestContext, id string) (sp space.Space, err error) {
|
||||||
err = s.Runtime.Db.Get(&sp, "SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label WHERE orgid=? and refid=?",
|
err = s.Runtime.Db.Get(&sp, "SELECT id,refid,label as name,orgid,userid,type,likes,created,revised FROM label WHERE orgid=? and refid=?",
|
||||||
ctx.OrgID, id)
|
ctx.OrgID, id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -59,7 +59,7 @@ func (s Scope) Get(ctx domain.RequestContext, id string) (sp space.Space, err er
|
||||||
|
|
||||||
// PublicSpaces returns spaces that anyone can see.
|
// PublicSpaces returns spaces that anyone can see.
|
||||||
func (s Scope) PublicSpaces(ctx domain.RequestContext, orgID string) (sp []space.Space, err error) {
|
func (s Scope) PublicSpaces(ctx domain.RequestContext, orgID string) (sp []space.Space, err error) {
|
||||||
qry := "SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label a where orgid=? AND type=1"
|
qry := "SELECT id,refid,label as name,orgid,userid,type,likes,created,revised FROM label a where orgid=? AND type=1"
|
||||||
|
|
||||||
err = s.Runtime.Db.Select(&sp, qry, orgID)
|
err = s.Runtime.Db.Select(&sp, qry, orgID)
|
||||||
|
|
||||||
|
@ -78,7 +78,7 @@ func (s Scope) PublicSpaces(ctx domain.RequestContext, orgID string) (sp []space
|
||||||
// Also handles which spaces can be seen by anonymous users.
|
// Also handles which spaces can be seen by anonymous users.
|
||||||
func (s Scope) GetViewable(ctx domain.RequestContext) (sp []space.Space, err error) {
|
func (s Scope) GetViewable(ctx domain.RequestContext) (sp []space.Space, err error) {
|
||||||
q := `
|
q := `
|
||||||
SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label
|
SELECT id,refid,label as name,orgid,userid,type,likes,created,revised FROM label
|
||||||
WHERE orgid=?
|
WHERE orgid=?
|
||||||
AND refid IN (SELECT refid FROM permission WHERE orgid=? AND location='space' AND refid IN (
|
AND refid IN (SELECT refid FROM permission WHERE orgid=? AND location='space' AND refid IN (
|
||||||
SELECT refid from permission WHERE orgid=? AND who='user' AND (whoid=? OR whoid='0') AND location='space' AND action='view' UNION ALL
|
SELECT refid from permission WHERE orgid=? AND who='user' AND (whoid=? OR whoid='0') AND location='space' AND action='view' UNION ALL
|
||||||
|
@ -109,7 +109,7 @@ func (s Scope) GetViewable(ctx domain.RequestContext) (sp []space.Space, err err
|
||||||
// GetAll for admin users!
|
// GetAll for admin users!
|
||||||
func (s Scope) GetAll(ctx domain.RequestContext) (sp []space.Space, err error) {
|
func (s Scope) GetAll(ctx domain.RequestContext) (sp []space.Space, err error) {
|
||||||
qry := `
|
qry := `
|
||||||
SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label
|
SELECT id,refid,label as name,orgid,userid,type,likes,created,revised FROM label
|
||||||
WHERE orgid=?
|
WHERE orgid=?
|
||||||
ORDER BY name`
|
ORDER BY name`
|
||||||
|
|
||||||
|
@ -130,7 +130,7 @@ func (s Scope) GetAll(ctx domain.RequestContext) (sp []space.Space, err error) {
|
||||||
func (s Scope) Update(ctx domain.RequestContext, sp space.Space) (err error) {
|
func (s Scope) Update(ctx domain.RequestContext, sp space.Space) (err error) {
|
||||||
sp.Revised = time.Now().UTC()
|
sp.Revised = time.Now().UTC()
|
||||||
|
|
||||||
_, err = ctx.Transaction.NamedExec("UPDATE label SET label=:name, type=:type, userid=:userid, revised=:revised WHERE orgid=:orgid AND refid=:refid", &sp)
|
_, err = ctx.Transaction.NamedExec("UPDATE label SET label=:name, type=:type, userid=:userid, likes=:likes, revised=:revised WHERE orgid=:orgid AND refid=:refid", &sp)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = errors.Wrap(err, fmt.Sprintf("unable to execute update for label %s", sp.RefID))
|
err = errors.Wrap(err, fmt.Sprintf("unable to execute update for label %s", sp.RefID))
|
||||||
|
|
|
@ -181,6 +181,7 @@ type DocumentStorer interface {
|
||||||
DeleteBySpace(ctx RequestContext, spaceID string) (rows int64, err error)
|
DeleteBySpace(ctx RequestContext, spaceID string) (rows int64, err error)
|
||||||
GetVersions(ctx RequestContext, groupID string) (v []doc.Version, err error)
|
GetVersions(ctx RequestContext, groupID string) (v []doc.Version, err error)
|
||||||
MoveActivity(ctx RequestContext, documentID, oldSpaceID, newSpaceID string) (err error)
|
MoveActivity(ctx RequestContext, documentID, oldSpaceID, newSpaceID string) (err error)
|
||||||
|
Vote(ctx RequestContext, refID, orgID, documentID, userID string, vote int) (err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingStorer defines required methods for persisting global and user level settings
|
// SettingStorer defines required methods for persisting global and user level settings
|
||||||
|
|
|
@ -41,7 +41,7 @@ func main() {
|
||||||
// product details
|
// product details
|
||||||
rt.Product = env.ProdInfo{}
|
rt.Product = env.ProdInfo{}
|
||||||
rt.Product.Major = "1"
|
rt.Product.Major = "1"
|
||||||
rt.Product.Minor = "61"
|
rt.Product.Minor = "62"
|
||||||
rt.Product.Patch = "0"
|
rt.Product.Patch = "0"
|
||||||
rt.Product.Version = fmt.Sprintf("%s.%s.%s", rt.Product.Major, rt.Product.Minor, rt.Product.Patch)
|
rt.Product.Version = fmt.Sprintf("%s.%s.%s", rt.Product.Major, rt.Product.Minor, rt.Product.Patch)
|
||||||
rt.Product.Edition = "Community"
|
rt.Product.Edition = "Community"
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -40,6 +40,7 @@ export default Component.extend(TooltipMixin, {
|
||||||
return this.get('blocks.length') > 0;
|
return this.get('blocks.length') > 0;
|
||||||
}),
|
}),
|
||||||
mousetrap: null,
|
mousetrap: null,
|
||||||
|
voteThanks: false,
|
||||||
|
|
||||||
didRender() {
|
didRender() {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
|
@ -77,7 +78,7 @@ export default Component.extend(TooltipMixin, {
|
||||||
let mousetrap = this.get('mousetrap');
|
let mousetrap = this.get('mousetrap');
|
||||||
if (is.not.null(mousetrap)) {
|
if (is.not.null(mousetrap)) {
|
||||||
mousetrap.unbind('esc');
|
mousetrap.unbind('esc');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
contentLinkHandler() {
|
contentLinkHandler() {
|
||||||
|
@ -229,7 +230,7 @@ export default Component.extend(TooltipMixin, {
|
||||||
// let cb2 = this.get('onSavePage');
|
// let cb2 = this.get('onSavePage');
|
||||||
// cb2(page, meta);
|
// cb2(page, meta);
|
||||||
this.attrs.onSavePage(page, meta); // eslint-disable-line ember/no-attrs-in-components
|
this.attrs.onSavePage(page, meta); // eslint-disable-line ember/no-attrs-in-components
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -351,6 +352,11 @@ export default Component.extend(TooltipMixin, {
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onVote(vote) {
|
||||||
|
this.get('documentService').vote(this.get('document.id'), vote);
|
||||||
|
this.set('voteThanks', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,7 +12,9 @@
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import { computed } from '@ember/object';
|
import { computed } from '@ember/object';
|
||||||
import { schedule } from '@ember/runloop';
|
import { schedule } from '@ember/runloop';
|
||||||
|
import { A } from '@ember/array';
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
|
import constants from '../../utils/constants';
|
||||||
import TooltipMixin from '../../mixins/tooltip';
|
import TooltipMixin from '../../mixins/tooltip';
|
||||||
import ModalMixin from '../../mixins/modal';
|
import ModalMixin from '../../mixins/modal';
|
||||||
import AuthMixin from '../../mixins/auth';
|
import AuthMixin from '../../mixins/auth';
|
||||||
|
@ -48,6 +50,11 @@ export default Component.extend(ModalMixin, TooltipMixin, AuthMixin, {
|
||||||
|
|
||||||
dropzone: null,
|
dropzone: null,
|
||||||
|
|
||||||
|
spaceTypeOptions: A([]),
|
||||||
|
spaceType: constants.FolderType.Private,
|
||||||
|
likes: '',
|
||||||
|
allowLikes: false,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
this.importedDocuments = [];
|
this.importedDocuments = [];
|
||||||
|
@ -77,6 +84,16 @@ export default Component.extend(ModalMixin, TooltipMixin, AuthMixin, {
|
||||||
if (this.get('inviteMessage').length === 0) {
|
if (this.get('inviteMessage').length === 0) {
|
||||||
this.set('inviteMessage', this.getDefaultInvitationMessage());
|
this.set('inviteMessage', this.getDefaultInvitationMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let spaceTypeOptions = A([]);
|
||||||
|
spaceTypeOptions.pushObject({id: constants.FolderType.Private, label: 'Private - viewable only by me'});
|
||||||
|
spaceTypeOptions.pushObject({id: constants.FolderType.Protected, label: 'Protected - access is restricted to selected users'});
|
||||||
|
spaceTypeOptions.pushObject({id: constants.FolderType.Public, label: 'Public - can be seen by everyone'});
|
||||||
|
this.set('spaceTypeOptions', spaceTypeOptions);
|
||||||
|
this.set('spaceType', spaceTypeOptions.findBy('id', folder.get('folderType')));
|
||||||
|
|
||||||
|
this.set('likes', folder.get('likes'));
|
||||||
|
this.set('allowLikes', folder.get('allowLikes'));
|
||||||
},
|
},
|
||||||
|
|
||||||
didInsertElement() {
|
didInsertElement() {
|
||||||
|
@ -357,6 +374,31 @@ export default Component.extend(ModalMixin, TooltipMixin, AuthMixin, {
|
||||||
|
|
||||||
let slug = stringUtil.makeSlug(template.get('title'));
|
let slug = stringUtil.makeSlug(template.get('title'));
|
||||||
this.get('router').transitionTo('document', this.get('space.id'), this.get('space.slug'), id, slug);
|
this.get('router').transitionTo('document', this.get('space.id'), this.get('space.slug'), id, slug);
|
||||||
|
},
|
||||||
|
|
||||||
|
onSetSpaceType(t) {
|
||||||
|
this.set('spaceType', t);
|
||||||
|
},
|
||||||
|
|
||||||
|
onSetLikes(l) {
|
||||||
|
this.set('allowLikes', l);
|
||||||
|
schedule('afterRender', () => {
|
||||||
|
if (l) $('#space-likes-prompt').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
onSpaceSettings() {
|
||||||
|
let space = this.get('space');
|
||||||
|
space.set('folderType', this.get('spaceType.id'));
|
||||||
|
|
||||||
|
let allowLikes = this.get('allowLikes');
|
||||||
|
space.set('likes', allowLikes ? this.get('likes') : '');
|
||||||
|
|
||||||
|
this.get('spaceService').save(space).then(() => {
|
||||||
|
});
|
||||||
|
|
||||||
|
this.modalClose("#space-settings-modal");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,5 +12,5 @@
|
||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
|
|
||||||
export default Component.extend({
|
export default Component.extend({
|
||||||
classNames: ['col', 'col-sm-4']
|
classNames: ['col', 'col-sm-5']
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,6 +14,6 @@ import Component from '@ember/component';
|
||||||
|
|
||||||
export default Component.extend({
|
export default Component.extend({
|
||||||
appMeta: service(),
|
appMeta: service(),
|
||||||
classNames: ['col', 'col-sm-8'],
|
classNames: ['col', 'col-sm-7'],
|
||||||
selectItem: '',
|
selectItem: '',
|
||||||
});
|
});
|
||||||
|
|
|
@ -20,6 +20,11 @@ export default Model.extend({
|
||||||
orgId: attr('string'),
|
orgId: attr('string'),
|
||||||
userId: attr('string'),
|
userId: attr('string'),
|
||||||
folderType: attr('number', { defaultValue: 2 }),
|
folderType: attr('number', { defaultValue: 2 }),
|
||||||
|
likes: attr('string'),
|
||||||
|
|
||||||
|
allowLikes: computed('likes', function () {
|
||||||
|
return is.not.empty(this.get('likes')) && is.not.undefined(this.get('likes'));
|
||||||
|
}),
|
||||||
|
|
||||||
slug: computed('name', function () {
|
slug: computed('name', function () {
|
||||||
return stringUtil.makeSlug(this.get('name'));
|
return stringUtil.makeSlug(this.get('name'));
|
||||||
|
|
|
@ -11,11 +11,13 @@
|
||||||
|
|
||||||
import { set } from '@ember/object';
|
import { set } from '@ember/object';
|
||||||
import { A } from '@ember/array';
|
import { A } from '@ember/array';
|
||||||
|
import stringUtil from '../utils/string';
|
||||||
import ArrayProxy from '@ember/array/proxy';
|
import ArrayProxy from '@ember/array/proxy';
|
||||||
import Service, { inject as service } from '@ember/service';
|
import Service, { inject as service } from '@ember/service';
|
||||||
|
|
||||||
export default Service.extend({
|
export default Service.extend({
|
||||||
sessionService: service('session'),
|
sessionService: service('session'),
|
||||||
|
storageSvc: service('localStorage'),
|
||||||
folderService: service('folder'),
|
folderService: service('folder'),
|
||||||
ajax: service(),
|
ajax: service(),
|
||||||
store: service(),
|
store: service(),
|
||||||
|
@ -318,6 +320,41 @@ export default Service.extend({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
//**************************************************
|
||||||
|
// Voting / Liking
|
||||||
|
//**************************************************
|
||||||
|
|
||||||
|
// Vote records content vote from user.
|
||||||
|
// Anonymous users can vote to and are assigned temp id that is stored
|
||||||
|
// client-side in browser local storage.
|
||||||
|
vote(documentId, vote) {
|
||||||
|
let userId = '';
|
||||||
|
|
||||||
|
if (this.get('sessionService.authenticated')) {
|
||||||
|
userId = this.get('sessionService.user.id');
|
||||||
|
} else {
|
||||||
|
let id = this.get('storageSvc').getSessionItem('anonId');
|
||||||
|
|
||||||
|
if (is.not.null(id) && is.not.undefined(id) && id.length === 16) {
|
||||||
|
userId = id;
|
||||||
|
} else {
|
||||||
|
userId = stringUtil.anonUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.get('storageSvc').storeSessionItem('anonId', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = {
|
||||||
|
userId: userId,
|
||||||
|
vote: vote
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.get('ajax').post(`public/document/${documentId}/vote`, {
|
||||||
|
data: JSON.stringify(payload),
|
||||||
|
contentType: 'json'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
//**************************************************
|
//**************************************************
|
||||||
// Fetch bulk data
|
// Fetch bulk data
|
||||||
//**************************************************
|
//**************************************************
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
|
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
|
||||||
//
|
//
|
||||||
// This software (Documize Community Edition) is licensed under
|
// This software (Documize Community Edition) is licensed under
|
||||||
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
|
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
|
||||||
//
|
//
|
||||||
// You can operate outside the AGPL restrictions by purchasing
|
// You can operate outside the AGPL restrictions by purchasing
|
||||||
// Documize Enterprise Edition and obtaining a commercial license
|
// Documize Enterprise Edition and obtaining a commercial license
|
||||||
// by contacting <sales@documize.com>.
|
// by contacting <sales@documize.com>.
|
||||||
//
|
//
|
||||||
// https://documize.com
|
// https://documize.com
|
||||||
|
|
||||||
|
@ -27,4 +27,4 @@ export default Service.extend({
|
||||||
clearAll() {
|
clearAll() {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,4 +7,5 @@
|
||||||
@import "view-attachment.scss";
|
@import "view-attachment.scss";
|
||||||
@import "view-activity.scss";
|
@import "view-activity.scss";
|
||||||
@import "view-revision.scss";
|
@import "view-revision.scss";
|
||||||
@import "wysiwyg.scss";
|
@import "vote-likes.scss";
|
||||||
|
@import "wysiwyg.scss";
|
||||||
|
|
25
gui/app/styles/view/document/vote-likes.scss
Normal file
25
gui/app/styles/view/document/vote-likes.scss
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
.vote-box {
|
||||||
|
margin: 50px 0;
|
||||||
|
padding: 30px 50px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
border: 1px dotted $color-border;
|
||||||
|
background: $color-off-white;
|
||||||
|
@include border-radius(3px);
|
||||||
|
|
||||||
|
> .prompt {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .buttons {
|
||||||
|
margin: 30px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .ack {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-green;
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,7 +10,7 @@
|
||||||
<div class="section-divider" />
|
<div class="section-divider" />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{document/document-page document=document folder=folder page=item.page meta=item.meta pending=item.pending
|
{{document/document-page document=document folder=folder page=item.page meta=item.meta pending=item.pending
|
||||||
permissions=permissions toEdit=toEdit roles=roles blocks=blocks
|
permissions=permissions toEdit=toEdit roles=roles blocks=blocks
|
||||||
onSavePage=(action 'onSavePage') onSavePageAsBlock=(action 'onSavePageAsBlock')
|
onSavePage=(action 'onSavePage') onSavePageAsBlock=(action 'onSavePageAsBlock')
|
||||||
onCopyPage=(action 'onCopyPage') onMovePage=(action 'onMovePage') onDeletePage=(action 'onDeletePage') refresh=(action refresh)}}
|
onCopyPage=(action 'onCopyPage') onMovePage=(action 'onMovePage') onDeletePage=(action 'onDeletePage') refresh=(action refresh)}}
|
||||||
|
@ -23,6 +23,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if folder.allowLikes}}
|
||||||
|
<div class=" d-flex justify-content-center">
|
||||||
|
<div class="vote-box">
|
||||||
|
{{#unless voteThanks}}
|
||||||
|
<div class="prompt">
|
||||||
|
{{folder.likes}}
|
||||||
|
</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" class="btn btn-outline-success font-weight-bold" {{action 'onVote' 1}}>Yes, thanks!</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary font-weight-bold" {{action 'onVote' 2}}>Not really</button>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="ack">Thanks for the feedback!</div>
|
||||||
|
{{/unless}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#unless hasPages}}
|
{{#unless hasPages}}
|
||||||
|
|
|
@ -105,6 +105,47 @@
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if (or permissions.spaceOwner permissions.spaceManage)}}
|
||||||
|
<div id="space-settings-button" class="button-icon-gray align-middle" data-toggle="tooltip" data-placement="top" title="Settings">
|
||||||
|
<i class="material-icons" data-toggle="modal" data-target="#space-settings-modal" data-backdrop="static">settings</i>
|
||||||
|
</div>
|
||||||
|
<div class="button-icon-gap" />
|
||||||
|
<div id="space-settings-modal" class="modal" tabindex="-1" role="dialog">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">Space Settings</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Space Type</label>
|
||||||
|
{{ui-select id="group-dropdown" content=spaceTypeOptions optionValuePath="id" optionLabelPath="label" selection=spaceType action=(action 'onSetSpaceType')}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Content Liking</label>
|
||||||
|
{{#if allowLikes}}
|
||||||
|
{{input type='text' id="space-likes-prompt" class="form-control" placeholder="Did this help you?" value=likes}}
|
||||||
|
<small class="form-text text-muted">Specify the prompt, e.g. Did this help you? Was this helpful? Did you find what you needed?</small>
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick={{action 'onSetLikes' false}}>Do not allow users to like content</button>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-outline-success" onclick={{action 'onSetLikes' true}}>Allow users to like content</button>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-success" onclick={{action 'onSpaceSettings'}}>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{#if pinState.isPinned}}
|
{{#if pinState.isPinned}}
|
||||||
<div id="space-pin-button" class="button-icon-gold align-middle" data-toggle="tooltip" data-placement="top" title="Remove favorite" {{action 'onUnpin'}}>
|
<div id="space-pin-button" class="button-icon-gold align-middle" data-toggle="tooltip" data-placement="top" title="Remove favorite" {{action 'onUnpin'}}>
|
||||||
<i class="material-icons">star</i>
|
<i class="material-icons">star</i>
|
||||||
|
|
|
@ -36,9 +36,14 @@ function endsWith(str, suffix) {
|
||||||
return str.indexOf(suffix, str.length - suffix.length) !== -1;
|
return str.indexOf(suffix, str.length - suffix.length) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function anonUserId() {
|
||||||
|
return 'anon_' + makeId(11);
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
makeSlug,
|
makeSlug,
|
||||||
makeId,
|
makeId,
|
||||||
endsWith
|
endsWith,
|
||||||
|
anonUserId
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "documize",
|
"name": "documize",
|
||||||
"version": "1.61.0",
|
"version": "1.62.0",
|
||||||
"description": "The Document IDE",
|
"description": "The Document IDE",
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": "",
|
"repository": "",
|
||||||
|
|
|
@ -22,6 +22,9 @@ type Space struct {
|
||||||
OrgID string `json:"orgId"`
|
OrgID string `json:"orgId"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
Type Scope `json:"folderType"`
|
Type Scope `json:"folderType"`
|
||||||
|
// Likes stores the question to ask the user such as 'Did this help you?'.
|
||||||
|
// An empty value tells us liking is not allowed.
|
||||||
|
Likes string `json:"likes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope determines folder visibility.
|
// Scope determines folder visibility.
|
||||||
|
|
|
@ -86,6 +86,7 @@ func RegisterEndpoints(rt *env.Runtime, s *domain.Store) {
|
||||||
AddPublic(rt, "reset/{token}", []string{"POST", "OPTIONS"}, nil, user.ResetPassword)
|
AddPublic(rt, "reset/{token}", []string{"POST", "OPTIONS"}, nil, user.ResetPassword)
|
||||||
AddPublic(rt, "share/{spaceID}", []string{"POST", "OPTIONS"}, nil, space.AcceptInvitation)
|
AddPublic(rt, "share/{spaceID}", []string{"POST", "OPTIONS"}, nil, space.AcceptInvitation)
|
||||||
AddPublic(rt, "attachments/{orgID}/{attachmentID}", []string{"GET", "OPTIONS"}, nil, attachment.Download)
|
AddPublic(rt, "attachments/{orgID}/{attachmentID}", []string{"GET", "OPTIONS"}, nil, attachment.Download)
|
||||||
|
AddPublic(rt, "document/{documentID}/vote", []string{"POST", "OPTIONS"}, nil, document.Vote)
|
||||||
|
|
||||||
//**************************************************
|
//**************************************************
|
||||||
// Secured private routes (require authentication)
|
// Secured private routes (require authentication)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue