Skip to content

Commit 0511829

Browse files
author
Bibhas
authored
Draft model and autosave on Project edit form (#352)
* 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
1 parent 9bb3d1a commit 0511829

File tree

7 files changed

+293
-28
lines changed

7 files changed

+293
-28
lines changed

funnel/forms/project.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from baseframe import __
88
import baseframe.forms as forms
99
from baseframe.forms.sqlalchemy import AvailableName, QuerySelectField
10-
from ..models import RSVP_STATUS
10+
from ..models import RSVP_STATUS, Project
1111

1212
__all__ = [
1313
'EventForm', 'ProjectForm', 'ProjectTransitionForm', 'RsvpForm',
@@ -76,6 +76,8 @@ def set_queries(self):
7676
self.admin_team.query = profile_teams
7777
self.review_team.query = profile_teams
7878
self.checkin_team.query = profile_teams
79+
self.parent_project.query = Project.query.filter(
80+
Project.profile == self.edit_obj.profile, Project.id != self.edit_obj.id, Project.parent_project == None) # NOQA
7981

8082
def validate_bg_color(self, field):
8183
if not valid_color_re.match(field.data):

funnel/models/__init__.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33

44
from coaster.sqlalchemy import (TimestampMixin, UuidMixin, BaseMixin, BaseNameMixin,
55
BaseScopedNameMixin, BaseScopedIdNameMixin, BaseIdNameMixin, MarkdownColumn,
6-
JsonDict, CoordinatesMixin, make_timestamp_columns)
6+
JsonDict, NoIdMixin, CoordinatesMixin, make_timestamp_columns)
77
from coaster.db import db
88

9-
from .user import *
10-
from .profile import *
119
from .commentvote import *
10+
from .contact_exchange import *
11+
from .draft import *
12+
from .event import *
13+
from .feedback import *
14+
from .profile import *
1215
from .project import *
13-
from .section import *
14-
from .usergroup import *
1516
from .proposal import *
16-
from .feedback import *
17+
from .rsvp import *
18+
from .section import *
1719
from .session import *
20+
from .user import *
21+
from .usergroup import *
1822
from .venue import *
19-
from .rsvp import *
20-
from .event import *
21-
from .contact_exchange import *

funnel/models/draft.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from sqlalchemy_utils import UUIDType
4+
from werkzeug.datastructures import MultiDict
5+
from . import db, JsonDict, NoIdMixin
6+
7+
__all__ = ['Draft']
8+
9+
10+
class Draft(NoIdMixin, db.Model):
11+
"""Store for autosaved, unvalidated drafts on behalf of other models"""
12+
__tablename__ = 'draft'
13+
14+
table = db.Column(db.UnicodeText, primary_key=True)
15+
table_row_id = db.Column(UUIDType(binary=False), primary_key=True)
16+
body = db.Column(JsonDict, nullable=False, server_default='{}')
17+
revision = db.Column(UUIDType(binary=False))
18+
19+
@property
20+
def formdata(self):
21+
return MultiDict(self.body.get('form', {}))
22+
23+
@formdata.setter
24+
def formdata(self, value):
25+
if self.body is not None:
26+
self.body['form'] = value
27+
else:
28+
self.body = {'form': value}

funnel/templates/formlayout.html.jinja2

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@
99
{%- endassets -%}
1010
{% endblock %}
1111

12+
{% block contentwrapper %}
13+
<div class="grid">
14+
<div class="grid__col-xs-12">
15+
{%- if autosave %}
16+
<div><p class="mui--text-subhead mui--text-light mui--pull-right" id="autosave-msg"></p></div>
17+
{% endif %}
18+
{% block content %}{% endblock %}
19+
</div>
20+
</div>
21+
{% endblock %}
22+
1223
{% block pagescripts %}
1324
{% assets "js_codemirrormarkdown" -%}
1425
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
@@ -17,3 +28,89 @@
1728
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
1829
{%- endassets -%}
1930
{% endblock %}
31+
32+
{% block layoutscripts %}
33+
{%- if autosave %}
34+
<script type="text/javascript">
35+
$(function() {
36+
var typingTimer;
37+
var typingWaitInterval = 1000; // wait till user stops typing for one second to send form data
38+
var waitingForResponse = false;
39+
var lastSavedData = '';
40+
41+
$('input[name="form.revision"]').val() ? $('#autosave-msg').text('These changes have not been published yet.') : '';
42+
43+
$('#{{ ref_id }}').on('change', function(e) {
44+
autosaveForm();
45+
});
46+
47+
$('#{{ ref_id }}').on('keyup', function(e) {
48+
if(typingTimer) clearTimeout(typingTimer);
49+
typingTimer = setTimeout(autosaveForm, typingWaitInterval);
50+
});
51+
52+
function autosaveForm() {
53+
var actionUrl = $('#{{ ref_id }}').attr('action');
54+
var sep = (actionUrl.indexOf('?') === -1) ? '?' : '&';
55+
if(!waitingForResponse && haveDirtyFields()) {
56+
$.ajax({
57+
type: 'POST',
58+
url: actionUrl + sep + 'form.autosave=true',
59+
data: $("#{{ ref_id }}").serialize(),
60+
dataType: 'json',
61+
timeout: 15000,
62+
beforeSend: function() {
63+
$('#autosave-msg').text('Autosaving...');
64+
lastSavedData = $("#{{ ref_id }}").find('[type!="hidden"]').serialize();
65+
waitingForResponse = true;
66+
},
67+
success: function (remoteData) {
68+
// Todo: Update window.history.pushState for new form
69+
$('#autosave-msg').text('Changes saved but not published');
70+
if(remoteData.revision) {
71+
$('input[name="form.revision"]').val(remoteData.revision);
72+
}
73+
waitingForResponse = false;
74+
autosaveForm();
75+
},
76+
error: function (response) {
77+
var errorMsg = '';
78+
waitingForResponse = false;
79+
if (response.readyState === 4) {
80+
if (response.status === 500) {
81+
errorMsg ='Internal Server Error. Please reload and try again.';
82+
} else {
83+
// There is a version mismatch, notify user to reload the page.
84+
waitingForResponse = true;
85+
errorMsg = JSON.parse(response.responseText).error_description;
86+
}
87+
} else {
88+
errorMsg = 'Unable to connect. Please reload and try again.';
89+
}
90+
$('#autosave-msg').text(errorMsg);
91+
window.toastr.error(errorMsg);
92+
},
93+
});
94+
}
95+
96+
function haveDirtyFields() {
97+
var latestFormData = $('#{{ ref_id }}').find('[type!="hidden"]').serialize();
98+
if (latestFormData !== lastSavedData) {
99+
return true;
100+
}
101+
}
102+
103+
$(window).bind('beforeunload', function() {
104+
if(haveDirtyFields()){
105+
return 'You have unsaved changes on this page. Do you want to leave this page?';
106+
}
107+
});
108+
}
109+
});
110+
</script>
111+
{%- endif %}
112+
{% block footerscripts %}{% endblock %}
113+
{% endblock %}
114+
115+
116+

funnel/views/mixins.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from uuid import uuid4
12
from flask import abort, g, redirect, request
3+
from baseframe import _, forms
24
from coaster.utils import require_one_of
3-
from ..models import (Project, Profile, ProjectRedirect, Proposal, ProposalRedirect, Session,
4-
UserGroup, Venue, VenueRoom, Section)
5+
from werkzeug.datastructures import MultiDict
6+
from ..models import (Draft, Project, Profile, ProjectRedirect, Proposal, ProposalRedirect, Session,
7+
UserGroup, Venue, VenueRoom, Section, db)
58

69

710
class ProjectViewMixin(object):
@@ -130,3 +133,80 @@ def loader(self, profile, project, section):
130133
).first_or_404()
131134
g.profile = section.project.profile
132135
return section
136+
137+
138+
class DraftViewMixin(object):
139+
def get_draft(self, obj=None):
140+
"""
141+
Returns the draft object for `obj`. Defaults to `self.obj`.
142+
`obj` is needed in case of multi-model views.
143+
"""
144+
obj = obj if obj is not None else self.obj
145+
return Draft.query.get((self.model.__tablename__, obj.uuid))
146+
147+
def delete_draft(self, obj=None):
148+
"""
149+
Deletes draft for `obj`, or `self.obj` if `obj` is `None`.
150+
"""
151+
draft = self.get_draft(obj)
152+
if draft is not None:
153+
db.session.delete(draft)
154+
else:
155+
raise ValueError(_("There is no draft for the given object."))
156+
157+
def get_draft_data(self, obj=None):
158+
"""
159+
Returns a tuple of the current draft revision and the formdata needed to initialize forms
160+
"""
161+
draft = self.get_draft(obj)
162+
if draft is not None:
163+
return draft.revision, draft.formdata
164+
else:
165+
return None, None
166+
167+
def autosave_post(self, obj=None):
168+
"""
169+
Handles autosave POST requests
170+
"""
171+
obj = obj if obj is not None else self.obj
172+
if 'form.revision' not in request.form:
173+
# as form.autosave is true, the form should have `form.revision` field even if it's empty
174+
return {'status': 'error', 'error_identifier': 'form_missing_revision_field', 'error_description': _("Form must contain a revision ID.")}, 400
175+
176+
# CSRF check
177+
if forms.Form().validate_on_submit():
178+
incoming_data = MultiDict(request.form.items(multi=True))
179+
client_revision = incoming_data.pop('form.revision')
180+
incoming_data.pop('csrf_token', None)
181+
182+
# find the last draft
183+
draft = self.get_draft(obj)
184+
185+
if draft is not None:
186+
if client_revision is None or (client_revision is not None and str(draft.revision) != client_revision):
187+
# draft exists, but the form did not send a revision ID,
188+
# OR revision ID sent by client does not match the last revision ID
189+
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
190+
elif client_revision is not None and str(draft.revision) == client_revision:
191+
# revision ID sent my client matches, save updated draft data and update revision ID
192+
existing = draft.formdata
193+
for key in incoming_data.keys():
194+
if existing[key] != incoming_data[key]:
195+
existing[key] = incoming_data[key]
196+
draft.formdata = existing
197+
draft.revision = uuid4()
198+
elif draft is None and client_revision:
199+
# The form contains a revision ID but no draft exists.
200+
# Somebody is making autosave requests with an invalid draft ID.
201+
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
202+
else:
203+
# no draft exists, create one
204+
draft = Draft(
205+
table=Project.__tablename__, table_row_id=obj.uuid,
206+
formdata=incoming_data, revision=uuid4()
207+
)
208+
db.session.add(draft)
209+
db.session.commit()
210+
return {'revision': draft.revision}
211+
else:
212+
return {'status': 'error', 'error_identifier': 'invalid_csrf', 'error_description': _("Invalid CSRF token")}, 400

