feat: Adds the ability to edit article (#5232)
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div
|
||||
class="edit-article--container"
|
||||
:class="{ 'is-settings-sidebar-open': isSettingsSidebarOpen }"
|
||||
>
|
||||
<input
|
||||
v-model="articleTitle"
|
||||
type="text"
|
||||
class="article-heading"
|
||||
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@input="onTitleInput"
|
||||
/>
|
||||
<woot-message-editor
|
||||
v-model="articleContent"
|
||||
class="article-content"
|
||||
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.CONTENT_PLACEHOLDER')"
|
||||
:is-format-mode="true"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@input="onContentInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootMessageEditor,
|
||||
},
|
||||
props: {
|
||||
article: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isSettingsSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
articleTitle: '',
|
||||
articleContent: '',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.articleTitle = this.article.title;
|
||||
this.articleContent = this.article.content;
|
||||
this.saveArticle = debounce(
|
||||
values => {
|
||||
this.$emit('save-article', values);
|
||||
},
|
||||
300,
|
||||
false
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
onFocus() {
|
||||
this.$emit('focus');
|
||||
},
|
||||
onBlur() {
|
||||
this.$emit('blur');
|
||||
},
|
||||
onTitleInput() {
|
||||
this.saveArticle({ title: this.articleTitle });
|
||||
},
|
||||
onContentInput() {
|
||||
this.saveArticle({ content: this.articleContent });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-article--container {
|
||||
margin: var(--space-large) auto;
|
||||
width: 640px;
|
||||
}
|
||||
|
||||
.is-settings-sidebar-open {
|
||||
margin: var(--space-large) var(--space-small);
|
||||
}
|
||||
|
||||
.article-heading {
|
||||
font-size: var(--font-size-giga);
|
||||
font-weight: var(--font-weight-bold);
|
||||
min-height: var(--space-jumbo);
|
||||
max-height: var(--space-jumbo);
|
||||
border: 0px solid transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror-menubar .ProseMirror-menuitem {
|
||||
.ProseMirror-icon {
|
||||
margin-right: var(--space-normal);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
min-height: var(--space-giga);
|
||||
max-height: 100%;
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-default);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
font-size: var(--font-size-default);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -186,6 +186,7 @@ export default {
|
||||
portalSlug: this.selectedPortalSlug,
|
||||
});
|
||||
});
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
toggleKeyShortcutModal() {
|
||||
this.showShortcutModal = true;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import ArticleEditor from './ArticleEditor.vue';
|
||||
|
||||
export default {
|
||||
title: 'Components/Help Center',
|
||||
component: ArticleEditor,
|
||||
argTypes: {
|
||||
article: {
|
||||
defaultValue: {},
|
||||
control: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { ArticleEditor },
|
||||
template:
|
||||
'<article-editor v-bind="$props" @focus="onFocus" @blur="onBlur"></-article>',
|
||||
});
|
||||
|
||||
export const EditArticleView = Template.bind({});
|
||||
EditArticleView.args = {
|
||||
article: {
|
||||
id: '1',
|
||||
title: 'Lorem ipsum',
|
||||
content:
|
||||
'L**orem ipsum** dolor sit amet, consectetur adipiscing elit. Congue diam orci tellus *varius per cras turpis aliquet commodo dolor justo* rutrum lorem venenatis aliquet orci curae hac. Sagittis ultrices felis **`ante placerat condimentum parturient erat consequat`** sollicitudin *sagittis potenti sollicitudin* quis velit at placerat mi torquent. Dignissim luctus nulla suspendisse purus cras commodo ipsum orci tempus morbi metus conubia et hac potenti quam suspendisse feugiat. Turpis eros dictum tellus natoque laoreet lacus dolor cras interdum **vitae gravida tincidunt ultricies tempor convallis tortor rhoncus suspendisse.** Nisi lacinia etiam vivamus tellus sed taciti potenti quam praesent congue euismod mauris est eu risus convallis taciti etiam. Inceptos iaculis turpis leo porta pellentesque dictum `bibendum blandit parturient nulla leo pretium` rhoncus litora dapibus fringilla hac litora.',
|
||||
},
|
||||
onFocus: action('focus'),
|
||||
onBlur: action('blur'),
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user