diff --git a/app/assets/javascripts/posts.js b/app/assets/javascripts/posts.js index 4e5270fed..21d6de5fb 100644 --- a/app/assets/javascripts/posts.js +++ b/app/assets/javascripts/posts.js @@ -117,51 +117,91 @@ $(() => { }); /** - * @typedef {{ - * body: string - * comment?: string - * excerpt?: string - * license?: string - * tag_name?: string - * tags?: string[] - * title?: string - * }} PostDraft - * + * Temporarily displays the draft's status with a given message + * @param {JQuery} $field draftable field + * @param {string} message draft status message + * @returns {void} + */ + const flashDraftStatus = ($field, message) => { + const $statusEl = $field.parents('.widget').find('.js-post-draft-status'); + + $statusEl.text(message); + $statusEl.removeClass('transparent'); + + setTimeout(() => { + $statusEl.addClass('transparent'); + }, 1500); + }; + + /** + * Removes the "draft loaded" notice from the page + * @returns {void} + */ + const removeDraftLoadedNotice = () => { + document.querySelector('.js-draft-notice')?.remove(); + }; + + /** * Attempts to save a post draft - * @param {PostDraft} draft post draft - * @param {JQuery} $field body input element + * @param {QPixelDraft} draft post draft + * @param {JQuery} $field draftable field * @param {boolean} [manual] whether manual draft saving is enabled * @returns {Promise} */ const saveDraft = async (draft, $field, manual = false) => { const autosavePref = await QPixel.preference('autosave', true); + if (autosavePref !== 'on' && !manual) { return; } - const resp = await QPixel.fetchJSON('/posts/save-draft', { ...draft, path: location.pathname }); + const data = await QPixel.saveDraft(draft); - if (resp.status === 200) { - const $statusEl = $field.parents('.widget').find('.js-post-draft-status'); + QPixel.handleJSONResponse(data, () => { + flashDraftStatus($field, 'draft saved'); + }); + }; + + /** + * Attempts to remove a post draft + * @param {JQuery} $field draftable field + * @returns {Promise} + */ + const deleteDraft = async ($field) => { + const data = await QPixel.deleteDraft(); - $statusEl.removeClass('transparent'); + return QPixel.handleJSONResponse(data, () => { + flashDraftStatus($field, 'draft deleted'); + removeDraftLoadedNotice(); + }); + } - setTimeout(() => { - $statusEl.addClass('transparent'); - }, 1500); - } + /** + * Helper for getting draft-related elements from a given event target + * @param {EventTarget} target post field or one of the draft buttons + * @returns {{ + * $form: JQuery, + * $field: JQuery + * }} + */ + const getDraftElements = (target) => { + const $tgt = $(target); + const $form = $tgt.parents('form'); + const $field = $form.find('.js-post-field'); + return { $form, $field }; }; /** * Extracts draft info from a given target - * @param {EventTarget} target post input field or "save draft" button - * @returns {{ draft: PostDraft, field: any }} + * @param {EventTarget} target post field or one of the draft buttons + * @returns {{ + * draft: QPixelDraft, + * $field: JQuery + * }} */ const parseDraft = (target) => { - const $tgt = $(target); - const $form = $tgt.parents('form'); + const { $field: $bodyField, $form } = getDraftElements(target); - const $bodyField = $form.find('.js-post-field'); const $licenseField = $form.find('.js-license-select'); const $excerptField = $form.find('.js-tag-excerpt'); @@ -178,7 +218,7 @@ $(() => { const titleText = $titleField.val()?.toString(); const tagName = $tagNameField.val()?.toString(); - /** @type {PostDraft} */ + /** @type {QPixelDraft} */ const draft = { body: bodyText, comment: commentText, @@ -189,12 +229,17 @@ $(() => { title: titleText, }; - return { draft, field: $bodyField }; + return { draft, $field: $bodyField }; }; + $('.js-delete-draft').on('click', async (ev) => { + const { $field } = getDraftElements(ev.target); + await deleteDraft($field); + }); + $('.js-save-draft').on('click', async (ev) => { - const { draft, field } = parseDraft(ev.target); - await saveDraft(draft, field, true); + const { draft, $field } = parseDraft(ev.target); + await saveDraft(draft, $field, true); }); let featureTimeout = null; @@ -215,8 +260,8 @@ $(() => { $(draftFieldsSelectors.join(', ')).on('keyup change', (ev) => { clearTimeout(draftTimeout); draftTimeout = setTimeout(() => { - const { draft, field } = parseDraft(ev.target); - saveDraft(draft, field); + const { draft, $field } = parseDraft(ev.target); + saveDraft(draft, $field); }, 1000); }); @@ -299,7 +344,7 @@ $(() => { $postFields.parents('form').on('submit', async (ev) => { const $tgt = $(ev.target); - const field = $tgt.find('.post-field'); + const $field = $tgt.find('.js-post-field'); const draftDeleted = $tgt.attr('data-draft-deleted') === 'true'; const isValidated = $tgt.attr('data-validated') === 'true'; @@ -307,12 +352,14 @@ $(() => { if (draftDeleted && isValidated) { return; } + ev.preventDefault(); // Draft handling if (!draftDeleted) { - const resp = await QPixel.fetchJSON('/posts/delete-draft', { path: location.pathname }); - if (resp.status === 200) { + const status = await deleteDraft($field); + + if (status) { $tgt.attr('data-draft-deleted', 'true'); if (isValidated) { @@ -320,14 +367,14 @@ $(() => { } } else { - QPixel.createNotification('danger', `Failed to delete post draft. (${resp.status})`); + QPixel.createNotification('danger', `Failed to delete post draft. (${status})`); } } // Validation if (!isValidated) { - const text = $(field).val()?.toString(); + const text = $field.val()?.toString(); const validated = QPixel.validatePost(text); if (validated[0] === true) { $tgt.attr('data-validated', 'true'); @@ -369,7 +416,7 @@ $(() => { }); $('.js-draft-loaded').each((_i, e) => { - $(e).parents('.widget').after(`
+ $(e).parents('.widget').after(`
Draft loaded. You had edited this before but haven't saved it. We loaded the edits for you.
`); @@ -456,7 +503,9 @@ $(() => { return; } - await QPixel.fetchJSON('/posts/delete-draft', { path: location.pathname }); + const { $field } = getDraftElements(ev.target); + + await deleteDraft($field); location.href = $btn.attr('href'); }); diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 68cccddae..6245629a8 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -411,7 +411,9 @@ window.QPixel = { }, handleJSONResponse: (data, onSuccess, onFinally) => { - if (data.status === 'success') { + const is_success = data.status === 'success'; + + if (is_success) { onSuccess(data); } else { @@ -419,6 +421,8 @@ window.QPixel = { } onFinally?.(data); + + return is_success; }, flag: async (flag) => { @@ -452,6 +456,18 @@ window.QPixel = { return data; }, + deleteDraft: async () => { + const resp = await QPixel.fetchJSON(`/posts/delete-draft`, { + path: location.pathname + }, { + headers: { 'Accept': 'application/json' } + }); + + const data = await resp.json(); + + return data; + }, + undeleteComment: async (id) => { const resp = await QPixel.fetchJSON(`/comments/${id}/delete`, {}, { headers: { 'Accept': 'application/json' }, @@ -491,5 +507,16 @@ window.QPixel = { const data = await resp.json(); return data; - } + }, + + saveDraft: async (draft) => { + const resp = await QPixel.fetchJSON('/posts/save-draft', { + ...draft, + path: location.pathname + }); + + const data = await resp.json(); + + return data; + }, }; diff --git a/app/views/shared/_draft_tools.html.erb b/app/views/shared/_draft_tools.html.erb new file mode 100644 index 000000000..2935299de --- /dev/null +++ b/app/views/shared/_draft_tools.html.erb @@ -0,0 +1,16 @@ + diff --git a/app/views/shared/_markdown_tools.html.erb b/app/views/shared/_markdown_tools.html.erb index a7d95d34d..601a25421 100644 --- a/app/views/shared/_markdown_tools.html.erb +++ b/app/views/shared/_markdown_tools.html.erb @@ -81,10 +81,5 @@
-
- - - -
+ <%= render 'shared/draft_tools' %> diff --git a/app/views/users/edit_profile.html.erb b/app/views/users/edit_profile.html.erb index 654a2ef29..577787773 100644 --- a/app/views/users/edit_profile.html.erb +++ b/app/views/users/edit_profile.html.erb @@ -22,7 +22,12 @@ <%= form_for current_user, url: update_user_profile_path do |f| %>
- user avatar + user avatar <%= f.label :avatar, class: "form-element" %>
An optional profile picture. Max file size <%= SiteSetting['MaxUploadSize'] %>. @@ -34,12 +39,20 @@
<%= f.label :username, class: "form-element" %>
What other people call you.
- <%= f.text_field :username, class: 'form-element', autocomplete: 'off', data: { character_count: '.js-character-count-user-name' } %> + <%= f.text_field :username, + class: 'form-element', + autocomplete: 'off', + data: { character_count: '.js-character-count-user-name' } %> <%= render 'shared/char_count', type: 'user-name', cur: current_user.username&.length, min: 3, max: 50 %>
- <%= render 'shared/body_field', f: f, field_name: :profile_markdown, field_label: 'Profile', post: current_user, - cur_length: current_user.profile_markdown&.length, min_length: 0 %> + <%= render 'shared/body_field', + f: f, + field_name: :profile_markdown, + field_label: 'Profile', + post: current_user, + cur_length: current_user.profile_markdown&.length, + min_length: 0 %> <% unless current_user.community_user.privilege?('unrestricted') %>

Note: Links are not shown publicly until you have earned the Participate Everywhere ability.

@@ -47,15 +60,22 @@
-

Extra fields -- your web site, GitHub profile, social-media usernames, whatever you want. Only values that begin with "http" are rendered as links.

+

Extra fields -- your web site, GitHub profile, social-media usernames, whatever you want.
+ Only values that begin with "http" are rendered as links.

<%= f.fields_for :user_websites do |w| %>
-
<%= w.text_field :label, class: 'form-element', autocomplete: 'off', placeholder: 'label' %>
+
<%= w.text_field :label, + class: 'form-element', + autocomplete: 'off', + placeholder: 'label' %>
-
<%= w.text_field :url, class: 'form-element', autocomplete: 'off', placeholder: 'https://...' %>
+
<%= w.text_field :url, + class: 'form-element', + autocomplete: 'off', + placeholder: 'https://...' %>
<% end %> @@ -70,6 +90,11 @@ <%= f.submit 'Save', class: 'button is-filled' %> + <%= link_to 'Cancel', + users_me_path, + class: 'button is-muted is-outlined js-cancel-edit', + data: { question_body: t('posts.unsaved_changes_confirmation') }, + role: 'button' %> <% end %> <% if SiteSetting['AllowContentTransfer'] && current_user.se_acct_id.nil? %> diff --git a/global.d.ts b/global.d.ts index d70d51d1c..58c225881 100644 --- a/global.d.ts +++ b/global.d.ts @@ -258,6 +258,16 @@ type QPixelUser = { username: string } +type QPixelDraft = { + body: string + comment?: string + excerpt?: string + license?: string + tag_name?: string + tags?: string[] + title?: string +} + type QPixelFlagData = { flag_type: number | null post_id: string @@ -486,13 +496,13 @@ interface QPixel { /** * Processes JSON responses from QPixel API - * @param data + * @param data parsed response JSON body from the API * @param onSuccess callback to call for successful requests * @param onFinally callback to call for all requests */ handleJSONResponse?: (data: T, onSuccess: (data: T) => void, - onFinally?: (data: T) => void) => void + onFinally?: (data: T) => void) => boolean /** * Attempts to delete a comment @@ -508,6 +518,19 @@ interface QPixel { */ followComments?: (postId: string) => Promise + /** + * Attempts to delete a given post draft + * @returns result of the operation + */ + deleteDraft?: () => Promise + + /** + * Attempts to save a post draft + * @param draft draft to save + * @returns result of the operation + */ + saveDraft?: (draft: QPixelDraft) => Promise + /** * Attempts to undelete a comment * @param id id of the comment to undelete