Skip to content

Commit 16d0278

Browse files
authored
Merge pull request #1460 from codidact/0valt/1459/draft-discard
Add ability to manually discard post drafts
2 parents a9400ee + 79ee877 commit 16d0278

File tree

6 files changed

+190
-55
lines changed

6 files changed

+190
-55
lines changed

app/assets/javascripts/posts.js

Lines changed: 87 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -117,51 +117,91 @@ $(() => {
117117
});
118118

119119
/**
120-
* @typedef {{
121-
* body: string
122-
* comment?: string
123-
* excerpt?: string
124-
* license?: string
125-
* tag_name?: string
126-
* tags?: string[]
127-
* title?: string
128-
* }} PostDraft
129-
*
120+
* Temporarily displays the draft's status with a given message
121+
* @param {JQuery<Element>} $field draftable field
122+
* @param {string} message draft status message
123+
* @returns {void}
124+
*/
125+
const flashDraftStatus = ($field, message) => {
126+
const $statusEl = $field.parents('.widget').find('.js-post-draft-status');
127+
128+
$statusEl.text(message);
129+
$statusEl.removeClass('transparent');
130+
131+
setTimeout(() => {
132+
$statusEl.addClass('transparent');
133+
}, 1500);
134+
};
135+
136+
/**
137+
* Removes the "draft loaded" notice from the page
138+
* @returns {void}
139+
*/
140+
const removeDraftLoadedNotice = () => {
141+
document.querySelector('.js-draft-notice')?.remove();
142+
};
143+
144+
/**
130145
* Attempts to save a post draft
131-
* @param {PostDraft} draft post draft
132-
* @param {JQuery<Element>} $field body input element
146+
* @param {QPixelDraft} draft post draft
147+
* @param {JQuery<Element>} $field draftable field
133148
* @param {boolean} [manual] whether manual draft saving is enabled
134149
* @returns {Promise<void>}
135150
*/
136151
const saveDraft = async (draft, $field, manual = false) => {
137152
const autosavePref = await QPixel.preference('autosave', true);
153+
138154
if (autosavePref !== 'on' && !manual) {
139155
return;
140156
}
141157

142-
const resp = await QPixel.fetchJSON('/posts/save-draft', { ...draft, path: location.pathname });
158+
const data = await QPixel.saveDraft(draft);
143159

144-
if (resp.status === 200) {
145-
const $statusEl = $field.parents('.widget').find('.js-post-draft-status');
160+
QPixel.handleJSONResponse(data, () => {
161+
flashDraftStatus($field, 'draft saved');
162+
});
163+
};
164+
165+
/**
166+
* Attempts to remove a post draft
167+
* @param {JQuery<Element>} $field draftable field
168+
* @returns {Promise<boolean>}
169+
*/
170+
const deleteDraft = async ($field) => {
171+
const data = await QPixel.deleteDraft();
146172

147-
$statusEl.removeClass('transparent');
173+
return QPixel.handleJSONResponse(data, () => {
174+
flashDraftStatus($field, 'draft deleted');
175+
removeDraftLoadedNotice();
176+
});
177+
}
148178

149-
setTimeout(() => {
150-
$statusEl.addClass('transparent');
151-
}, 1500);
152-
}
179+
/**
180+
* Helper for getting draft-related elements from a given event target
181+
* @param {EventTarget} target post field or one of the draft buttons
182+
* @returns {{
183+
* $form: JQuery<HTMLFormElement>,
184+
* $field: JQuery<HTMLElement>
185+
* }}
186+
*/
187+
const getDraftElements = (target) => {
188+
const $tgt = $(target);
189+
const $form = $tgt.parents('form');
190+
const $field = $form.find('.js-post-field');
191+
return { $form, $field };
153192
};
154193