funnel/views/project.py

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from baseframe import _, forms
77
from baseframe.forms import render_form
88
from coaster.auth import current_auth
9+
from coaster.utils import getbool
910
from coaster.views import jsonp, route, render_with, requires_permission, UrlForView, ModelView
1011

1112
from .. import app, funnelapp, lastuser
@@ -16,7 +17,7 @@
1617
from .schedule import schedule_data
1718
from .venue import venue_data, room_data
1819
from .section import section_data
19-
from .mixins import ProjectViewMixin, ProfileViewMixin
20+
from .mixins import ProjectViewMixin, ProfileViewMixin, DraftViewMixin
2021
from .decorators import legacy_redirect
2122

2223

@@ -75,7 +76,7 @@ class FunnelProfileProjectView(ProfileProjectView):
7576

7677

7778
@route('/<profile>/<project>/')
78-
class ProjectView(ProjectViewMixin, UrlForView, ModelView):
79+
class ProjectView(ProjectViewMixin, DraftViewMixin, UrlForView, ModelView):
7980
__decorators__ = [legacy_redirect]
8081

8182
@route('')
@@ -129,23 +130,47 @@ def csv(self):
129130
headers=[('Content-Disposition', 'attachment;filename="{project}.csv"'.format(project=self.obj.name))])
130131

