+ {{ + t( + 'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION' + ) + }} +
+), so we keep those together.
+const splitBlocks = text => {
+ const lines = (text || '').split('\n');
+ const blocks = [];
+ let buffer = [];
+ let fence = null;
+ let inList = false;
+
+ const flush = () => {
+ const block = buffer.join('\n');
+ if (block.trim()) blocks.push(block);
+ buffer = [];
+ inList = false;
+ };
+
+ lines.forEach((line, index) => {
+ const marker = line.match(FENCE_RE)?.[1];
+ if (marker && !fence) fence = marker;
+ else if (fence && line.trimStart().startsWith(fence)) fence = null;
+
+ if (fence) {
+ buffer.push(line);
+ return;
+ }
+
+ if (LIST_ITEM_RE.test(line)) inList = true;
+
+ if (line.trim() !== '') {
+ buffer.push(line);
+ return;
+ }
+
+ // Blank line: keep it when the current list continues on the next non-blank
+ // line (another item or an indented continuation); otherwise end the block.
+ const next = lines.slice(index + 1).find(other => other.trim() !== '');
+ if (inList && next && (LIST_ITEM_RE.test(next) || /^\s/.test(next))) {
+ buffer.push(line);
+ } else {
+ flush();
+ }
+ });
+
+ flush();
+ return blocks;
+};
+
+const BLOCK_TYPE = { equal: 'equal', del: 'removed', ins: 'added' };
+
+// Diffs the body block by block. Blocks match when they render to the same HTML
+// (the check staging uses), so only edits that change the page show as a diff.
+export const buildDiffBlocks = (oldText, newText) => {
+ const toBlocks = text =>
+ splitBlocks(text).map(md => ({ md, key: commonmark.render(md) }));
+ const ops = diffSequence(toBlocks(oldText), toBlocks(newText), b => b.key);
+ return ops.map(op => ({ type: BLOCK_TYPE[op.type], md: op.item.md }));
+};
+
+export const hasPendingChanges = article =>
+ article?.draftTitle != null || article?.draftContent != null;
diff --git a/app/javascript/dashboard/helper/specs/articleDiffHelper.spec.js b/app/javascript/dashboard/helper/specs/articleDiffHelper.spec.js
new file mode 100644
index 000000000..9e9f0cea9
--- /dev/null
+++ b/app/javascript/dashboard/helper/specs/articleDiffHelper.spec.js
@@ -0,0 +1,170 @@
+import {
+ renderInlineDiff,
+ buildDiffBlocks,
+ hasPendingChanges,
+ rendersIdentically,
+} from '../articleDiffHelper';
+
+describe('articleDiffHelper', () => {
+ describe('renderInlineDiff', () => {
+ it('returns the text unchanged when there is no difference', () => {
+ const result = renderInlineDiff('hello world', 'hello world');
+ expect(result).toBe('hello world');
+ expect(result).not.toContain('', () => {
+ const result = renderInlineDiff('hello', 'hello there');
+ expect(result).toContain('hello');
+ expect(result).toContain('', () => {
+ const result = renderInlineDiff('hello there', 'hello');
+ expect(result).toContain(' {
+ const result = renderInlineDiff(
+ 'How to use Agent bots?',
+ 'How How to Agent bots?'
+ );
+ expect(result).toBe(
+ 'How How to use Agent bots?'
+ );
+ });
+
+ it('escapes markup when diffing plain text', () => {
+ const result = renderInlineDiff('a', 'a ');
+ expect(result).toContain('<b>');
+ expect(result).not.toContain('');
+ });
+
+ it('treats a cleared empty string as a full deletion', () => {
+ const result = renderInlineDiff('gone', '');
+ expect(result).toContain(' {
+ it('passes an unchanged block through as equal', () => {
+ const blocks = buildDiffBlocks('same para', 'same para');
+ expect(blocks).toEqual([{ type: 'equal', md: 'same para' }]);
+ });
+
+ it('marks an appended block as added', () => {
+ const blocks = buildDiffBlocks('a', 'a\n\nb');
+ expect(blocks).toContainEqual({ type: 'equal', md: 'a' });
+ expect(blocks).toContainEqual({ type: 'added', md: 'b' });
+ });
+
+ it('marks a deleted block as removed', () => {
+ const blocks = buildDiffBlocks('a\n\nb', 'a');
+ expect(blocks).toContainEqual({ type: 'removed', md: 'b' });
+ });
+
+ it('emits the old block then the new block for a reworded section', () => {
+ const blocks = buildDiffBlocks('hello world', 'hello there');
+ expect(blocks).toEqual([
+ { type: 'removed', md: 'hello world' },
+ { type: 'added', md: 'hello there' },
+ ]);
+ });
+
+ it('keeps a fenced code block whole when it contains blank lines', () => {
+ const code = '```\nline one\n\nline two\n```';
+ const blocks = buildDiffBlocks(code, code);
+ expect(blocks).toEqual([{ type: 'equal', md: code }]);
+ });
+
+ it('diffs an edited code block as one whole removed + added block', () => {
+ const live = '```\ncode line\n```';
+ const draft = '```\ncode line\n\nsd\n```';
+ const blocks = buildDiffBlocks(live, draft);
+ expect(blocks).toContainEqual({ type: 'removed', md: live });
+ expect(blocks).toContainEqual({ type: 'added', md: draft });
+ });
+
+ it('surfaces whitespace edits that change the rendered output', () => {
+ expect(
+ buildDiffBlocks('```\nx\n```', '```\n x\n```').some(
+ block => block.type !== 'equal'
+ )
+ ).toBe(true);
+ expect(
+ buildDiffBlocks('line one\nline two', 'line one \nline two').some(
+ block => block.type !== 'equal'
+ )
+ ).toBe(true);
+ });
+
+ it('surfaces an indented code block turning into a paragraph', () => {
+ const blocks = buildDiffBlocks(
+ ' curl example.com',
+ 'curl example.com'
+ );
+ expect(blocks).toContainEqual({
+ type: 'removed',
+ md: ' curl example.com',
+ });
+ expect(blocks).toContainEqual({ type: 'added', md: 'curl example.com' });
+ });
+
+ it('keeps spacing the renderer ignores as equal', () => {
+ const blocks = buildDiffBlocks('a\nb', 'a \nb');
+ expect(blocks.every(block => block.type === 'equal')).toBe(true);
+ });
+
+ it('keeps a loose list with item descriptions as one block', () => {
+ const list =
+ '1. **One**\n\n First item.\n\n2. **Two**\n\n Second item.';
+ const blocks = buildDiffBlocks(list, list);
+ expect(blocks).toEqual([{ type: 'equal', md: list }]);
+ });
+ });
+
+ describe('rendersIdentically', () => {
+ it('ignores blank-line / empty-paragraph differences', () => {
+ expect(rendersIdentically('a\n\nb', 'a\n\n\nb')).toBe(true);
+ expect(rendersIdentically('hello', 'hello\n\n')).toBe(true);
+ });
+
+ it('counts code-block indentation changes', () => {
+ expect(rendersIdentically('```\n x\n```', '```\nx\n```')).toBe(false);
+ });
+
+ it('counts smart vs straight quotes (no typographer)', () => {
+ expect(rendersIdentically('"hi"', '“hi”')).toBe(false);
+ });
+
+ it('counts real text changes', () => {
+ expect(rendersIdentically('hello world', 'hello there')).toBe(false);
+ });
+
+ it('treats nullish input as empty', () => {
+ expect(rendersIdentically(null, '')).toBe(true);
+ expect(rendersIdentically(undefined, 'x')).toBe(false);
+ });
+ });
+
+ describe('hasPendingChanges', () => {
+ it('is true when a draft title or content is staged', () => {
+ expect(hasPendingChanges({ draftContent: 'edit' })).toBe(true);
+ expect(hasPendingChanges({ draftTitle: 'edit' })).toBe(true);
+ });
+
+ it('treats a cleared empty-string draft as a pending change', () => {
+ expect(hasPendingChanges({ draftTitle: '' })).toBe(true);
+ });
+
+ it('is false with no draft columns', () => {
+ expect(hasPendingChanges({ title: 'live' })).toBe(false);
+ expect(hasPendingChanges({})).toBe(false);
+ expect(hasPendingChanges(null)).toBe(false);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index ae099a7a9..53a745dc2 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -533,6 +533,8 @@
"PUBLISHED": "Published",
"ARCHIVED": "Archived"
},
+ "PENDING_EDITS": "Unpublished edits",
+ "PENDING_EDITS_TOOLTIP": "This published article has unpublished edits",
"CATEGORY": {
"UNCATEGORISED": "Uncategorised"
}
@@ -616,6 +618,8 @@
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
+ "STATUS_SKIPPED": "1 article with unpublished edits was skipped — open it to publish or discard. | {count} articles with unpublished edits were skipped — open them to publish or discard.",
+ "STATUS_SKIPPED_ALL": "These articles have unpublished edits — open each to publish or discard.",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
@@ -763,10 +767,30 @@
},
"PREVIEW": "Preview",
"PUBLISH": "Publish",
+ "PUBLISH_CHANGES": "Publish changes",
+ "PUBLISH_CHANGES_SUCCESS": "Changes published successfully",
+ "PUBLISH_CHANGES_ERROR": "Could not publish changes",
+ "SAVE_IN_PROGRESS": "Still saving your latest changes — please try again in a moment.",
+ "DISCARD_CHANGES": "Discard changes",
+ "DISCARD_CHANGES_SUCCESS": "Changes discarded",
+ "DISCARD_CHANGES_ERROR": "Could not discard changes",
+ "PENDING_CHANGES": "Pending changes",
+ "VIEW_CHANGES": "View unpublished changes",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"BACK_TO_ARTICLES": "Back to articles"
},
+ "PENDING_CHANGES_POPOVER": {
+ "TITLE": "Unpublished changes",
+ "DESCRIPTION": "This article has draft changes that aren't live yet. Apply them before changing the status, or discard them?",
+ "APPLY": "Apply changes",
+ "DISCARD": "Discard changes"
+ },
+ "DIFF_DIALOG": {
+ "TITLE": "Unpublished changes",
+ "DESCRIPTION": "Compare your draft against the version that's currently live.",
+ "TITLE_LABEL": "Title"
+ },
"EDIT_ARTICLE": {
"MORE_PROPERTIES": "More properties",
"UNCATEGORIZED": "Uncategorized",
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue
index 36508ded5..794f8c839 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue
@@ -4,8 +4,12 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
-import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
+import {
+ buildPortalArticleURL,
+ ARTICLE_STATUSES,
+} from 'dashboard/helper/portalHelper';
import { useStore, useMapGetter } from 'dashboard/composables/store';
+import { rendersIdentically } from 'dashboard/helper/articleDiffHelper';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
@@ -40,13 +44,60 @@ const articleLink = computed(() => {
);
});
+// On a published article, title/content edits stage into draft_* columns (kept
+// off the live site). Anywhere else they save straight to the live record — and
+// we drop any leftover draft (e.g. left behind when the card/bulk menu moved a
+// published article to draft) so a later publish can't resurrect stale content.
+const stageDraftFields = values => {
+ if (article.value?.status !== ARTICLE_STATUSES.PUBLISHED) {
+ const hasStaleDraft =
+ article.value?.draftTitle != null || article.value?.draftContent != null;
+ if (!hasStaleDraft) return values;
+ // The editor is showing the staged draft, so promote both fields to the live
+ // record (the field being autosaved wins) before dropping the drafts —
+ // otherwise saving one field would snap the other back to the old live value.
+ return {
+ ...values,
+ title: values.title ?? article.value.draftTitle ?? article.value.title,
+ content:
+ values.content ?? article.value.draftContent ?? article.value.content,
+ draft_title: null,
+ draft_content: null,
+ };
+ }
+
+ const staged = { ...values };
+ ['title', 'content'].forEach(field => {
+ if (field in staged) {
+ staged[`draft_${field}`] = staged[field];
+ delete staged[field];
+ }
+ });
+
+ // Clear the draft when it matches the live version (a revert, or a body edit
+ // the renderer ignores like a blank line) so it doesn't leave a "pending
+ // changes" badge with nothing to compare. The title is shown as raw escaped
+ // text, so compare it exactly; only the body is Markdown, so compare its render.
+ const liveTitle = article.value.title ?? '';
+ const liveContent = article.value.content ?? '';
+ const nextTitle = staged.draft_title ?? article.value.draftTitle ?? liveTitle;
+ const nextContent =
+ staged.draft_content ?? article.value.draftContent ?? liveContent;
+ if (nextTitle === liveTitle && rendersIdentically(liveContent, nextContent)) {
+ staged.draft_title = null;
+ staged.draft_content = null;
+ }
+
+ return staged;
+};
+
const saveArticle = async ({ ...values }) => {
isUpdating.value = true;
try {
await store.dispatch('articles/update', {
portalSlug,
articleId: articleSlug,
- ...values,
+ ...stageDraftFields(values),
});
isSaved.value = true;
} catch (error) {
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
index 7dd42e749..a9da13106 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
@@ -96,6 +96,32 @@ export const actions = {
}
},
+ // Push the draft to live and clear it, optionally changing status in the same
+ // update. Only edited fields are sent so an untouched live value survives.
+ publishDraft: ({ dispatch, state }, { portalSlug, articleId, status }) => {
+ const article = state.articles.byId[articleId];
+ const payload = {
+ portalSlug,
+ articleId,
+ status,
+ draft_title: null,
+ draft_content: null,
+ };
+ if (article?.draftTitle != null) payload.title = article.draftTitle;
+ if (article?.draftContent != null) payload.content = article.draftContent;
+ return dispatch('update', payload);
+ },
+
+ // Clear the draft (optionally changing status); live content is left untouched.
+ discardDraft: ({ dispatch }, { portalSlug, articleId, status }) =>
+ dispatch('update', {
+ portalSlug,
+ articleId,
+ status,
+ draft_title: null,
+ draft_content: null,
+ }),
+
updateArticleMeta: async ({ commit }, { portalSlug, locale }) => {
try {
const { data } = await articlesAPI.getArticles({
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
index 6517f44fd..5f3215fab 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
@@ -150,6 +150,101 @@ describe('#actions', () => {
});
});
+ describe('#publishDraft', () => {
+ const state = {
+ articles: {
+ byId: {
+ 1: {
+ id: 1,
+ draftTitle: 'Draft title',
+ draftContent: 'Draft content',
+ },
+ },
+ },
+ };
+
+ it('dispatches update promoting the edited fields and clearing the draft', async () => {
+ await actions.publishDraft(
+ { dispatch, state },
+ { portalSlug: 'room-rental', articleId: 1 }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: undefined,
+ draft_title: null,
+ draft_content: null,
+ title: 'Draft title',
+ content: 'Draft content',
+ });
+ });
+
+ it('only sends the fields that were actually edited', async () => {
+ const partialState = {
+ articles: { byId: { 1: { id: 1, draftContent: 'Only content' } } },
+ };
+ await actions.publishDraft(
+ { dispatch, state: partialState },
+ { portalSlug: 'room-rental', articleId: 1 }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: undefined,
+ draft_title: null,
+ draft_content: null,
+ content: 'Only content',
+ });
+ });
+
+ it('forwards a status to change it in the same update', async () => {
+ await actions.publishDraft(
+ { dispatch, state },
+ { portalSlug: 'room-rental', articleId: 1, status: 'archived' }
+ );
+ expect(dispatch).toHaveBeenCalledWith(
+ 'update',
+ expect.objectContaining({
+ status: 'archived',
+ title: 'Draft title',
+ content: 'Draft content',
+ draft_title: null,
+ draft_content: null,
+ })
+ );
+ });
+ });
+
+ describe('#discardDraft', () => {
+ it('dispatches update clearing the draft columns', async () => {
+ await actions.discardDraft(
+ { dispatch },
+ { portalSlug: 'room-rental', articleId: 1 }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: undefined,
+ draft_title: null,
+ draft_content: null,
+ });
+ });
+
+ it('forwards a status to change it in the same update', async () => {
+ await actions.discardDraft(
+ { dispatch },
+ { portalSlug: 'room-rental', articleId: 1, status: 'draft' }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: 'draft',
+ draft_title: null,
+ draft_content: null,
+ });
+ });
+ });
+
describe('#updateArticleMeta', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js
index 1a14266f0..b5dc61702 100644
--- a/app/javascript/shared/helpers/MessageFormatter.js
+++ b/app/javascript/shared/helpers/MessageFormatter.js
@@ -67,8 +67,11 @@ const createMarkdownInstance = (linkify = true) => {
// `` comment before the table. It exists only for the
// editor's markdown round-trip and must never surface as text — markdown-it runs
// with `html: false`, which would otherwise escape it into a visible comment in
-// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
-const COLWIDTHS_MARKER_REGEX = /\r?\n?/g;
+// rendered/plain output (e.g. dashboard search snippets). Strip the whole marker
+// line, including any blockquote prefix, so a quoted table's `>` prefixes don't
+// collapse together and break table parsing.
+const COLWIDTHS_MARKER_REGEX =
+ /^[ \t>]*[ \t]*\r?\n?/gm;
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 20d64005a..70e71c46d 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -153,6 +153,15 @@ After`;
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
expect(formatter.plainText).not.toContain('cw-colwidths');
});
+
+ it('strips a blockquote-prefixed marker so the quoted table still renders', () => {
+ const message =
+ '> \n> | A | B |\n> | --- | --- |\n> | 1 | 2 |';
+ const { formattedMessage } = new MessageFormatter(message);
+ expect(formattedMessage).not.toContain('cw-colwidths');
+ expect(formattedMessage).toContain('');
+ expect(formattedMessage).toContain('
');
+ });
});
describe('#sanitize', () => {
diff --git a/app/models/article.rb b/app/models/article.rb
index 9d1247e8b..eb843493a 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -5,6 +5,8 @@
# id :bigint not null, primary key
# content :text
# description :text
+# draft_content :text
+# draft_title :string
# locale :string default("en"), not null
# meta :jsonb
# position :integer
diff --git a/app/views/api/v1/accounts/articles/_article.json.jbuilder b/app/views/api/v1/accounts/articles/_article.json.jbuilder
index a6eb7a551..426b24afe 100644
--- a/app/views/api/v1/accounts/articles/_article.json.jbuilder
+++ b/app/views/api/v1/accounts/articles/_article.json.jbuilder
@@ -4,6 +4,8 @@ json.title article.title
json.content article.content
json.description article.description
json.status article.status
+json.draft_title article.draft_title
+json.draft_content article.draft_content
json.position article.position
json.account_id article.account_id
json.updated_at article.updated_at.to_i
diff --git a/db/migrate/20260623000000_add_draft_columns_to_articles.rb b/db/migrate/20260623000000_add_draft_columns_to_articles.rb
new file mode 100644
index 000000000..a6f5e0cd7
--- /dev/null
+++ b/db/migrate/20260623000000_add_draft_columns_to_articles.rb
@@ -0,0 +1,6 @@
+class AddDraftColumnsToArticles < ActiveRecord::Migration[7.1]
+ def change
+ add_column :articles, :draft_title, :string
+ add_column :articles, :draft_content, :text
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 43e7135b9..14b634c30 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -211,6 +211,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.string "slug", null: false
t.integer "position"
t.string "locale", default: "en", null: false
+ t.string "draft_title"
+ t.text "draft_content"
t.index ["account_id"], name: "index_articles_on_account_id"
t.index ["associated_article_id"], name: "index_articles_on_associated_article_id"
t.index ["author_id"], name: "index_articles_on_author_id"
diff --git a/spec/controllers/api/v1/accounts/articles_controller_spec.rb b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
index bf3ae2aa9..6651a5b20 100644
--- a/spec/controllers/api/v1/accounts/articles_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
@@ -170,6 +170,29 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(json_response['payload']['status']).to eql(article_params[:article][:status])
expect(json_response['payload']['position']).to eql(article_params[:article][:position])
end
+
+ it 'stages draft-only fields without bumping updated_at' do
+ expect do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
+ params: { article: { draft_title: 'Draft title', draft_content: 'Draft body' } },
+ headers: admin.create_new_auth_token
+ end.not_to(change { article.reload.updated_at })
+
+ expect(response).to have_http_status(:success)
+ expect(article.draft_title).to eq('Draft title')
+ expect(article.draft_content).to eq('Draft body')
+ end
+
+ it 'rejects an over-length draft without persisting it' do
+ expect do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
+ params: { article: { draft_content: 'a' * 20_001 } },
+ headers: admin.create_new_auth_token
+ end.not_to(change { article.reload.draft_content })
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['message']).to include('too long')
+ end
end
end