-
Notifications
You must be signed in to change notification settings - Fork 53
Draft model and autosave on Project edit form #352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 40 commits
fd29b97
5e0c866
29ad207
468cc92
e20f3f0
86b5449
6db29ca
45cc1ec
21c55fe
3404dc3
2ba252a
13bb27f
2009366
4e3f037
e32971e
cbae311
22cae87
7154dd2
acdb758
beaa65f
bc25e3e
f1fe677
94f25bb
07b1c5f
c2d1bc0
4d31f12
d25d236
51ac07f
daf4270
5df6d23
3a7b9f2
34a5295
7f3ae6a
51e6dfd
ae27841
059430d
35715f2
2e773ed
e8497a1
8013eb2
7d460d3
193416a
2df3296
e075640
2ec4fb8
3d2b35f
a9f1933
3bf1d4e
aadd9f8
a0b660e
90a12ff
03be511
49e91eb
dea2a95
96af04a
b703846
82f87da
e5050fd
dd6499c
7866c6e
2ab6a44
9f1c3f5
5bb58c6
91f3d93
30d0b2e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
# -*- coding: utf-8 -*- | ||
|
||
from sqlalchemy_utils import UUIDType | ||
from . import db, JsonDict, IdMixin, TimestampMixin | ||
|
||
__all__ = ['Draft'] | ||
|
||
|
||
class Draft(IdMixin, TimestampMixin, db.Model): | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""Generic model to store unvalidated draft of any other models""" | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
__tablename__ = 'draft' | ||
__uuid_primary_key__ = True | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
table = db.Column(db.UnicodeText) | ||
table_row_id = db.Column(UUIDType(binary=False)) | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
body = db.Column(JsonDict, nullable=False, server_default='{}') | ||
revision = db.Column(UUIDType(binary=False)) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,17 @@ | |
{%- endassets -%} | ||
{% endblock %} | ||
|
||
{% block contentwrapper %} | ||
<div class="grid"> | ||
<div class="grid__col-xs-12"> | ||
{%- if autosave %} | ||
<div><p class="mui--text-subhead mui--text-light mui--pull-right" id="autosave-msg"></p></div> | ||
{% endif %} | ||
{% block content %}{% endblock %} | ||
</div> | ||
</div> | ||
{% endblock %} | ||
|
||
{% block pagescripts %} | ||
{% assets "js_codemirrormarkdown" -%} | ||
<script type="text/javascript" src="{{ ASSET_URL }}"></script> | ||
|
@@ -17,3 +28,91 @@ | |
<script type="text/javascript" src="{{ ASSET_URL }}"></script> | ||
{%- endassets -%} | ||
{% endblock %} | ||
|
||
{% block layoutscripts %} | ||
{%- if autosave %} | ||
<script type="text/javascript"> | ||
$(function() { | ||
var typingTimer; | ||
var typingWaitInterval = 1000; // wait till user stops typing for one second to send form data | ||
var waitingForResponse = false; | ||
var lastSavedData = ''; | ||
|
||
$('#revision').val() ? $('#autosave-msg').text('These changes have not been published yet.') : ''; | ||
|
||
$('#{{ ref_id }}').on('change', function(e) { | ||
autosaveForm(); | ||
}); | ||
|
||
$('#{{ ref_id }}').on('keyup', function(e) { | ||
if(e.target.value) { | ||
if(typingTimer) clearTimeout(typingTimer); | ||
typingTimer = setTimeout(autosaveForm, typingWaitInterval); | ||
} | ||
}); | ||
|
||
function autosaveForm() { | ||
if(!waitingForResponse && haveDirtyFields()) { | ||
$.ajax({ | ||
type: 'POST', | ||
data: $("#{{ ref_id }}").serialize() + '&autosave=true', | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
dataType: 'json', | ||
timeout: 15000, | ||
beforeSend: function() { | ||
console.log('sending'); | ||
$('#autosave-msg').text('Autosaving...'); | ||
lastSavedData = $("#{{ ref_id }}").find(":input:not(:hidden)").serialize(); | ||
waitingForResponse = true; | ||
}, | ||
success: function (remoteData) { | ||
// Todo: Update window.history.pushState for new form | ||
console.log('autosaveForm done', remoteData); | ||
$('#autosave-msg').text('Changes saved but not published'); | ||
if(remoteData.revision) { | ||
$('#revision').val(remoteData.revision); | ||
} | ||
waitingForResponse = false; | ||
autosaveForm(); | ||
}, | ||
error: function (response) { | ||
console.log('error', response); | ||
var errorMsg = ''; | ||
waitingForResponse = false; | ||
if (response.readyState === 4) { | ||
if (response.status === 500) { | ||
errorMsg ='Internal Server Error. Please reload and try again.'; | ||
} else if(response.status === 400) { | ||
// There is a version mismatch, notify user to reload the page. | ||
waitingForResponse = true; | ||
errorMsg = 'This page has already been edited. Please reload the page.'; | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} else { | ||
errorMsg = 'Unable to connect. Please reload and try again.'; | ||
} | ||
$('#autosave-msg').text(errorMsg); | ||
window.toastr.error(errorMsg); | ||
}, | ||
}); | ||
} | ||
|
||
function haveDirtyFields() { | ||
var latestFormData = $('#{{ ref_id }}').find(':input:not(:hidden)').serialize(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is checking the entire form. Is this efficient? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is event to detect user typing into input fields and event to detect form changes for fields like select, radio. Incase user selects browser's autocomplete, both these events are triggered and we end up sending two ajax events. This was added to avoid this. We are actually comparing two longs strings since |
||
if (latestFormData !== lastSavedData) { | ||
return true; | ||
} | ||
} | ||
|
||
$(window).bind('beforeunload', function() { | ||
if(haveDirtyFields()){ | ||
return 'You have unsaved changes on this page. Do you want to leave this page?'; | ||
} | ||
}); | ||
} | ||
}); | ||
</script> | ||
{%- endif %} | ||
{% block footerscripts %}{% endblock %} | ||
{% endblock %} | ||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,17 @@ | ||
# -*- coding: utf-8 -*- | ||
|
||
import unicodecsv | ||
from uuid import uuid4, UUID | ||
from cStringIO import StringIO | ||
from flask import g, flash, redirect, Response, request, abort, current_app | ||
from werkzeug.datastructures import MultiDict | ||
from baseframe import _, forms | ||
from baseframe.forms import render_form | ||
from coaster.auth import current_auth | ||
from coaster.views import jsonp, route, render_with, requires_permission, UrlForView, ModelView | ||
|
||
from .. import app, funnelapp, lastuser | ||
from ..models import db, Project, Section, Proposal, Rsvp, RSVP_STATUS | ||
from ..models import db, Project, Section, Proposal, Rsvp, Draft, RSVP_STATUS | ||
from ..forms import ProjectForm, SubprojectForm, RsvpForm, ProjectTransitionForm, ProjectBoxofficeForm | ||
from ..jobs import tag_locations, import_tickets | ||
from .proposal import proposal_headers, proposal_data, proposal_data_flat | ||
|
@@ -129,23 +131,77 @@ def csv(self): | |
headers=[('Content-Disposition', 'attachment;filename="{project}.csv"'.format(project=self.obj.name))]) | ||
|
||
@route('edit', methods=['GET', 'POST']) | ||
@render_with(json=True) | ||
@lastuser.requires_login | ||
@requires_permission('edit_project') | ||
def edit(self): | ||
if self.obj.parent_project: | ||
form = SubprojectForm(obj=self.obj, model=Project) | ||
else: | ||
form = ProjectForm(obj=self.obj, parent=self.obj.profile, model=Project) | ||
form.parent_project.query = Project.query.filter(Project.profile == self.obj.profile, Project.id != self.obj.id, Project.parent_project == None) # NOQA | ||
if request.method == 'GET' and not self.obj.timezone: | ||
form.timezone.data = current_app.config.get('TIMEZONE') | ||
if form.validate_on_submit(): | ||
form.populate_obj(self.obj) | ||
db.session.commit() | ||
flash(_("Your changes have been saved"), 'info') | ||
tag_locations.queue(self.obj.id) | ||
return redirect(self.obj.url_for(), code=303) | ||
return render_form(form=form, title=_("Edit project"), submit=_("Save changes")) | ||
if request.method == 'GET': | ||
# find draft if it exists | ||
draft = Draft.query.filter_by(table=Project.__tablename__, table_row_id=self.obj.uuid).first() | ||
initial_formdata = MultiDict(draft.body['form']) if draft is not None else None | ||
|
||
# initialize forms with draft initial formdata. | ||
# if no draft exists, initial_formdata is None. wtforms ignore formdata if it's None. | ||
if self.obj.parent_project: | ||
form = SubprojectForm(obj=self.obj, model=Project, formdata=initial_formdata) | ||
else: | ||
form = ProjectForm(obj=self.obj, parent=self.obj.profile, model=Project, formdata=initial_formdata) | ||
form.parent_project.query = Project.query.filter(Project.profile == self.obj.profile, Project.id != self.obj.id, Project.parent_project == None) # NOQA | ||
|
||
# if draft exists, add latest revision ID to the form | ||
if draft is not None: | ||
form.revision.data = draft.revision | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if not self.obj.timezone: | ||
form.timezone.data = current_app.config.get('TIMEZONE') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unrelated fix, but this should default to the user's timezone, not the app's. Use |
||
return render_form(form=form, title=_("Edit project"), submit=_("Save changes"), autosave=True) | ||
elif request.method == 'POST': | ||
if 'autosave' in request.form and request.form['autosave'] == 'true': | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if 'revision' not in request.form: | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return {'error': _("Form must contain a valid revision ID.")}, 400 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You will not have a revision if there was no draft when the user submitted. This can happen if the user submits without editing, or JavaScript is disabled. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a comment. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The first autosave will not have a revision. There is nothing to revise. |
||
|
||
# ensure that the revision ID is valid | ||
try: | ||
client_revision = UUID(request.form['revision']) if request.form['revision'] else None | ||
except Exception as e: | ||
return {'error': _("Invalid UUID: {0!r}".format(e))}, 400 | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# find the last draft | ||
draft = Draft.query.filter_by(table=Project.__tablename__, table_row_id=self.obj.uuid).order_by(Draft.updated_at.desc()).first() | ||
|
||
if draft is not None: | ||
if client_revision is None or (client_revision is not None and draft.revision != client_revision): | ||
# draft exists, but the form did not send a revision ID, | ||
# OR revision ID sent by client does not match the last revision ID | ||
return {'error': _("There has been changes to this draft since you last edited it. Please reload.")}, 400 | ||
elif client_revision is not None and draft.revision == client_revision: | ||
# revision ID sent my client matches, save updated draft data and update revision ID | ||
draft.body = {'form': request.form} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code assumes the client is sending the entire form for autosave. Ideally it should only be sending dirty fields, and we should be updating here instead of replacing. (I'm okay with this inefficient implementation for a first pass, but it will need to be fixed.) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I updated it to only take in changes available in data sent by client. check once. |
||
draft.revision = uuid4() | ||
else: | ||
# no draft exists, create one | ||
draft = Draft(table=Project.__tablename__, table_row_id=self.obj.uuid, body={'form': request.form}, revision=uuid4()) | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
db.session.add(draft) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This requires a CSRF check. Without the check, a malicious third party website can dump junk content into the Draft model by performing a cross-site POST (which is what CSRF protects from).
|
||
db.session.commit() | ||
return {'draft': draft.body['form'], 'revision': draft.revision} | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
else: | ||
if self.obj.parent_project: | ||
form = SubprojectForm(obj=self.obj, model=Project) | ||
else: | ||
form = ProjectForm(obj=self.obj, parent=self.obj.profile, model=Project) | ||
form.parent_project.query = Project.query.filter(Project.profile == self.obj.profile, Project.id != self.obj.id, Project.parent_project == None) # NOQA | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if form.validate_on_submit(): | ||
form.populate_obj(self.obj) | ||
db.session.commit() | ||
flash(_("Your changes have been saved"), 'info') | ||
tag_locations.queue(self.obj.id) | ||
|
||
# find and delete drafts | ||
Draft.query.filter_by(table=Project.__tablename__, table_row_id=self.obj.uuid).delete() | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
db.session.commit() | ||
return redirect(self.obj.url_for(), code=303) | ||
else: | ||
return render_form(form=form, title=_("Edit project"), submit=_("Save changes"), autosave=True) | ||
|
||
@route('boxoffice_data', methods=['GET', 'POST']) | ||
@lastuser.requires_login | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
"""draft model | ||
|
||
Revision ID: e073be362102 | ||
Revises: c3069d33419a | ||
Create Date: 2019-02-05 12:12:21.134284 | ||
|
||
""" | ||
|
||
revision = 'e073be362102' | ||
down_revision = 'c3069d33419a' | ||
|
||
from alembic import op | ||
import sqlalchemy as sa | ||
|
||
from sqlalchemy_utils.types.uuid import UUIDType | ||
from coaster.sqlalchemy.columns import JsonDict | ||
|
||
|
||
def upgrade(): | ||
op.create_table('draft', | ||
sa.Column('table', sa.UnicodeText(), nullable=True), | ||
sa.Column('table_row_id', UUIDType(binary=False), nullable=True), | ||
sa.Column('body', JsonDict(), server_default='{}', nullable=False), | ||
sa.Column('revision', UUIDType(binary=False), nullable=True), | ||
sa.Column('created_at', sa.DateTime(), nullable=False), | ||
sa.Column('updated_at', sa.DateTime(), nullable=False), | ||
sa.Column('id', UUIDType(binary=False), nullable=False), | ||
sa.PrimaryKeyConstraint('id') | ||
) | ||
|
||
|
||
def downgrade(): | ||
op.drop_table('draft') |
Uh oh!
There was an error while loading. Please reload this page.