131132
@route('edit', methods=['GET', 'POST'])
133+
@render_with(json=True)
132134
@lastuser.requires_login
133135
@requires_permission('edit_project')
134136
def edit(self):
135-
if self.obj.parent_project:
136-
form = SubprojectForm(obj=self.obj, model=Project)
137-
else:
138-
form = ProjectForm(obj=self.obj, parent=self.obj.profile, model=Project)
139-
form.parent_project.query = Project.query.filter(Project.profile == self.obj.profile, Project.id != self.obj.id, Project.parent_project == None) # NOQA
140-
if request.method == 'GET' and not self.obj.timezone:
141-
form.timezone.data = current_app.config.get('TIMEZONE')
142-
if form.validate_on_submit():
143-
form.populate_obj(self.obj)
144-
db.session.commit()
145-
flash(_("Your changes have been saved"), 'info')
146-
tag_locations.queue(self.obj.id)
147-
return redirect(self.obj.url_for(), code=303)
148-
return render_form(form=form, title=_("Edit project"), submit=_("Save changes"))
137+
if request.method == 'GET':
138+
# find draft if it exists
139+
draft_revision, initial_formdata = self.get_draft_data()
140+
141+
# initialize forms with draft initial formdata.
142+
# if no draft exists, initial_formdata is None. wtforms ignore formdata if it's None.
143+
if self.obj.parent_project:
144+
form = SubprojectForm(obj=self.obj, model=Project, formdata=initial_formdata)
145+
else:
146+
form = ProjectForm(obj=self.obj, parent=self.obj.profile, model=Project, formdata=initial_formdata)
147+
148+
if not self.obj.timezone:
149+
form.timezone.data = current_auth.user.timezone
150+
151+
return render_form(form=form, title=_("Edit project"), submit=_("Save changes"), autosave=True, draft_revision=draft_revision)
152+
elif request.method == 'POST':
153+
if getbool(request.args.get('form.autosave')):
154+
return self.autosave_post()
155+
else:
156+
if self.obj.parent_project:
157+
form = SubprojectForm(obj=self.obj, model=Project)
158+
else:
159+
form = ProjectForm(obj=self.obj, parent=self.obj.profile, model=Project)
160+
if form.validate_on_submit():
161+
form.populate_obj(self.obj)
162+
db.session.commit()
163+
flash(_("Your changes have been saved"), 'info')
164+
tag_locations.queue(self.obj.id)
165+
166+
# find and delete draft if it exists
167+
if self.get_draft() is not None:
168+
self.delete_draft()
169+
db.session.commit()
170+
171+
return redirect(self.obj.url_for(), code=303)
172+
else:
173+
return render_form(form=form, title=_("Edit project"), submit=_("Save changes"), autosave=True)
149174

