Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bd00424
abstract /delete-draft request logic & ensured drafts can be manually…
Oaphi Dec 1, 2024
e265b8c
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Dec 3, 2024
9f34700
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Dec 6, 2024
fe39f71
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Mar 31, 2025
963c090
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Aug 9, 2025
933e372
added 'cancel' button for the edit profile tab (redirects to current …
Oaphi Aug 9, 2025
373d943
added markdown tools button for discarding drafts
Oaphi Aug 9, 2025
f492bd1
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Aug 10, 2025
b1f8ca3
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Aug 13, 2025
9fe3d30
moved post draft typedef to glboal.d.ts & added saveDraft client API …
Oaphi Aug 13, 2025
c0852c1
added deleteDraft client API method
Oaphi Aug 13, 2025
ddb3b27
added draft delete indicator & ensured cancelling user profile edit d…
Oaphi Aug 13, 2025
72ff2a3
minor cleanup of the draft-related client helpers
Oaphi Aug 13, 2025
13501e5
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Aug 14, 2025
3adea95
adjusted delete draft button's title to make it clearer
Oaphi Aug 14, 2025
f2c38be
forgot to adjust the delete draft's button aria-label
Oaphi Aug 14, 2025
49503af
ensured 'draft loaded' notice is removed upon discarding the draft
Oaphi Aug 14, 2025
cc4c70f
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Aug 14, 2025
ba669b4
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Aug 29, 2025
a2b7af8
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Sep 1, 2025
79ee877
Merge branch 'develop' into 0valt/1459/draft-discard
Oaphi Sep 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 77 additions & 38 deletions app/assets/javascripts/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,51 +132,81 @@ $(() => {
});

/**
* @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<Element>} $field draftable field
* @param {string} message draft status message
*/
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);
};

/**
* Attempts to save a post draft
* @param {PostDraft} draft post draft
* @param {JQuery<Element>} $field body input element
* @param {QPixelDraft} draft post draft
* @param {JQuery<Element>} $field draftable field
* @param {boolean} [manual] whether manual draft saving is enabled
* @returns {Promise<void>}
*/
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');
});
};

$statusEl.removeClass('transparent');
/**
* Attempts to remove a post draft
* @param {JQuery<Element>} $field draftable field
* @returns {Promise<boolean>}
*/
const deleteDraft = async ($field) => {
const data = await QPixel.deleteDraft();

setTimeout(() => {
$statusEl.addClass('transparent');
}, 1500);
}
return QPixel.handleJSONResponse(data, () => {
flashDraftStatus($field, 'draft deleted');
});
}

/**
* 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<HTMLFormElement>,
* $field: JQuery<HTMLElement>
* }}
*/
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<HTMLElement>
* }}
*/
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');

Expand All @@ -193,7 +223,7 @@ $(() => {
const titleText = $titleField.val()?.toString();
const tagName = $tagNameField.val()?.toString();

/** @type {PostDraft} */
/** @type {QPixelDraft} */
const draft = {
body: bodyText,
comment: commentText,
Expand All @@ -204,12 +234,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;
Expand All @@ -230,8 +265,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);
});

Expand Down Expand Up @@ -314,35 +349,37 @@ $(() => {

$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';

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) {
$tgt.submit();
}
}
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');
Expand Down Expand Up @@ -384,7 +421,7 @@ $(() => {
});

$('.js-draft-loaded').each((_i, e) => {
$(e).parents('.widget').after(`<div class="notice is-info has-font-size-caption">
$(e).parents('.widget').after(`<div class="notice is-info has-font-size-caption js-draft-notice">
<i class="fas fa-exclamation-circle"></i> <strong>Draft loaded.</strong>
You had edited this before but haven't saved it. We loaded the edits for you.
</div>`);
Expand Down Expand Up @@ -471,7 +508,9 @@ $(() => {
return;
}

await QPixel.fetchJSON('/posts/delete-draft', { path: location.pathname });
const { $field } = getDraftElements(ev.target);

await deleteDraft($field);

location.href = $btn.attr('href');
});
Expand Down
31 changes: 29 additions & 2 deletions app/assets/javascripts/qpixel_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,14 +444,18 @@ window.QPixel = {
},

handleJSONResponse: (data, onSuccess, onFinally) => {
if (data.status === 'success') {
const is_success = data.status === 'success';

if (is_success) {
onSuccess(data);
}
else {
QPixel.createNotification('danger', data.message);
}

onFinally?.(data);

return is_success;
},

flag: async (flag) => {
Expand All @@ -475,6 +479,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' },
Expand All @@ -494,5 +510,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;
},
};
16 changes: 16 additions & 0 deletions app/views/shared/_draft_tools.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div class="button-list is-gutterless">
<a href="javascript:void(0)"
class="button is-muted is-outlined is-icon-only js-save-draft"
aria-label="Save draft"
title="Save draft"
role="button">
<i class="fas fa-save"></i>
</a>
<a href="javascript:void(0)"
class="button is-muted is-outlined is-icon-only js-delete-draft"
aria-label="Delete draft"
title="Delete draft"
role="button">
<i class="fas fa-trash has-color-red"></i>
</a>
</div>
7 changes: 1 addition & 6 deletions app/views/shared/_markdown_tools.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,5 @@
</div>
</div>

<div class="button-list is-gutterless">
<a href="javascript:void(0)" class="button is-muted is-outlined is-icon-only js-save-draft" aria-label="Save draft"
title="Save draft" role="button">
<i class="fas fa-save"></i>
</a>
</div>
<%= render 'shared/draft_tools' %>
</div>
39 changes: 32 additions & 7 deletions app/views/users/edit_profile.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@

<%= form_for current_user, url: update_user_profile_path do |f| %>
<div class="form-group has-padding-2">
<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" />
<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" />
<%= f.label :avatar, class: "form-element" %>
<div class="form-caption">
An optional profile picture. Max file size <%= SiteSetting['MaxUploadSize'] %>.
Expand All @@ -34,28 +39,43 @@
<div class="form-group has-padding-2">
<%= f.label :username, class: "form-element" %>
<div class="form-caption">What other people call you.</div>
<%= 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 %>
</div>

<%= 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') %>
<p>Note: Links are not shown publicly until you have earned the Participate Everywhere ability.</p>
<% end %>
<div class="post-preview"></div>

<div>
<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>
<p>Extra fields -- your web site, GitHub profile, social-media usernames, whatever you want.<br/>
Only values that begin with "http" are rendered as links.</p>
<div class="grid">
<%= f.fields_for :user_websites do |w| %>
<div class="grid grid--cell is-12 is-12-sm">
<div class="grid grid--cell is-3 is-3-sm">
<div class="grid--cell is-12"><%= w.text_field :label, class: 'form-element', autocomplete: 'off', placeholder: 'label' %></div>
<div class="grid--cell is-12"><%= w.text_field :label,
class: 'form-element',
autocomplete: 'off',
placeholder: 'label' %></div>
</div>
<div class="grid grid--cell is-6 is-9-sm">
<div class="grid--cell is-12"><%= w.text_field :url, class: 'form-element', autocomplete: 'off', placeholder: 'https://...' %></div>
<div class="grid--cell is-12"><%= w.text_field :url,
class: 'form-element',
autocomplete: 'off',
placeholder: 'https://...' %></div>
</div>
</div>
<% end %>
Expand All @@ -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? %>
Expand Down
Loading