Redesign corpus page and add possibility to add followers by username for owner

This commit is contained in:
Patrick Jentsch 2023-03-01 16:31:41 +01:00
parent ec6d0a6477
commit 145b80356d
4 changed files with 313 additions and 201 deletions

View File

@ -3,6 +3,7 @@ from flask import (
abort,
current_app,
flash,
jsonify,
make_response,
Markup,
redirect,
@ -49,22 +50,34 @@ def fake_add():
return ''
@bp.route('/<hashid:corpus_id>/is_public/enable', methods=['POST'])
@bp.route('/<hashid:corpus_id>/is_public', methods=['POST'])
@login_required
@owner_or_admin_required()
def enable_corpus_is_public(corpus_id):
def update_corpus_is_public(corpus_id):
is_public = request.json
if not isinstance(is_public, bool):
response = jsonify('The request body must be a boolean')
response.status_code = 400
abort(response)
corpus = Corpus.query.get_or_404(corpus_id)
corpus.is_public = True
corpus.is_public = is_public
db.session.commit()
return '', 204
@bp.route('/<hashid:corpus_id>/is_public/disable', methods=['POST'])
@bp.route('/<hashid:corpus_id>/followers/add', methods=['POST'])
@login_required
@owner_or_admin_required()
def disable_corpus_is_public(corpus_id):
def add_corpus_followers(corpus_id):
usernames = request.json
if not (isinstance(usernames, list) or all(isinstance(u, str) for u in usernames)):
response = jsonify('The request body must be a list of strings')
response.status_code = 400
abort(response)
corpus = Corpus.query.get_or_404(corpus_id)
corpus.is_public = False
for username in usernames:
user = User.query.filter_by(username=username, is_public=True).first_or_404()
user.follow_corpus(corpus)
db.session.commit()
return '', 204
@ -169,14 +182,12 @@ def create_corpus():
@login_required
def corpus(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
exp_date = (datetime.utcnow() + timedelta(days=7)).strftime('%b %d, %Y')
roles = [x.name for x in CorpusFollowerRole.query.all()]
corpus_follower_roles = CorpusFollowerRole.query.all()
if corpus.user == current_user or current_user.is_administrator():
return render_template(
'corpora/corpus.html.j2',
corpus=corpus,
exp_date=exp_date,
roles=roles,
corpus_follower_roles=corpus_follower_roles,
title='Corpus'
)
if current_user.is_following_corpus(corpus) or corpus.is_public:
@ -191,6 +202,7 @@ def corpus(corpus_id):
)
abort(403)
@bp.route('/<hashid:corpus_id>/generate-corpus-share-link', methods=['GET', 'POST'])
@login_required
@corpus_follower_permission_required('GENERATE_SHARE_LINK')

View File

@ -12,16 +12,6 @@ class CorpusDisplay extends RessourceDisplay {
.addEventListener('click', (event) => {
Utils.deleteCorpusRequest(this.userId, this.corpusId);
});
this.displayElement
.querySelector('.action-switch[data-action="toggle-is-public"]')
.addEventListener('click', (event) => {
if (event.target.tagName !== 'INPUT') {return;}
if (event.target.checked) {
Utils.enableCorpusIsPublicRequest(this.userId, this.corpusId);
} else {
Utils.disableCorpusIsPublicRequest(this.userId, this.corpusId);
}
});
}
init(user) {
@ -31,7 +21,6 @@ class CorpusDisplay extends RessourceDisplay {
this.setStatus(corpus.status);
this.setTitle(corpus.title);
this.setNumTokens(corpus.num_tokens);
this.setShareLink();
}
onPatch(patch) {
@ -82,7 +71,7 @@ class CorpusDisplay extends RessourceDisplay {
}
setStatus(status) {
let elements = this.displayElement.querySelectorAll('.corpus-analyse-trigger')
let elements = this.displayElement.querySelectorAll('.action-button[data-action="analyze"]');
for (let element of elements) {
if (['BUILT', 'STARTING_ANALYSIS_SESSION', 'RUNNING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) {
element.classList.remove('disabled');
@ -118,28 +107,4 @@ class CorpusDisplay extends RessourceDisplay {
new Date(creationDate).toLocaleString("en-US")
);
}
setShareLink() {
let generateShareLinkButton = this.displayElement.querySelector('#generate-share-link-button');
let copyShareLinkButton = this.displayElement.querySelector('#copy-share-link-button');
let shareLinkInput = this.displayElement.querySelector('#share-link-input');
let shareLinkContainer = this.displayElement.querySelector('#share-link-container');
let roleSelect = this.displayElement.querySelector('#role-select');
let expirationDate = this.displayElement.querySelector('#expiration');
generateShareLinkButton.addEventListener('click', () => {
Utils.generateCorpusShareLinkRequest(`${this.corpusId}`, roleSelect.value, expirationDate.value)
.then((shareLink) => {
shareLinkContainer.classList.remove('hide');
shareLinkInput.value = shareLink;
});
});
copyShareLinkButton.addEventListener('click', () => {
shareLinkInput.select();
navigator.clipboard.writeText(shareLinkInput.value);
app.flash(`Copied!`, 'success');
});
}
}

View File

@ -69,6 +69,36 @@ class Utils {
return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2));
}
static updateCorpusIsPublicRequest(corpusId, isPublic) {
return new Promise((resolve, reject) => {
let fetchRessource = `/corpora/${corpusId}/is_public`;
let fetchOptions = {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(isPublic)
};
fetch(fetchRessource, fetchOptions)
.then(
(response) => {
if (response.ok) {
app.flash(`Corpus is now ${isPublic ? 'public' : 'private'}`, 'corpus');
resolve(response);
} else {
app.flash(`${response.statusText}`, 'error');
reject(response);
}
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
}
static updateCorpusFollowerRole(corpusId, followerId, roleName) {
return new Promise((resolve, reject) => {
fetch(`/corpora/${corpusId}/followers/${followerId}/role`, {method: 'POST', headers: {Accept: 'application/json', 'Content-Type': 'application/json'}, body: JSON.stringify({role: roleName})})
@ -91,79 +121,27 @@ class Utils {
});
}
static enableCorpusIsPublicRequest(userId, corpusId) {
static addCorpusFollowersRequest(corpusId, usernames) {
return new Promise((resolve, reject) => {
let corpus;
try {
corpus = app.data.users[userId].corpora[corpusId];
} catch (error) {
corpus = {};
}
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Hier könnte eine Warnung stehen</h4>
<p></p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
<a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Confirm</a>
</div>
</div>
`
);
document.querySelector('#modals').appendChild(modalElement);
let modal = M.Modal.init(
modalElement,
{
dismissible: false,
onCloseEnd: () => {
modal.destroy();
modalElement.remove();
}
}
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let corpusTitle = corpus?.title;
fetch(`/corpora/${corpusId}/is_public/enable`, {method: 'POST', headers: {Accept: 'application/json'}})
let fetchRessource = `/corpora/${corpusId}/followers/add`;
let fetchOptions = {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(usernames)
};
fetch(fetchRessource, fetchOptions)
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Corpus "${corpusTitle}" is public now`, 'corpus');
if (response.ok) {
app.flash(`${usernames.length > 1 ? 'Users are' : 'User is'} following now`, 'corpus');
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
} else {
app.flash(`${response.statusText}`, 'error');
reject(response);
}
);
});
modal.open();
});
}
static disableCorpusIsPublicRequest(userId, corpusId) {
return new Promise((resolve, reject) => {
let corpus;
try {
corpus = app.data.users[userId].corpora[corpusId];
} catch (error) {
corpus = {};
}
let corpusTitle = corpus?.title;
fetch(`/corpora/${corpusId}/is_public/disable`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Corpus "${corpusTitle}" is private now`, 'corpus');
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');

View File

@ -8,13 +8,11 @@
<div class="container">
<div class="row" data-corpus-id="{{ corpus.hashid }}" data-user-id="{{ corpus.user.hashid }}" id="corpus-display">
<div class="col s12">
<div class="row">
<div class="col s8 m9 l10">
<h1 id="title"><span class="corpus-title"></span></h1>
</div>
<div class="col s4 m3 l2 right-align">
<p>&nbsp;</p>
<p>&nbsp;</p>
<div class="col s12 l8">
<div class="card service-color-border border-darken" data-service="corpus-analysis" style="border-top: 10px solid">
<div class="card-content">
<span class="chip corpus-status corpus-status-color corpus-status-text white-text"></span>
<div class="active preloader-wrapper small corpus-status-spinner">
<div class="spinner-layer spinner-blue-only">
@ -29,11 +27,6 @@
</div>
</div>
</div>
</div>
</div>
<div class="card service-color-border border-darken" data-service="corpus-analysis" style="border-top: 10px solid">
<div class="card-content">
<div class="row">
<div class="col s12">
<div class="input-field">
@ -57,12 +50,35 @@
</div>
</div>
</div>
<div class="card-action">
<div class="right-align">
<a class="btn corpus-analyse-trigger disabled waves-effect waves-light" href="{{ url_for('corpora.analyse_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">search</i>Analyze</a>
<a class="action-button btn disabled waves-effect waves-light" data-action="build-request"><i class="nopaque-icons left">K</i>Build</a>
<a class="btn disabled export-corpus-trigger waves-effect waves-light" href="{{ url_for('corpora.export_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">import_export</i>Export</a>
<a class="action-button btn red waves-effect waves-light" data-action="delete-request"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
<div class="col s12 l4">
<div class="card">
<div class="card-content">
<span class="card-title">Actions</span>
<div class="row">
<div class="col s12 l6" style="padding: 0 2.5px;">
<a class="action-button btn disabled waves-effect waves-light" data-action="build-request" style="width: 100%;"><i class="nopaque-icons left">K</i>Build</a>
</div>
<div class="col s12 l6" style="padding: 0 2.5px;">
<a class="action-button btn disabled waves-effect waves-light" data-action="analyze" href="{{ url_for('corpora.analyse_corpus', corpus_id=corpus.id) }}" style="width: 100%;"><i class="material-icons left">search</i>Analyze</a>
</div>
<div class="col s12 l6" style="padding: 5px 2.5px 0 2.5px;">
<a class="btn waves-effect waves-light modal-trigger" href="#publishing-modal" style="width: 100%;"><i class="material-icons left">publish</i>Publishing</a>
</div>
<div class="col s12 l6" style="padding: 5px 2.5px 0 2.5px;">
<a class="action-button btn red waves-effect waves-light" data-action="delete-request" style="width: 100%;"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
<span class="card-title">Social</span>
<div class="row">
<div class="col s12 l6" style="padding: 0 2.5px;">
<a class="btn waves-effect waves-light modal-trigger" href="#invite-user-modal" style="width: 100%;"><i class="material-icons left">person_add</i>invite user</a>
</div>
<div class="col s12 l6" style="padding: 0 2.5px;">
<a class="btn waves-effect waves-light modal-trigger" href="#share-link-modal" style="width: 100%;"><i class="material-icons left">link</i>Share link</a>
</div>
</div>
</div>
</div>
@ -80,73 +96,8 @@
</div>
</div>
<div class="col s12">
<div class="card">
<div class="card-content">
<span class="card-title">Share your Corpus</span>
<br>
<p></p>
<p><b>Change your Corpus Status to Public</b></p>
<p><i>Other users can only see the meta data of your corpus. The files of the corpus remain private and can only be viewed via a share link.</i></p>
<br>
<div class="action-switch switch" data-action="toggle-is-public">
<span class="share"></span>
<label>
<input {% if corpus.is_public %}checked{% endif %} type="checkbox">
<span class="lever"></span>
public
</label>
</div>
<br>
<p></p>
<hr style="height:1px;border:none;color:grey;background-color:grey;">
<br>
<p></p>
<p><b>Create a link to share your corpus files with your team</b></p>
<p><i>With the link other users follow your corpus directly, if it has not expired.
You can set different roles via the link, you can also edit them later in the menu below.
It is recommended not to set the expiration date of the link too far.</i></p>
<br>
<div class="row">
<div class="col s4">
<div class="input-field">
<select id="role-select">
{% for role in roles%}
<option value="{{role}}">{{role}}</option>
{% endfor %}
</select>
<label>Role</label>
</div>
</div>
</div>
<div class="row">
<div class="col s4">
<div class="input-field">
<input type="text" class="datepicker" value="{{exp_date}}" id="expiration">
<label for="expiration-date">Expiration date</label>
</div>
</div>
</div>
<div class="row">
<div class="col s12">
<a class="action-button btn waves-effect waves-light" id="generate-share-link-button">Generate Share Link</a>
</div>
<div class="col s12 hide" id="share-link-container">
<p></p>
<br>
<div class="row">
<div class="col s1">
<a class="action-button btn-small waves-effect waves-light" id="copy-share-link-button">Copy</a>
</div>
<div class="col s11">
<input id="share-link-input" readonly>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col s12"></div>
<div class="col s12">
<div class="card">
<div class="card-content">
@ -159,9 +110,215 @@
</div>
{% endblock page_content %}
{% block modals %}
{{ super() }}
<div class="modal" id="publishing-modal">
<div class="modal-content">
<h4>Change your Corpus publishing status</h4>
<p><i>Other users can only see the meta data of your corpus. The files of the corpus remain private and can only be viewed via a share link.</i></p>
<br>
<div class="switch">
<label>
private
<input {% if corpus.is_public %}checked{% endif %} id="publishing-modal-is-public-switch" type="checkbox">
<span class="lever"></span>
public
</label>
</div>
</div>
<div class="modal-footer">
<a class="modal-close waves-effect waves-green btn-flat">Close</a>
</div>
</div>
<div class="modal no-autoinit" id="invite-user-modal">
<div class="modal-content">
<h4>Invite a nopaque user by username</h4>
<p>
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
</p>
<div class="row">
<div class="col s10">
<div class="chips no-autoinit" id="invite-user-modal-search"></div>
</div>
<div class="col s2">
<br class="hide-on-med-and-down">
<a class="btn modal-close waves-effect waves-light" id="invite-user-modal-invite-button">Invite<i class="material-icons right">send</i></a>
</div>
</div>
</div>
<div class="modal-footer">
<a class="modal-close waves-effect waves-green btn-flat">Close</a>
</div>
</div>
<div class="modal no-autoinit" id="share-link-modal">
<div class="modal-content">
<h4>Create a link to share your corpus</h4>
<p>
With the link other users follow your corpus directly, if it has not expired.
You can set different roles via the link, you can also edit them later in the menu below.
It is recommended not to set the expiration date of the link too far.
</p>
<div class="row">
<div class="col s12 l2">
<div class="input-field">
<i class="material-icons prefix">badge</i>
<select id="share-link-modal-corpus-follower-role-select">
{% for corpus_follower_role in corpus_follower_roles %}
<option value="{{ corpus_follower_role.hashid }}">{{ corpus_follower_role.name }}</option>
{% endfor %}
</select>
<label>Role</label>
</div>
</div>
<div class="col s12 l2">
<div class="input-field">
<i class="material-icons prefix">calendar_month</i>
<input type="text" class="datepicker no-autoinit" id="share-link-modal-expiration-date-datepicker">
<label for="expiration-date">Expiration date</label>
</div>
</div>
<div class="col s12 l2">
<br class="hide-on-med-and-down">
<a class="btn waves-effect waves-light" id="share-link-modal-create-button">Create<i class="material-icons right">send</i></a>
</div>
<div class="col s12 l6">
<div class="row hide" id="share-link-modal-output-container">
<div class="col s9">
<div class="input-field">
<input disabled id="share-link-modal-output-field" readonly type="text">
</div>
</div>
<div class="col s3">
<br class="hide-on-med-and-down">
<a class="btn-small waves-effect waves-light" id="share-link-modal-output-copy-button"><i class="material-icons left">content_copy</i>Copy</a>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<a class="modal-close waves-effect waves-green btn-flat">Close</a>
</div>
</div>
{% endblock modals %}
{% block scripts %}
{{ super() }}
<script>
let corpusId = {{ corpus.hashid|tojson }};
let corpusDisplay = new CorpusDisplay(document.querySelector('#corpus-display'));
// #region publishing_modal_js
let publishingModalIsPublicSwitchElement = document.querySelector('#publishing-modal-is-public-switch');
publishingModalIsPublicSwitchElement.addEventListener('change', (event) => {
let newIsPublic = publishingModalIsPublicSwitchElement.checked;
Utils.updateCorpusIsPublicRequest(corpusId, newIsPublic)
.catch((response) => {
publishingModalIsPublicSwitchElement.checked = !newIsPublic;
});
});
// #endregion publishing_modal_js
// #region invite_user_modal_js
let inviteUserModalElement = document.querySelector('#invite-user-modal');
let inviteUserModalSearchElement = document.querySelector('#invite-user-modal-search');
let inviteUserModalInviteButtonElement = document.querySelector('#invite-user-modal-invite-button');
let inviteUserModalSearch = M.Chips.init(
inviteUserModalSearchElement,
{
autocompleteOptions: {
data: {
'nopaque': '/users/3V8Aqpg74JvxOd9o/avatar',
'pjentsch': '/users/3V8Aqpg74JvxOd9o/avatar',
'pjentsch2': '/users/3V8Aqpg74JvxOd9o/avatar'
}
},
limit: 3,
onChipAdd: (a, chipElement) => {
if (!(chipElement.firstChild.data in inviteUserModalSearch.autocomplete.options.data)) {
chipElement.firstElementChild.click();
}
},
placeholder: 'Enter a username',
secondaryPlaceholder: 'Add more users'
}
);
M.Modal.init(
inviteUserModalElement,
{
onOpenStart: (modalElement, modalTriggerElement) => {
while (inviteUserModalSearch.chipsData.length > 0) {
inviteUserModalSearch.deleteChip(0);
}
}
}
)
inviteUserModalInviteButtonElement.addEventListener('click', (event) => {
let usernames = inviteUserModalSearch.chipsData.map((chipData) => chipData.tag);
Utils.addCorpusFollowersRequest(corpusId, usernames);
});
// #endregion invite_user_modal_js
// #region share_link_modal_js
let shareLinkModalElement = document.querySelector('#share-link-modal');
let shareLinkModalCorpusFollowerRoleSelectElement = document.querySelector('#share-link-modal-corpus-follower-role-select');
let shareLinkModalExpirationDateDatepickerElement = document.querySelector('#share-link-modal-expiration-date-datepicker');
let shareLinkModalCreateButtonElement = document.querySelector('#share-link-modal-create-button');
let shareLinkModalOutputContainerElement = document.querySelector('#share-link-modal-output-container');
let shareLinkModalOutputFieldElement = document.querySelector('#share-link-modal-output-field');
let shareLinkModalOutputCopyButtonElement = document.querySelector('#share-link-modal-output-copy-button');
let today = new Date();
let tomorrow = new Date();
tomorrow.setDate(today.getDate() + 1);
let oneWeekLater = new Date();
oneWeekLater.setDate(today.getDate() + 7);
let fourWeeksLater = new Date();
fourWeeksLater.setDate(today.getDate() + 28);
M.Datepicker.init(
shareLinkModalExpirationDateDatepickerElement,
{
container: document.querySelector('main'),
defaultDate: oneWeekLater,
setDefaultDate: true,
minDate: tomorrow,
maxDate: fourWeeksLater
}
);
M.Modal.init(
shareLinkModalElement,
{
onOpenStart: (modalElement, modalTriggerElement) => {
shareLinkModalOutputFieldElement.value = '';
shareLinkModalOutputContainerElement.classList.add('hide');
}
}
)
shareLinkModalCreateButtonElement.addEventListener('click', (event) => {
Utils.generateCorpusShareLinkRequest(corpusId, shareLinkModalCorpusFollowerRoleSelectElement.value, shareLinkModalExpirationDateDatepickerElement.value)
.then((shareLink) => {
shareLinkModalOutputContainerElement.classList.remove('hide');
shareLinkModalOutputFieldElement.value = shareLink;
});
});
shareLinkModalOutputCopyButtonElement.addEventListener('click', (event) => {
navigator.clipboard.writeText(shareLinkModalOutputFieldElement.value)
.then(
() => {app.flash('Copied!');},
() => {app.flash('Could not copy to clipboard. Please copy manually.', 'error');}
);
});
// #endregion share_link_modal_js
</script>
{% endblock scripts %}