155194
/**
156195
* Extracts draft info from a given target
157-
* @param {EventTarget} target post input field or "save draft" button
158-
* @returns {{ draft: PostDraft, field: any }}
196+
* @param {EventTarget} target post field or one of the draft buttons
197+
* @returns {{
198+
* draft: QPixelDraft,
199+
* $field: JQuery<HTMLElement>
200+
* }}
159201
*/
160202
const parseDraft = (target) => {
161-
const $tgt = $(target);
162-
const $form = $tgt.parents('form');
203+
const { $field: $bodyField, $form } = getDraftElements(target);
163204

164-
const $bodyField = $form.find('.js-post-field');
165205
const $licenseField = $form.find('.js-license-select');
166206
const $excerptField = $form.find('.js-tag-excerpt');
167207

@@ -178,7 +218,7 @@ $(() => {
178218
const titleText = $titleField.val()?.toString();
179219
const tagName = $tagNameField.val()?.toString();
180220

181-
/** @type {PostDraft} */
221+
/** @type {QPixelDraft} */
182222
const draft = {
183223
body: bodyText,
184224
comment: commentText,
@@ -189,12 +229,17 @@ $(() => {
189229
title: titleText,
190230
};
191231

192-
return { draft, field: $bodyField };
232+
return { draft, $field: $bodyField };
193233
};
194234

235+
$('.js-delete-draft').on('click', async (ev) => {
236+
const { $field } = getDraftElements(ev.target);
237+
await deleteDraft($field);
238+
});
239+
195240
$('.js-save-draft').on('click', async (ev) => {
196-
const { draft, field } = parseDraft(ev.target);
197-
await saveDraft(draft, field, true);
241+
const { draft, $field } = parseDraft(ev.target);
242+
await saveDraft(draft, $field, true);
198243
});
199244

200245
let featureTimeout = null;
@@ -215,8 +260,8 @@ $(() => {
215260
$(draftFieldsSelectors.join(', ')).on('keyup change', (ev) => {
216261
clearTimeout(draftTimeout);
217262
draftTimeout = setTimeout(() => {
218-
const { draft, field } = parseDraft(ev.target);
219-
saveDraft(draft, field);
263+
const { draft, $field } = parseDraft(ev.target);
264+
saveDraft(draft, $field);
220265
}, 1000);
221266
});
222267

@@ -299,35 +344,37 @@ $(() => {
299344

300345
$postFields.parents('form').on('submit', async (ev) => {
301346
const $tgt = $(ev.target);
302-
const field = $tgt.find('.post-field');
347+
const $field = $tgt.find('.js-post-field');
303348

304349
const draftDeleted = $tgt.attr('data-draft-deleted') === 'true';
305350
const isValidated = $tgt.attr('data-validated') === 'true';
306351

307352
if (draftDeleted && isValidated) {
308353
return;
309354
}
355+
310356
ev.preventDefault();
311357

312358
// Draft handling
313359
if (!draftDeleted) {
314-
const resp = await QPixel.fetchJSON('/posts/delete-draft', { path: location.pathname });
315-
if (resp.status === 200) {
360+
const status = await deleteDraft($field);
361+
362+
if (status) {
316363
$tgt.attr('data-draft-deleted', 'true');
317364

318365
if (isValidated) {
319366
$tgt.submit();
320367
}
321368
}
322369
else {
323-
QPixel.createNotification('danger', `Failed to delete post draft. (${resp.status})`);
370+
QPixel.createNotification('danger', `Failed to delete post draft. (${status})`);
324371
}
325372
}
326373

327374

328375
// Validation
329376
if (!isValidated) {
330-
const text = $(field).val()?.toString();
377+
const text = $field.val()?.toString();
331378
const validated = QPixel.validatePost(text);
332379
if (validated[0] === true) {
333380
$tgt.attr('data-validated', 'true');
@@ -369,7 +416,7 @@ $(() => {
369416
});
370417

371418
$('.js-draft-loaded').each((_i, e) => {
372-
$(e).parents('.widget').after(`<div class="notice is-info has-font-size-caption">
419+
$(e).parents('.widget').after(`<div class="notice is-info has-font-size-caption js-draft-notice">
373420
<i class="fas fa-exclamation-circle"></i> <strong>Draft loaded.</strong>
374421
You had edited this before but haven't saved it. We loaded the edits for you.
375422
</div>`);
@@ -456,7 +503,9 @@ $(() => {
456503
return;
457504
}
458505

459-
await QPixel.fetchJSON('/posts/delete-draft', { path: location.pathname });
506+
const { $field } = getDraftElements(ev.target);
507+
508+
await deleteDraft($field);
460509

461510
location.href = $btn.attr('href');
462511
});

app/assets/javascripts/qpixel_api.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,14 +411,18 @@ window.QPixel = {
411411
},
412412

413413
handleJSONResponse: (data, onSuccess, onFinally) => {
414-
if (data.status === 'success') {
414+
const is_success = data.status === 'success';
415+
416+
if (is_success) {
415417
onSuccess(data);
416418
}
417419
else {
418420
QPixel.createNotification('danger', data.message);
419421
}
420422

421423
onFinally?.(data);
424+
425+
return is_success;
422426
},
423427

424428
flag: async (flag) => {
@@ -452,6 +456,18 @@ window.QPixel = {
452456
return data;
453457
},
454458

459+
deleteDraft: async () => {
460+
const resp = await QPixel.fetchJSON(`/posts/delete-draft`, {
461+
path: location.pathname
462+
}, {
463+
headers: { 'Accept': 'application/json' }
464+
});
465+
466+
const data = await resp.json();
467+
468+
return data;
469+
},
470+
455471
undeleteComment: async (id) => {
456472
const resp = await QPixel.fetchJSON(`/comments/${id}/delete`, {}, {
457473
headers: { 'Accept': 'application/json' },
@@ -491,5 +507,16 @@ window.QPixel = {
491507
const data = await resp.json();
492508

493509
return data;
494-
}
510+
},
511+
512+
saveDraft: async (draft) => {
513+
const resp = await QPixel.fetchJSON('/posts/save-draft', {
514+
...draft,
515+
path: location.pathname
516+
});
517+
518+
const data = await resp.json();
519+
520+
return data;
521+
},
495522
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<div class="button-list is-gutterless">
2+
<a href="javascript:void(0)"
3+
class="button is-muted is-outlined is-icon-only js-save-draft"
4+
aria-label="Save draft"
5+
title="Save draft"
6+
role="button">
7+
<i class="fas fa-save"></i>
8+
</a>
9+
<a href="javascript:void(0)"
10+
class="button is-muted is-outlined is-icon-only js-delete-draft"
11+
aria-label="Delete saved draft"
12+
title="Delete saved draft"
13+
role="button">
14+
<i class="fas fa-trash has-color-red"></i>
15+
</a>
16+
</div>

app/views/shared/_markdown_tools.html.erb

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,5 @@
8181
</div>
8282
</div>
8383

84-
<div class="button-list is-gutterless">
85-
<a href="javascript:void(0)" class="button is-muted is-outlined is-icon-only js-save-draft" aria-label="Save draft"
86-
title="Save draft" role="button">
87-
<i class="fas fa-save"></i>
88-
</a>
89-
</div>
84+
<%= render 'shared/draft_tools' %>
9085
</div>

app/views/users/edit_profile.html.erb

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222

2323
<%= form_for current_user, url: update_user_profile_path do |f| %>
2424
<div class="form-group has-padding-2">
25-
<img alt="user avatar" class="has-float-right has-margin-2 avatar-64" title="Current avatar" src="<%= avatar_url(current_user, 64) %>" height="64" width="64" />
25+
<img alt="user avatar"
26+
class="has-float-right has-margin-2 avatar-64"
27+
title="Current avatar"
28+
src="<%= avatar_url(current_user, 64) %>"
29+
height="64"
30+
width="64" />
2631
<%= f.label :avatar, class: "form-element" %>
2732
<div class="form-caption">
2833
An optional profile picture. Max file size <%= SiteSetting['MaxUploadSize'] %>.
@@ -34,28 +39,43 @@
3439
<div class="form-group has-padding-2">
3540
<%= f.label :username, class: "form-element" %>
3641
<div class="form-caption">What other people call you.</div>
37-
<%= f.text_field :username, class: 'form-element', autocomplete: 'off', data: { character_count: '.js-character-count-user-name' } %>
42+
<%= f.text_field :username,
43+
class: 'form-element',
44+
autocomplete: 'off',
45+
data: { character_count: '.js-character-count-user-name' } %>
3846
<%= render 'shared/char_count', type: 'user-name', cur: current_user.username&.length, min: 3, max: 50 %>
3947
</div>
4048

41-
<%= render 'shared/body_field', f: f, field_name: :profile_markdown, field_label: 'Profile', post: current_user,
42-
cur_length: current_user.profile_markdown&.length, min_length: 0 %>
49+
<%= render 'shared/body_field',
50+
f: f,
51+
field_name: :profile_markdown,
52+
field_label: 'Profile',
53+
post: current_user,
54+
cur_length: current_user.profile_markdown&.length,
55+
min_length: 0 %>
4356

4457
<% unless current_user.community_user.privilege?('unrestricted') %>
4558
<p>Note: Links are not shown publicly until you have earned the Participate Everywhere ability.</p>
4659
<% end %>
4760
<div class="post-preview"></div>
4861

4962
<div>
50-
<p>Extra fields -- your web site, GitHub profile, social-media usernames, whatever you want. Only values that begin with "http" are rendered as links.</p>
63+
<p>Extra fields -- your web site, GitHub profile, social-media usernames, whatever you want.<br/>
64+
Only values that begin with "http" are rendered as links.</p>
5165
<div class="grid">
5266
<%= f.fields_for :user_websites do |w| %>
5367
<div class="grid grid--cell is-12 is-12-sm">
5468
<div class="grid grid--cell is-3 is-3-sm">
55-
<div class="grid--cell is-12"><%= w.text_field :label, class: 'form-element', autocomplete: 'off', placeholder: 'label' %></div>
69+
<div class="grid--cell is-12"><%= w.text_field :label,
70+
class: 'form-element',
71+
autocomplete: 'off',
72+
placeholder: 'label' %></div>
5673
</div>
5774
<div class="grid grid--cell is-6 is-9-sm">
58-
<div class="grid--cell is-12"><%= w.text_field :url, class: 'form-element', autocomplete: 'off', placeholder: 'https://...' %></div>
75+
<div class="grid--cell is-12"><%= w.text_field :url,
76+
class: 'form-element',
77+
autocomplete: 'off',
78+
placeholder: 'https://...' %></div>
5979
</div>
6080
</div>
6181
<% end %>
@@ -70,6 +90,11 @@
7090

7191

7292
<%= f.submit 'Save', class: 'button is-filled' %>
93+
<%= link_to 'Cancel',
94+
users_me_path,
95+
class: 'button is-muted is-outlined js-cancel-edit',
96+
data: { question_body: t('posts.unsaved_changes_confirmation') },
97+
role: 'button' %>
7398
<% end %>
7499

75100
<% if SiteSetting['AllowContentTransfer'] && current_user.se_acct_id.nil? %>

0 commit comments

Comments
 (0)