1
0
Fork 0
mirror of https://github.com/documize/community.git synced 2025-07-19 13:19:43 +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:
Harvey Kandola 2018-04-13 11:01:36 +01:00
parent 6c8f23792c
commit 22b6674edb
23 changed files with 1031 additions and 694 deletions

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: '',
}); });

View file

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

View file

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

View file

@ -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 "vote-likes.scss";
@import "wysiwyg.scss"; @import "wysiwyg.scss";

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

View file

@ -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>&nbsp;&nbsp;
<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}}

View file

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

View file

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

View file

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

View file

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

View file

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