150175
@route('boxoffice_data', methods=['GET', 'POST'])
151176
@lastuser.requires_login
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""draft model
2+
3+
Revision ID: 94ce3a9b7a3a
4+
Revises: c3069d33419a
5+
Create Date: 2019-02-06 20:48:34.700795
6+
7+
"""
8+
9+
revision = '94ce3a9b7a3a'
10+
down_revision = 'a9cb0e1c52ed'
11+
12+
from alembic import op
13+
import sqlalchemy as sa
14+
15+
from sqlalchemy_utils.types.uuid import UUIDType
16+
from coaster.sqlalchemy.columns import JsonDict
17+
18+
19+
def upgrade():
20+
op.create_table('draft',
21+
sa.Column('created_at', sa.DateTime(), nullable=False),
22+
sa.Column('updated_at', sa.DateTime(), nullable=False),
23+
sa.Column('table', sa.UnicodeText(), nullable=False),
24+
sa.Column('table_row_id', UUIDType(binary=False), nullable=False),
25+
sa.Column('body', JsonDict(), server_default='{}', nullable=False),
26+
sa.Column('revision', UUIDType(binary=False), nullable=True),
27+
sa.PrimaryKeyConstraint('table', 'table_row_id')
28+
)
29+
30+
31+
def downgrade():
32+
op.drop_table('draft')

0 commit comments

Comments
 (0)