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
19 changed files with 326 additions and 130 deletions

View File

@@ -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>

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

@@ -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'),
};

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 {