Skip to content

Commit

Permalink
Draft model and autosave on Project edit form (#352)
Browse files Browse the repository at this point in the history
* Use ES6 JS. Use webpack to build and transpile ES6 code.

* Add scroll active menu and swipe functionality.

* Sort schedule event days.

* Change buttons design of the header.

* Add check to find if eventday is present.

* Avoid using global names.

* Add support to lazy load images on the home page

* Add script folder. Change to lazy load on hasgeek index page.

* Sync with master.

* added Makefile

* Updated package-lock.json.

* Fix the mui class name.

* added newline

* Remove default lat and lon values.

* split makefile

* Move view proposal btn to right.

* Add support for autosave (wip)

* Add autosave flag to form data.

* added draft model and initial view

* fixed edit endpoint

* Update server response flag in error cases.

* fixed creation of draft

* Check form data changes before sending to backend.

* Refactor checking for dirty fields of the form.

* added timestamp to draft model, fixed form submit workflow

* removed revision id validation

* removed print statement

* removed unused import

* updated draft model and using composite key

* Send last_revision_id to render_form

* various fixes

* update form post url

* Update form id

* fixed action url, only updating fields sent in request

* Fix form field selector

* Add missing semicolon.

* Change to draft_revision. Use updated form review field.

* added csrf check for draft autosave

* fixed draft model

* fixed draft delete and invalid revision ID handling

* updated down revision

* update down revision

* fixed revision check logic

* added DraftModelViewMixin

* fixed draftmixin

* minor fixes

* changed error format

* fixed multidict update

* Display the error message from the server.

* removed redundant statement
  • Loading branch information
Bibhas authored Feb 19, 2019
1 parent 3e1d69c commit 85f91cd
Show file tree
Hide file tree
Showing 7 changed files with 293 additions and 28 deletions.
4 changes: 3 additions & 1 deletion funnel/forms/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from baseframe import __
import baseframe.forms as forms
from baseframe.forms.sqlalchemy import AvailableName, QuerySelectField
from ..models import RSVP_STATUS
from ..models import RSVP_STATUS, Project

__all__ = [
'EventForm', 'ProjectForm', 'ProjectTransitionForm', 'RsvpForm',
Expand Down Expand Up @@ -76,6 +76,8 @@ def set_queries(self):
self.admin_team.query = profile_teams
self.review_team.query = profile_teams
self.checkin_team.query = profile_teams
self.parent_project.query = Project.query.filter(
Project.profile == self.edit_obj.profile, Project.id != self.edit_obj.id, Project.parent_project == None) # NOQA

def validate_bg_color(self, field):
if not valid_color_re.match(field.data):
Expand Down
19 changes: 10 additions & 9 deletions funnel/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@

from coaster.sqlalchemy import (TimestampMixin, UuidMixin, BaseMixin, BaseNameMixin,
BaseScopedNameMixin, BaseScopedIdNameMixin, BaseIdNameMixin, MarkdownColumn,
JsonDict, CoordinatesMixin, make_timestamp_columns)
JsonDict, NoIdMixin, CoordinatesMixin, make_timestamp_columns)
from coaster.db import db

from .user import *
from .profile import *
from .commentvote import *
from .contact_exchange import *
from .draft import *
from .event import *
from .feedback import *
from .profile import *
from .project import *
from .section import *
from .usergroup import *
from .proposal import *
from .feedback import *
from .rsvp import *
from .section import *
from .session import *
from .user import *
from .usergroup import *
from .venue import *
from .rsvp import *
from .event import *
from .contact_exchange import *
28 changes: 28 additions & 0 deletions funnel/models/draft.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-

from sqlalchemy_utils import UUIDType
from werkzeug.datastructures import MultiDict
from . import db, JsonDict, NoIdMixin

__all__ = ['Draft']


class Draft(NoIdMixin, db.Model):
"""Store for autosaved, unvalidated drafts on behalf of other models"""
__tablename__ = 'draft'

table = db.Column(db.UnicodeText, primary_key=True)
table_row_id = db.Column(UUIDType(binary=False), primary_key=True)
body = db.Column(JsonDict, nullable=False, server_default='{}')
revision = db.Column(UUIDType(binary=False))

@property
def formdata(self):
return MultiDict(self.body.get('form', {}))

@formdata.setter
def formdata(self, value):
if self.body is not None:
self.body['form'] = value
else:
self.body = {'form': value}
97 changes: 97 additions & 0 deletions funnel/templates/formlayout.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -17,3 +28,89 @@
<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 = '';
$('input[name="form.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(typingTimer) clearTimeout(typingTimer);
typingTimer = setTimeout(autosaveForm, typingWaitInterval);
});
function autosaveForm() {
var actionUrl = $('#{{ ref_id }}').attr('action');
var sep = (actionUrl.indexOf('?') === -1) ? '?' : '&';
if(!waitingForResponse && haveDirtyFields()) {
$.ajax({
type: 'POST',
url: actionUrl + sep + 'form.autosave=true',
data: $("#{{ ref_id }}").serialize(),
dataType: 'json',
timeout: 15000,
beforeSend: function() {
$('#autosave-msg').text('Autosaving...');
lastSavedData = $("#{{ ref_id }}").find('[type!="hidden"]').serialize();
waitingForResponse = true;
},
success: function (remoteData) {
// Todo: Update window.history.pushState for new form
$('#autosave-msg').text('Changes saved but not published');
if(remoteData.revision) {
$('input[name="form.revision"]').val(remoteData.revision);
}
waitingForResponse = false;
autosaveForm();
},
error: function (response) {
var errorMsg = '';
waitingForResponse = false;
if (response.readyState === 4) {
if (response.status === 500) {
errorMsg ='Internal Server Error. Please reload and try again.';
} else {
// There is a version mismatch, notify user to reload the page.
waitingForResponse = true;
errorMsg = JSON.parse(response.responseText).error_description;
}
} 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('[type!="hidden"]').serialize();
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 %}



84 changes: 82 additions & 2 deletions funnel/views/mixins.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from uuid import uuid4
from flask import abort, g, redirect, request
from baseframe import _, forms
from coaster.utils import require_one_of
from ..models import (Project, Profile, ProjectRedirect, Proposal, ProposalRedirect, Session,
UserGroup, Venue, VenueRoom, Section)
from werkzeug.datastructures import MultiDict
from ..models import (Draft, Project, Profile, ProjectRedirect, Proposal, ProposalRedirect, Session,
UserGroup, Venue, VenueRoom, Section, db)


class ProjectViewMixin(object):
Expand Down Expand Up @@ -130,3 +133,80 @@ def loader(self, profile, project, section):
).first_or_404()
g.profile = section.project.profile
return section


class DraftViewMixin(object):
def get_draft(self, obj=None):
"""
Returns the draft object for `obj`. Defaults to `self.obj`.
`obj` is needed in case of multi-model views.
"""
obj = obj if obj is not None else self.obj
return Draft.query.get((self.model.__tablename__, obj.uuid))

def delete_draft(self, obj=None):
"""
Deletes draft for `obj`, or `self.obj` if `obj` is `None`.
"""
draft = self.get_draft(obj)
if draft is not None:
db.session.delete(draft)
else:
raise ValueError(_("There is no draft for the given object."))

def get_draft_data(self, obj=None):
"""
Returns a tuple of the current draft revision and the formdata needed to initialize forms
"""
draft = self.get_draft(obj)
if draft is not None:
return draft.revision, draft.formdata
else:
return None, None

def autosave_post(self, obj=None):
"""
Handles autosave POST requests
"""
obj = obj if obj is not None else self.obj
if 'form.revision' not in request.form:
# as form.autosave is true, the form should have `form.revision` field even if it's empty
return {'status': 'error', 'error_identifier': 'form_missing_revision_field', 'error_description': _("Form must contain a revision ID.")}, 400

# CSRF check
if forms.Form().validate_on_submit():
incoming_data = MultiDict(request.form.items(multi=True))
client_revision = incoming_data.pop('form.revision')
incoming_data.pop('csrf_token', None)

# find the last draft
draft = self.get_draft(obj)

if draft is not None:
if client_revision is None or (client_revision is not None and str(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 {'status': 'error', 'error_identifier': 'missing_or_invalid_revision', 'error_description': _("There have been changes to this draft since you last edited it. Please reload.")}, 400
elif client_revision is not None and str(draft.revision) == client_revision:
# revision ID sent my client matches, save updated draft data and update revision ID
existing = draft.formdata
for key in incoming_data.keys():
if existing[key] != incoming_data[key]:
existing[key] = incoming_data[key]
draft.formdata = existing
draft.revision = uuid4()
elif draft is None and client_revision:
# The form contains a revision ID but no draft exists.
# Somebody is making autosave requests with an invalid draft ID.
return {'status': 'error', 'error_identifier': 'invalid_or_expired_revision', 'error_description': _("Invalid revision ID or the existing changes have been submitted already. Please reload.")}, 400
else:
# no draft exists, create one
draft = Draft(
table=Project.__tablename__, table_row_id=obj.uuid,
formdata=incoming_data, revision=uuid4()
)
db.session.add(draft)
db.session.commit()
return {'revision': draft.revision}
else:
return {'status': 'error', 'error_identifier': 'invalid_csrf', 'error_description': _("Invalid CSRF token")}, 400
57 changes: 41 additions & 16 deletions funnel/views/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from baseframe import _, forms
from baseframe.forms import render_form
from coaster.auth import current_auth
from coaster.utils import getbool
from coaster.views import jsonp, route, render_with, requires_permission, UrlForView, ModelView

from .. import app, funnelapp, lastuser
Expand All @@ -16,7 +17,7 @@
from .schedule import schedule_data
from .venue import venue_data, room_data
from .section import section_data
from .mixins import ProjectViewMixin, ProfileViewMixin
from .mixins import ProjectViewMixin, ProfileViewMixin, DraftViewMixin
from .decorators import legacy_redirect


Expand Down Expand Up @@ -75,7 +76,7 @@ class FunnelProfileProjectView(ProfileProjectView):


@route('/<profile>/<project>/')
class ProjectView(ProjectViewMixin, UrlForView, ModelView):
class ProjectView(ProjectViewMixin, DraftViewMixin, UrlForView, ModelView):
__decorators__ = [legacy_redirect]

@route('')
Expand Down Expand Up @@ -129,23 +130,47 @@ 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_revision, initial_formdata = self.get_draft_data()

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

if not self.obj.timezone:
form.timezone.data = current_auth.user.timezone

return render_form(form=form, title=_("Edit project"), submit=_("Save changes"), autosave=True, draft_revision=draft_revision)
elif request.method == 'POST':
if getbool(request.args.get('form.autosave')):
return self.autosave_post()
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)
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 draft if it exists
if self.get_draft() is not None:
self.delete_draft()
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
Expand Down
32 changes: 32 additions & 0 deletions migrations/versions/94ce3a9b7a3a_draft_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""draft model
Revision ID: 94ce3a9b7a3a
Revises: c3069d33419a
Create Date: 2019-02-06 20:48:34.700795
"""

revision = '94ce3a9b7a3a'
down_revision = 'a9cb0e1c52ed'

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('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('table', sa.UnicodeText(), nullable=False),
sa.Column('table_row_id', UUIDType(binary=False), nullable=False),
sa.Column('body', JsonDict(), server_default='{}', nullable=False),
sa.Column('revision', UUIDType(binary=False), nullable=True),
sa.PrimaryKeyConstraint('table', 'table_row_id')
)


def downgrade():
op.drop_table('draft')

0 comments on commit 85f91cd

Please sign in to comment.