feat: Adds the ability to edit article (#5232)

This commit is contained in:
Muhsin Keloth 2022-08-16 17:55:34 +05:30 committed by GitHub
parent b5e497a6a2
commit b71291619c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 326 additions and 130 deletions

View file

@ -42,7 +42,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params
params.require(:article).permit(
:title, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status
:title, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description,
{ tags: [] }]
)
end

View file

@ -21,6 +21,17 @@ class ArticlesAPI extends PortalsAPI {
if (category_slug) baseUrl += `&category_slug=${category_slug}`;
return axios.get(baseUrl);
}
getArticle({ id, portalSlug }) {
return axios.get(`${this.url}/${portalSlug}/articles/${id}`);
}
updateArticle({ portalSlug, articleId, articleObj }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/${articleId}`,
articleObj
);
}
}
export default new ArticlesAPI();

View file

@ -26,4 +26,30 @@ describe('#PortalAPI', () => {
);
});
});
describeWithAPIMock('API calls', context => {
it('#getArticle', () => {
articlesAPI.getArticle({
id: 1,
portalSlug: 'room-rental',
});
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles/1'
);
});
});
describeWithAPIMock('API calls', context => {
it('#updateArticle', () => {
articlesAPI.updateArticle({
articleId: 1,
portalSlug: 'room-rental',
articleObj: { title: 'Update shipping address' },
});
expect(context.axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles/1',
{
title: 'Update shipping address',
}
);
});
});
});

View file

@ -18,13 +18,14 @@
}
},
"EDIT_HEADER": {
"ALL_ARTICLES": "All Articles",
"PUBLISH_BUTTON": "Publish",
"PREVIEW": "Preview",
"ADD_TRANSLATION": "Add translation",
"OPEN_SIDEBAR": "Open sidebar",
"CLOSE_SIDEBAR": "Close sidebar",
"SAVING": "Draft saving...",
"SAVED": "Draft saved"
"SAVING": "Saving...",
"SAVED": "Saved"
},
"ARTICLE_SETTINGS": {
"TITLE": "Article Settings",
@ -175,8 +176,12 @@
}
},
"EDIT_ARTICLE": {
"LOADING": "Loading article...",
"TITLE_PLACEHOLDER": "Article title goes here",
"CONTENT_PLACEHOLDER": "Write your article here"
"CONTENT_PLACEHOLDER": "Write your article here",
"API": {
"ERROR": "Error while saving article"
}
},
"SIDEBAR": {
"SEARCH": {

View file

@ -25,7 +25,9 @@
</template>
<script>
import { debounce } from '@chatwoot/utils';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
export default {
components: {
WootMessageEditor,
@ -49,6 +51,13 @@ export default {
mounted() {
this.articleTitle = this.article.title;
this.articleContent = this.article.content;
this.saveArticle = debounce(
values => {
this.$emit('save-article', values);
},
300,
false
);
},
methods: {
onFocus() {
@ -58,10 +67,10 @@ export default {
this.$emit('blur');
},
onTitleInput() {
this.$emit('titleInput', this.articleTitle);
this.saveArticle({ title: this.articleTitle });
},
onContentInput() {
this.$emit('contentInput', this.articleContent);
this.saveArticle({ content: this.articleContent });
},
},
};

View file

@ -6,6 +6,7 @@
</div>
<div class="header-right--wrap">
<woot-button
v-if="shouldShowSettings"
class-names="article--buttons"
icon="filter"
color-scheme="secondary"
@ -16,6 +17,7 @@
{{ $t('HELP_CENTER.HEADER.FILTER') }}
</woot-button>
<woot-button
v-if="shouldShowSettings"
class-names="article--buttons"
icon="arrow-sort"
color-scheme="secondary"
@ -68,6 +70,7 @@
</woot-dropdown-menu>
</div>
<woot-button
v-if="shouldShowSettings"
v-tooltip.top-end="$t('HELP_CENTER.HEADER.SETTINGS_BUTTON')"
icon="settings"
class-names="article--buttons"
@ -113,6 +116,10 @@ export default {
type: String,
default: '',
},
shouldShowSettings: {
type: Boolean,
default: false,
},
},
data() {
return {

View file

@ -12,9 +12,10 @@
</woot-button>
</div>
<div class="header-right--wrap">
<span v-if="showDraftStatus" class="draft-status">
{{ draftStatusText }}
<span v-if="isUpdating || isSaved" class="draft-status">
{{ statusText }}
</span>
<woot-button
class-names="article--buttons"
icon="globe"
@ -73,9 +74,13 @@ export default {
type: String,
default: '',
},
draftState: {
type: String,
default: '',
isUpdating: {
type: Boolean,
default: false,
},
isSaved: {
type: Boolean,
default: false,
},
},
data() {
@ -84,20 +89,10 @@ export default {
};
},
computed: {
isDraftStatusSavingOrSaved() {
return this.draftState === 'saving' || 'saved';
},
draftStatusText() {
if (this.draftState === 'saving') {
return this.$t('HELP_CENTER.EDIT_HEADER.SAVING');
}
if (this.draftState === 'saved') {
return this.$t('HELP_CENTER.EDIT_HEADER.SAVED');
}
return '';
},
showDraftStatus() {
return this.isDraftStatusSavingOrSaved;
statusText() {
return this.isUpdating
? this.$t('HELP_CENTER.EDIT_HEADER.SAVING')
: this.$t('HELP_CENTER.EDIT_HEADER.SAVED');
},
},
methods: {
@ -150,5 +145,14 @@ export default {
color: var(--s-400);
align-items: center;
font-size: var(--font-size-mini);
animation: fadeIn 1s;
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}
</style>

View file

@ -186,6 +186,7 @@ export default {
portalSlug: this.selectedPortalSlug,
});
});
this.$store.dispatch('agents/get');
},
toggleKeyShortcutModal() {
this.showShortcutModal = true;

View file

@ -1,9 +1,9 @@
import { action } from '@storybook/addon-actions';
import EditArticle from './EditArticle.vue';
import ArticleEditor from './ArticleEditor.vue';
export default {
title: 'Components/Help Center',
component: EditArticle,
component: ArticleEditor,
argTypes: {
article: {
defaultValue: {},
@ -16,9 +16,9 @@ export default {
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { EditArticle },
components: { ArticleEditor },
template:
'<edit-article v-bind="$props" @focus="onFocus" @blur="onBlur"></edit-article>',
'<article-editor v-bind="$props" @focus="onFocus" @blur="onBlur"></-article>',
});
export const EditArticleView = Template.bind({});

View file

@ -8,7 +8,7 @@
<label>
{{ $t('HELP_CENTER.ARTICLE_SETTINGS.FORM.CATEGORY.LABEL') }}
<multiselect-dropdown
:options="categoryList"
:options="categories"
:selected-item="selectedCategory"
:has-thumbnail="false"
:multiselector-title="
@ -31,7 +31,7 @@
<label>
{{ $t('HELP_CENTER.ARTICLE_SETTINGS.FORM.AUTHOR.LABEL') }}
<multiselect-dropdown
:options="authorList"
:options="agents"
:selected-item="assignedAuthor"
:multiselector-title="
$t('HELP_CENTER.ARTICLE_SETTINGS.FORM.AUTHOR.TITLE')
@ -51,18 +51,19 @@
<label>
{{ $t('HELP_CENTER.ARTICLE_SETTINGS.FORM.META_TITLE.LABEL') }}
<textarea
v-model="title"
v-model="metaTitle"
rows="3"
type="text"
:placeholder="
$t('HELP_CENTER.ARTICLE_SETTINGS.FORM.META_TITLE.PLACEHOLDER')
"
@input="onChangeMetaInput"
/>
</label>
<label>
{{ $t('HELP_CENTER.ARTICLE_SETTINGS.FORM.META_DESCRIPTION.LABEL') }}
<textarea
v-model="description"
v-model="metaDescription"
rows="3"
type="text"
:placeholder="
@ -70,19 +71,20 @@
'HELP_CENTER.ARTICLE_SETTINGS.FORM.META_DESCRIPTION.PLACEHOLDER'
)
"
@input="onChangeMetaInput"
/>
</label>
<label>
{{ $t('HELP_CENTER.ARTICLE_SETTINGS.FORM.META_TAGS.LABEL') }}
<multiselect
ref="tagInput"
v-model="values"
v-model="metaTags"
:placeholder="
$t('HELP_CENTER.ARTICLE_SETTINGS.FORM.META_TAGS.PLACEHOLDER')
"
label="name"
:options="metaOptions"
track-by="name"
:options="options"
:multiple="true"
:taggable="true"
@tag="addTagValue"
@ -115,60 +117,88 @@
<script>
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown';
import { mapGetters } from 'vuex';
import { debounce } from '@chatwoot/utils';
import { isEmptyObject } from 'dashboard/helper/commons.js';
export default {
components: {
MultiselectDropdown,
},
props: {
article: {
type: Object,
required: true,
},
},
data() {
return {
// Dummy value
categoryList: [
{
id: 1,
name: 'Getting started',
},
{
id: 2,
name: 'Features',
},
],
selectedCategory: {
id: 1,
name: 'Features',
},
authorList: [
{
id: 1,
name: 'John Doe',
},
{
id: 2,
name: 'Jane Doe',
},
],
assignedAuthor: {
id: 1,
name: 'John Doe',
},
title: '',
description: '',
values: [],
options: [],
metaTitle: '',
metaDescription: '',
metaTags: [],
metaOptions: [],
};
},
computed: {
...mapGetters({
categories: 'categories/allCategories',
agents: 'agents/getAgents',
}),
assignedAuthor() {
return this.article?.author;
},
selectedCategory() {
return this.article?.category;
},
allTags() {
return this.metaTags.map(item => item.name);
},
},
mounted() {
if (!isEmptyObject(this.article.meta)) {
const {
meta: { title = '', description = '', tags = [] },
} = this.article;
this.metaTitle = title;
this.metaDescription = description;
this.metaTags = this.formattedTags({ tags });
}
this.saveArticle = debounce(
() => {
this.$emit('save-article', {
meta: {
title: this.metaTitle,
description: this.metaDescription,
tags: this.allTags,
},
});
},
1000,
false
);
},
methods: {
formattedTags({ tags }) {
return tags.map(tag => ({
name: tag,
}));
},
addTagValue(tagValue) {
const tag = {
name: tagValue,
};
this.values.push(tag);
this.metaTags.push(tag);
this.$refs.tagInput.$el.focus();
this.saveArticle();
},
onClickSelectCategory() {
this.$emit('select-category');
onClickSelectCategory({ id }) {
this.$emit('save-article', { category_id: id });
},
onClickAssignAuthor() {
this.$emit('assign-author');
onClickAssignAuthor({ id }) {
this.$emit('save-article', { author_id: id });
},
onChangeMetaInput() {
this.saveArticle();
},
onClickArchiveArticle() {
this.$emit('archive-article');

View file

@ -1,39 +1,131 @@
<template>
<div class="container">
<edit-article-header
back-button-label="All Articles"
draft-state="saved"
@back="onClickGoBack"
<div class="article-container">
<div
class="edit-article--container"
:class="{ 'is-sidebar-open': showArticleSettings }"
>
<edit-article-header
:back-button-label="$t('HELP_CENTER.HEADER.TITLES.ALL_ARTICLES')"
:is-updating="isUpdating"
:is-saved="isSaved"
@back="onClickGoBack"
@open="openArticleSettings"
@close="closeArticleSettings"
/>
<div v-if="isFetching" class="text-center p-normal fs-default h-full">
<spinner size="" />
<span>{{ $t('HELP_CENTER.EDIT_ARTICLE.LOADING') }}</span>
</div>
<article-editor
v-else
:is-settings-sidebar-open="showArticleSettings"
:article="article"
@save-article="saveArticle"
/>
</div>
<article-settings
v-if="showArticleSettings"
:article="article"
@save-article="saveArticle"
/>
<edit-article-field :article="article" />
</div>
</template>
<script>
import EditArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/EditArticleHeader';
import EditArticleField from 'dashboard/components/helpCenter/EditArticle';
import { mapGetters } from 'vuex';
import EditArticleHeader from '../../components/Header/EditArticleHeader.vue';
import ArticleEditor from '../../components/ArticleEditor.vue';
import ArticleSettings from './ArticleSettings.vue';
import Spinner from 'shared/components/Spinner';
import portalMixin from '../../mixins/portalMixin';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
EditArticleHeader,
EditArticleField,
ArticleEditor,
Spinner,
ArticleSettings,
},
props: {
article: {
type: Object,
default: () => {},
mixins: [portalMixin, alertMixin],
data() {
return {
isUpdating: false,
isSaved: false,
showArticleSettings: false,
};
},
computed: {
...mapGetters({
isFetching: 'articles/isFetching',
articles: 'articles/articles',
}),
article() {
return this.$store.getters['articles/articleById'](this.articleId);
},
articleId() {
return this.$route.params.articleSlug;
},
selectedPortalSlug() {
return this.portalSlug || this.selectedPortal?.slug;
},
},
mounted() {
this.fetchArticleDetails();
},
methods: {
onClickGoBack() {
this.$router.push({ name: 'list_all_locale_articles' });
},
fetchArticleDetails() {
this.$store.dispatch('articles/show', {
id: this.articleId,
portalSlug: this.selectedPortalSlug,
});
},
async saveArticle({ ...values }) {
this.isUpdating = true;
try {
await this.$store.dispatch('articles/update', {
portalSlug: this.selectedPortalSlug,
articleId: this.articleId,
...values,
});
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.EDIT_ARTICLE.API.ERROR_MESSAGE');
} finally {
setTimeout(() => {
this.isUpdating = false;
this.isSaved = true;
}, 1500);
}
},
openArticleSettings() {
this.showArticleSettings = true;
},
closeArticleSettings() {
this.showArticleSettings = false;
},
},
};
</script>
<style lang="scss" scoped>
.container {
.article-container {
display: flex;
padding: var(--space-small) var(--space-normal);
width: 100%;
flex: 1;
overflow: scroll;
.edit-article--container {
flex: 1;
flex-shrink: 0;
overflow: scroll;
}
.is-sidebar-open {
flex: 0.7;
}
}
</style>

View file

@ -1,35 +1,21 @@
<template>
<div class="article-container">
<div
class="edit-article--container"
:class="{ 'is-sidebar-open': showArticleSettings }"
>
<edit-article-header
back-button-label="All Articles"
draft-state="saved"
@back="onClickGoBack"
@open="openArticleSettings"
@close="closeArticleSettings"
/>
<edit-article-field
:is-settings-sidebar-open="showArticleSettings"
@titleInput="titleInput"
@contentInput="contentInput"
/>
</div>
<article-settings v-if="showArticleSettings" />
<div class="container">
<edit-article-header
back-button-label="All Articles"
draft-state="saved"
@back="onClickGoBack"
/>
<article-editor @titleInput="titleInput" @contentInput="contentInput" />
</div>
</template>
<script>
import EditArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/EditArticleHeader';
import EditArticleField from 'dashboard/components/helpCenter/EditArticle';
import ArticleSettings from 'dashboard/routes/dashboard/helpcenter/pages/articles/ArticleSettings';
import ArticleEditor from '../../components/ArticleEditor.vue';
export default {
components: {
EditArticleHeader,
EditArticleField,
ArticleSettings,
ArticleEditor,
},
data() {
return {

View file

@ -62,18 +62,24 @@ export const actions = {
commit(types.SET_UI_FLAG, { isFetching: false });
}
},
update: async ({ commit }, params) => {
const articleId = params.id;
update: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
commit(types.ADD_ARTICLE_FLAG, {
uiFlags: {
isUpdating: true,
},
articleId,
});
try {
const { data } = await articlesAPI.update(params);
commit(types.UPDATE_ARTICLE, data);
try {
const {
data: { payload },
} = await articlesAPI.updateArticle({
portalSlug,
articleId,
articleObj,
});
commit(types.UPDATE_ARTICLE, payload);
return articleId;
} catch (error) {

View file

@ -19,7 +19,7 @@ export const mutations = {
[types.CLEAR_ARTICLES]: $state => {
Vue.set($state.articles, 'byId', {});
Vue.set($state.articles, 'allIds', []);
Vue.set($state.articles, 'uiFlags', {});
Vue.set($state.articles, 'uiFlags.byId', {});
},
[types.ADD_MANY_ARTICLES]($state, articles) {
const allArticles = { ...$state.articles.byId };
@ -55,7 +55,6 @@ export const mutations = {
},
[types.UPDATE_ARTICLE]($state, article) {
const articleId = article.id;
if (!$state.articles.allIds.includes(articleId)) return;
Vue.set($state.articles.byId, articleId, {

View file

@ -89,8 +89,15 @@ describe('#actions', () => {
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: articleList[0] });
await actions.update({ commit }, articleList[0]);
axios.patch.mockResolvedValue({ data: { payload: articleList[0] } });
await actions.update(
{ commit },
{
portalSlug: 'room-rental',
articleId: 1,
title: 'Documents are required to complete KYC',
}
);
expect(commit.mock.calls).toEqual([
[
types.default.ADD_ARTICLE_FLAG,
@ -105,9 +112,17 @@ describe('#actions', () => {
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.update({ commit }, articleList[0])).rejects.toThrow(
Error
);
await expect(
actions.update(
{ commit },
{
portalSlug: 'room-rental',
articleId: 1,
title: 'Documents are required to complete KYC',
}
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[
types.default.ADD_ARTICLE_FLAG,

View file

@ -106,7 +106,11 @@ describe('#mutations', () => {
mutations[types.CLEAR_ARTICLES](state);
expect(state.articles.allIds).toEqual([]);
expect(state.articles.byId).toEqual({});
expect(state.articles.uiFlags).toEqual({});
expect(state.articles.uiFlags).toEqual({
byId: {
'1': { isFetching: false, isUpdating: true, isDeleting: false },
},
});
});
});
});

View file

@ -5,7 +5,7 @@ json.description article.description
json.status article.status
json.account_id article.account_id
json.updated_at article.updated_at.to_i
json.meta article.meta
json.category do
json.id article.category_id
json.name article.category.name

View file

@ -20,7 +20,7 @@
"dependencies": {
"@braid/vue-formulate": "^2.5.2",
"@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#7e8acadd10d7b932c0dc0bd0a18f804434f83517",
"@chatwoot/utils": "^0.0.6",
"@chatwoot/utils": "^0.0.10",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@rails/actioncable": "6.1.3",
"@rails/webpacker": "5.3.0",

View file

@ -1406,10 +1406,10 @@
prosemirror-state "^1.3.3"
prosemirror-view "^1.17.2"
"@chatwoot/utils@^0.0.6":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@chatwoot/utils/-/utils-0.0.6.tgz#76d7b17d692b5b656c565b9b714b98e0f2bc1324"
integrity sha512-fCvULfJSFSylDAiGh1cPAX5nQkVsmG5ASGm/E6YBYg8cox/2JU179JFstdtTxrIJg/YeHukcaq85Gc+/16ShPQ==
"@chatwoot/utils@^0.0.10":
version "0.0.10"
resolved "https://registry.npmjs.org/@chatwoot/utils/-/utils-0.0.10.tgz#59f68cc28d8718b261ebed8b9c94d2c493b6c67f"
integrity sha512-Zd+wQTblWKUV1mhcXoabcfoLygx/Ock5pP0JQdfqW64lubhjYaRR4gCutEgqUcQB4nuOUH7MZ7BTzdZm4RoM/g==
dependencies:
date-fns "^2.22.1"