Merge branch 'develop' into vue3-migration

This commit is contained in:
Muhsin Keloth 2022-08-19 11:54:23 +05:30 committed by GitHub
commit e831daf2b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 767 additions and 95 deletions

View file

@ -39,7 +39,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
# TODO: move this to a builder and combine the save account user method into a builder # TODO: move this to a builder and combine the save account user method into a builder
# ensure the account user association is also created in a single transaction # ensure the account user association is also created in a single transaction
def create_user def create_user
return if @user return @user.send_confirmation_instructions if @user
@user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation)) @user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation))
end end

View file

@ -32,6 +32,16 @@ class ArticlesAPI extends PortalsAPI {
articleObj articleObj
); );
} }
createArticle({ portalSlug, articleObj }) {
const { content, title, author_id, category_id } = articleObj;
return axios.post(`${this.url}/${portalSlug}/articles`, {
content,
title,
author_id,
category_id,
});
}
} }
export default new ArticlesAPI(); export default new ArticlesAPI();

View file

@ -1,9 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
class PortalsAPI extends ApiClient { class PortalsAPI extends ApiClient {
constructor() { constructor() {
super('portals', { accountScoped: true }); super('portals', { accountScoped: true });
} }
updatePortal({ portalSlug, params }) {
return axios.patch(`${this.url}/${portalSlug}`, params);
}
} }
export default PortalsAPI; export default PortalsAPI;

View file

@ -42,7 +42,6 @@
overflow: hidden; overflow: hidden;
} }
.border-right { .border-right {
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
} }
@ -66,3 +65,13 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.flex-end {
display: flex;
justify-content: end;
}
.flex-align-center {
align-items: center;
display: flex;
}

View file

@ -91,10 +91,11 @@
font-size: $font-size-default; font-size: $font-size-default;
line-height: 1; line-height: 1;
padding-left: $space-medium; padding-left: $space-medium;
}
.completed { .completed {
color: $success-color; color: $success-color;
} margin-left: $space-smaller;
} }
p { p {

View file

@ -2,7 +2,7 @@
<transition-group <transition-group
name="wizard-items" name="wizard-items"
tag="div" tag="div"
class="wizard-box flex-child-shrink" class="wizard-box"
:class="classObject" :class="classObject"
> >
<div <div
@ -11,12 +11,14 @@
class="item" class="item"
:class="{ active: isActive(item), over: isOver(item) }" :class="{ active: isActive(item), over: isOver(item) }"
> >
<h3> <div class="flex-align-center">
<h3 class="text-truncate">
{{ item.title }} {{ item.title }}
</h3>
<span v-if="isOver(item)" class="completed"> <span v-if="isOver(item)" class="completed">
<fluent-icon icon="checkmark" /> <fluent-icon icon="checkmark" />
</span> </span>
</h3> </div>
<span class="step"> <span class="step">
{{ items.indexOf(item) + 1 }} {{ items.indexOf(item) + 1 }}
</span> </span>

View file

@ -0,0 +1,8 @@
export const getRandomColor = () => {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i += 1) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
};

View file

@ -112,20 +112,55 @@
} }
}, },
"ADD": { "ADD": {
"TITLE": "Create a portal", "CREATE_FLOW": [
"SUB_TITLE": "A Help Center in Chatwoot is known as a portal. You can have multiple portals and can have different locales for each portal.", {
"title": "Help center information",
"route": "new_portal_information",
"body": "Basic information about portal",
"CREATE_BASIC_SETTING_BUTTON": "Create portal basic settings"
},
{
"title": "Help center customization",
"route": "portal_customization",
"body": "Customize portal",
"UPDATE_PORTAL_BUTTON": "Update portal settings"
},
{
"title": "Voila! 🎉",
"route": "portal_finish",
"body": "You're all set!",
"FINISH": "Finish"
}
],
"CREATE_FLOW_PAGE": {
"BACK_BUTTON": "Back",
"BASIC_SETTINGS_PAGE": {
"HEADER": "Create Portal",
"TITLE": "Help center information",
"CREATE_BASIC_SETTING_BUTTON": "Create portal basic settings"
},
"CUSTOMIZATION_PAGE": {
"HEADER": "Portal customisation",
"TITLE": "Help center customization",
"UPDATE_PORTAL_BUTTON": "Update portal settings"
},
"FINISH_PAGE": {
"TITLE": "Voila!🎉 You're all set up!",
"MESSAGE": "You can now see this created portal on your all portals page.",
"FINISH": "Go to all portals page"
}
},
"LOGO": {
"LABEL": "Logo",
"UPLOAD_BUTTON": "Upload logo",
"HELP_TEXT": "This logo will be displayed on the portal header."
},
"NAME": { "NAME": {
"LABEL": "Name", "LABEL": "Name",
"PLACEHOLDER": "Portal name", "PLACEHOLDER": "Portal name",
"HELP_TEXT": "The name will be used in the public facing portal internally", "HELP_TEXT": "The name will be used in the public facing portal internally.",
"ERROR": "Name is required" "ERROR": "Name is required"
}, },
"PAGE_TITLE": {
"LABEL": "Page Title",
"PLACEHOLDER": "Portal page title",
"HELP_TEXT": "The name will be used in the public facing portal",
"ERROR": "Page title is required"
},
"SLUG": { "SLUG": {
"LABEL": "Slug", "LABEL": "Slug",
"PLACEHOLDER": "Portal slug for urls", "PLACEHOLDER": "Portal slug for urls",
@ -135,7 +170,7 @@
"DOMAIN": { "DOMAIN": {
"LABEL": "Custom Domain", "LABEL": "Custom Domain",
"PLACEHOLDER": "Portal custom domain", "PLACEHOLDER": "Portal custom domain",
"HELP_TEXT": "Add only If you want to use a custom domain for your portals", "HELP_TEXT": "Add only If you want to use a custom domain for your portals.",
"ERROR": "Custom Domain is required" "ERROR": "Custom Domain is required"
}, },
"HOME_PAGE_LINK": { "HOME_PAGE_LINK": {
@ -144,19 +179,25 @@
"HELP_TEXT": "The link used to return from the portal to the home page.", "HELP_TEXT": "The link used to return from the portal to the home page.",
"ERROR": "Home Page Link is required" "ERROR": "Home Page Link is required"
}, },
"THEME_COLOR": {
"LABEL": "Portal theme color",
"HELP_TEXT": "This color will show as the theme color for the portal."
},
"PAGE_TITLE": {
"LABEL": "Page Title",
"PLACEHOLDER": "Portal page title",
"HELP_TEXT": "The page title will be used in the public facing portal.",
"ERROR": "Page title is required"
},
"HEADER_TEXT": { "HEADER_TEXT": {
"LABEL": "Header Text", "LABEL": "Header Text",
"PLACEHOLDER": "Portal header text", "PLACEHOLDER": "Portal header text",
"HELP_TEXT": "Portal header text", "HELP_TEXT": "The Portal header text will be used in the public facing portal.",
"ERROR": "Portal header text is required" "ERROR": "Portal header text is required"
}, },
"BUTTONS": {
"CREATE": "Create portal",
"CANCEL": "Cancel"
},
"API": { "API": {
"SUCCESS_MESSAGE": "Portal created successfully.", "ERROR_MESSAGE_FOR_BASIC": "Couldn't create the portal. Try again.",
"ERROR_MESSAGE": "Couldn't create the portal. Try again." "ERROR_MESSAGE_FOR_UPDATE": "Couldn't update the portal. Try again."
} }
} }
}, },
@ -183,6 +224,9 @@
"ERROR": "Error while saving article" "ERROR": "Error while saving article"
} }
}, },
"CREATE_ARTICLE": {
"ERROR_MESSAGE": "Something went wrong. Please try again."
},
"SIDEBAR": { "SIDEBAR": {
"SEARCH": { "SEARCH": {
"PLACEHOLDER": "Search for articles" "PLACEHOLDER": "Search for articles"

View file

@ -6,7 +6,7 @@
@open-key-shortcut-modal="toggleKeyShortcutModal" @open-key-shortcut-modal="toggleKeyShortcutModal"
@close-key-shortcut-modal="closeKeyShortcutModal" @close-key-shortcut-modal="closeKeyShortcutModal"
/> />
<div v-if="portals.length" class="margin-right-small"> <div v-if="portals.length">
<help-center-sidebar <help-center-sidebar
:class="sidebarClassName" :class="sidebarClassName"
:header-title="headerTitle" :header-title="headerTitle"

View file

@ -77,7 +77,7 @@
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.NAME' 'HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.PORTAL_CONFIG.ITEMS.NAME'
) )
}}</label> }}</label>
<span class="text-block-title">{{ portal.header_text }}</span> <span class="text-block-title">{{ portal.name }}</span>
</div> </div>
<div class="configuration-item"> <div class="configuration-item">
<label>{{ <label>{{

View file

@ -10,6 +10,7 @@
color-scheme="secondary" color-scheme="secondary"
icon="settings" icon="settings"
size="small" size="small"
@click="openPortalPage"
> >
{{ $t('HELP_CENTER.PORTAL.POPOVER.PORTAL_SETTINGS') }} {{ $t('HELP_CENTER.PORTAL.POPOVER.PORTAL_SETTINGS') }}
</woot-button> </woot-button>
@ -60,16 +61,10 @@ export default {
closePortalPopover() { closePortalPopover() {
this.$emit('close-popover'); this.$emit('close-popover');
}, },
openPortalPage({ slug, locale }) { openPortalPage() {
this.$emit('close-popover'); this.$emit('close-popover');
const portal = this.portals.find(p => p.slug === slug);
this.$store.dispatch('portals/setPortalId', portal.id);
this.$router.push({ this.$router.push({
name: 'list_all_locale_articles', name: 'list_all_portals',
params: {
portalSlug: slug,
locale: locale,
},
}); });
}, },
}, },
@ -86,6 +81,7 @@ export default {
border-radius: var(--border-radius-normal); border-radius: var(--border-radius-normal);
box-shadow: var(--shadow-large); box-shadow: var(--shadow-large);
max-width: 48rem; max-width: 48rem;
z-index: var(--z-index-high);
header { header {
.actions { .actions {

View file

@ -8,7 +8,9 @@
variant="square" variant="square"
/> />
<div class="header-title--wrap"> <div class="header-title--wrap">
<h4 class="sub-block-title title-view">{{ headerTitle }}</h4> <h4 class="sub-block-title title-view text-truncate">
{{ headerTitle }}
</h4>
<span class="sub-title--view">{{ subTitle }}</span> <span class="sub-title--view">{{ subTitle }}</span>
</div> </div>
</div> </div>
@ -89,6 +91,7 @@ export default {
} }
.title-view { .title-view {
width: var(--space-mega);
margin-bottom: var(--space-zero); margin-bottom: var(--space-zero);
} }

View file

@ -3,18 +3,19 @@ import { getPortalRoute } from './helpers/routeHelper';
const ListAllPortals = () => import('./pages/portals/ListAllPortals'); const ListAllPortals = () => import('./pages/portals/ListAllPortals');
const NewPortal = () => import('./pages/portals/NewPortal'); const NewPortal = () => import('./pages/portals/NewPortal');
const EditPortal = () => import('./pages/portals/EditPortal'); const EditPortal = () => import('./pages/portals/EditPortal');
const ShowPortal = () => import('./pages/portals/ShowPortal'); const ShowPortal = () => import('./pages/portals/ShowPortal');
const PortalDetails = () => import('./pages/portals/PortalDetails');
const PortalCustomization = () => import('./pages/portals/PortalCustomization');
const PortalSettingsFinish = () =>
import('./pages/portals/PortalSettingsFinish');
const ListAllCategories = () => import('./pages/categories/ListAllCategories'); const ListAllCategories = () => import('./pages/categories/ListAllCategories');
const NewCategory = () => import('./pages/categories/NewCategory'); const NewCategory = () => import('./pages/categories/NewCategory');
const EditCategory = () => import('./pages/categories/EditCategory'); const EditCategory = () => import('./pages/categories/EditCategory');
// const ShowCategory = () => import('./pages/categories/ShowCategory');
const ListCategoryArticles = () => const ListCategoryArticles = () =>
import('./pages/articles/ListCategoryArticles'); import('./pages/articles/ListCategoryArticles');
const ListAllArticles = () => import('./pages/articles/ListAllArticles'); const ListAllArticles = () => import('./pages/articles/ListAllArticles');
const NewArticle = () => import('./pages/articles/NewArticle'); const NewArticle = () => import('./pages/articles/NewArticle');
const EditArticle = () => import('./pages/articles/EditArticle'); const EditArticle = () => import('./pages/articles/EditArticle');
@ -27,9 +28,27 @@ const portalRoutes = [
}, },
{ {
path: getPortalRoute('new'), path: getPortalRoute('new'),
name: 'new_portal',
roles: ['administrator', 'agent'],
component: NewPortal, component: NewPortal,
children: [
{
path: '',
name: 'new_portal_information',
component: PortalDetails,
roles: ['administrator'],
},
{
path: ':portal_slug/customization',
name: 'portal_customization',
component: PortalCustomization,
roles: ['administrator'],
},
{
path: ':portal_slug/finish',
name: 'portal_finish',
component: PortalSettingsFinish,
roles: ['administrator'],
},
],
}, },
{ {
path: getPortalRoute(':portalSlug'), path: getPortalRoute(':portalSlug'),

View file

@ -51,12 +51,14 @@ export default {
isUpdating: false, isUpdating: false,
isSaved: false, isSaved: false,
showArticleSettings: false, showArticleSettings: false,
alertMessage: '',
}; };
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
isFetching: 'articles/isFetching', isFetching: 'articles/isFetching',
articles: 'articles/articles', articles: 'articles/articles',
selectedPortal: 'portals/getSelectedPortal',
}), }),
article() { article() {
return this.$store.getters['articles/articleById'](this.articleId); return this.$store.getters['articles/articleById'](this.articleId);
@ -93,6 +95,7 @@ export default {
this.alertMessage = this.alertMessage =
error?.message || error?.message ||
this.$t('HELP_CENTER.EDIT_ARTICLE.API.ERROR_MESSAGE'); this.$t('HELP_CENTER.EDIT_ARTICLE.API.ERROR_MESSAGE');
this.showAlert(this.alertMessage);
} finally { } finally {
setTimeout(() => { setTimeout(() => {
this.isUpdating = false; this.isUpdating = false;

View file

@ -1,22 +1,27 @@
<template> <template>
<div class="container"> <div class="article-container">
<edit-article-header <edit-article-header
back-button-label="All Articles" :back-button-label="$t('HELP_CENTER.HEADER.TITLES.ALL_ARTICLES')"
draft-state="saved" draft-state="saved"
@back="onClickGoBack" @back="onClickGoBack"
@save-article="createNewArticle"
/> />
<article-editor @titleInput="titleInput" @contentInput="contentInput" /> <article-editor :article="article" @save-article="createNewArticle" />
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import EditArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/EditArticleHeader'; import EditArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/EditArticleHeader';
import ArticleEditor from '../../components/ArticleEditor.vue'; import ArticleEditor from '../../components/ArticleEditor.vue';
import portalMixin from '../../mixins/portalMixin';
import alertMixin from 'shared/mixins/alertMixin.js';
export default { export default {
components: { components: {
EditArticleHeader, EditArticleHeader,
ArticleEditor, ArticleEditor,
}, },
mixins: [portalMixin, alertMixin],
data() { data() {
return { return {
articleTitle: '', articleTitle: '',
@ -24,21 +29,55 @@ export default {
showArticleSettings: false, showArticleSettings: false,
}; };
}, },
computed: {
...mapGetters({
selectedPortal: 'portals/getSelectedPortal',
currentUserID: 'getCurrentUserID',
categories: 'categories/allCategories',
}),
article() {
return { title: this.articleTitle, content: this.articleContent };
},
selectedPortalSlug() {
return this.portalSlug || this.selectedPortal?.slug;
},
categoryId() {
return this.categories.length ? this.categories[0].id : null;
},
},
methods: { methods: {
onClickGoBack() { onClickGoBack() {
this.$router.push({ name: 'list_all_locale_articles' }); this.$router.push({ name: 'list_all_locale_articles' });
}, },
titleInput(value) { async createNewArticle({ ...values }) {
this.articleTitle = value; const { title, content } = values;
if (title) this.articleTitle = title;
if (content) this.articleContent = content;
if (this.articleTitle && this.articleContent) {
try {
const articleId = await this.$store.dispatch('articles/create', {
portalSlug: this.selectedPortalSlug,
content: this.articleContent,
title: this.articleTitle,
author_id: this.currentUserID,
// TODO: Change to un categorized later when API supports
category_id: this.categoryId,
});
this.$router.push({
name: 'edit_article',
params: {
articleSlug: articleId,
portalSlug: this.selectedPortalSlug,
locale: this.locale,
}, },
contentInput(value) { });
this.articleContent = value; } catch (error) {
}, this.alertMessage =
openArticleSettings() { error?.message ||
this.showArticleSettings = true; this.$t('HELP_CENTER.CREATE_ARTICLE.API.ERROR_MESSAGE');
}, this.showAlert(this.alertMessage);
closeArticleSettings() { }
this.showArticleSettings = false; }
}, },
}, },
}; };
@ -46,7 +85,6 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.article-container { .article-container {
display: flex;
padding: var(--space-small) var(--space-normal); padding: var(--space-small) var(--space-normal);
width: 100%; width: 100%;
flex: 1; flex: 1;

View file

@ -64,7 +64,7 @@ export default {
}, },
methods: { methods: {
addPortal() { addPortal() {
this.isAddModalOpen = !this.isAddModalOpen; this.$router.push({ name: 'new_portal_information' });
}, },
closeModal() { closeModal() {
this.isAddModalOpen = false; this.isAddModalOpen = false;

View file

@ -1,5 +1,79 @@
<template> <template>
<div> <div class="wrapper">
Component to create a new portal <settings-header
button-route="new"
:header-title="portalHeaderText"
show-back-button
:back-button-label="
$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.BACK_BUTTON')
"
:show-new-button="false"
/>
<div class="row content-box full-height">
<woot-wizard
class="hide-for-small-only medium-3 columns"
:global-config="globalConfig"
:items="items"
/>
<router-view />
</div>
</div> </div>
</template> </template>
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import SettingsHeader from 'dashboard/routes/dashboard/settings/SettingsHeader';
export default {
components: {
SettingsHeader,
},
mixins: [globalConfigMixin],
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
}),
items() {
const allItems = this.$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW').map(
item => ({
...item,
body: this.useInstallationName(
item.body,
this.globalConfig.installationName
),
})
);
return allItems;
},
portalHeaderText() {
if (this.$route.name === 'new_portal_information') {
return this.$t(
'HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.BASIC_SETTINGS_PAGE.HEADER'
);
}
if (this.$route.name === 'portal_customization') {
return this.$t(
'HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.CUSTOMIZATION_PAGE.HEADER'
);
}
return '';
},
},
};
</script>
<style scoped lang="scss">
.wrapper {
flex: 1;
}
.container {
display: flex;
flex: 1;
}
.wizard-box {
border-right: 1px solid var(--s-25);
::v-deep .item {
background: var(--white);
}
}
</style>

View file

@ -0,0 +1,170 @@
<template>
<div class="wizard-body height-auto small-9 columns">
<div class="medium-12 columns">
<h3 class="block-title">
{{
$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.CUSTOMIZATION_PAGE.TITLE')
}}
</h3>
</div>
<div class="portal-form">
<div class="medium-8 columns">
<div class="form-item">
<label>
{{ $t('HELP_CENTER.PORTAL.ADD.THEME_COLOR.LABEL') }}
</label>
<woot-color-picker v-model="color" />
<p class="color-help--text">
{{ $t('HELP_CENTER.PORTAL.ADD.THEME_COLOR.HELP_TEXT') }}
</p>
</div>
<div class="form-item">
<woot-input
v-model.trim="pageTitle"
:label="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.HELP_TEXT')"
/>
</div>
<div class="form-item">
<woot-input
v-model.trim="headerText"
:label="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.HELP_TEXT')"
/>
</div>
<div class="form-item">
<woot-input
v-model.trim="homePageLink"
:label="$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.LABEL')"
:placeholder="
$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.PLACEHOLDER')
"
:help-text="$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.HELP_TEXT')"
/>
</div>
</div>
</div>
<div class="flex-end">
<woot-button
:is-loading="uiFlags.isUpdating"
@click="updatePortalSettings"
>
{{
$t(
'HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.CUSTOMIZATION_PAGE.UPDATE_PORTAL_BUTTON'
)
}}
</woot-button>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { getRandomColor } from 'dashboard/helper/labelColor';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {},
mixins: [alertMixin],
data() {
return {
color: '#000',
pageTitle: '',
headerText: '',
homePageLink: '',
alertMessage: '',
};
},
computed: {
...mapGetters({
uiFlags: 'portals/uiFlagsIn',
portals: 'portals/allPortals',
}),
createdPortalSlug() {
const {
params: { portal_slug: slug },
} = this.$route;
return slug;
},
createdPortalId() {
const { portals } = this;
const createdPortal = portals.find(
portal => portal.slug === this.createdPortalSlug
);
return createdPortal ? createdPortal.id : null;
},
},
mounted() {
this.fetchPortals();
this.color = getRandomColor();
},
methods: {
fetchPortals() {
this.$store.dispatch('portals/index');
},
async updatePortalSettings() {
try {
await this.$store.dispatch('portals/update', {
id: this.createdPortalId,
slug: this.createdPortalSlug,
color: this.color,
page_title: this.pageTitle,
header_text: this.headerText,
homepage_link: this.homePageLink,
config: {
// TODO: add support for choosing locale
allowed_locales: ['en'],
},
});
this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_UPDATE'
);
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.PORTAL.ADD.API.ERROR_MESSAGE_FOR_UPDATE');
} finally {
this.$router.push({
name: 'portal_finish',
});
}
},
},
};
</script>
<style lang="scss" scoped>
.wizard-body {
padding-top: var(--space-slab);
border: 1px solid transparent;
}
.portal-form {
margin: var(--space-normal) 0;
border-bottom: 1px solid var(--s-25);
.form-item {
margin-bottom: var(--space-normal);
.color-help--text {
margin-top: var(--space-smaller);
margin-bottom: 0;
font-size: var(--font-size-mini);
color: var(--s-600);
font-style: normal;
}
}
}
::v-deep {
input {
margin-bottom: var(--space-smaller);
}
.help-text {
margin-bottom: 0;
}
.colorpicker--selected {
margin-bottom: 0;
}
}
</style>

View file

@ -0,0 +1,203 @@
<template>
<div class="wizard-body columns content-box small-9">
<div class="medium-12 columns">
<h3 class="block-title">
{{
$t(
'HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.BASIC_SETTINGS_PAGE.TITLE'
)
}}
</h3>
</div>
<div class="portal-form">
<div class="medium-8 columns">
<div class="form-item">
<label>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL') }}
</label>
<div class="logo-container">
<thumbnail :username="name" size="56" variant="square" />
<woot-button
class="upload-button"
variant="smooth"
color-scheme="secondary"
icon="upload"
size="small"
>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.UPLOAD_BUTTON') }}
</woot-button>
</div>
<p class="logo-help--text">
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.HELP_TEXT') }}
</p>
</div>
<div class="form-item">
<woot-input
v-model.trim="name"
:class="{ error: $v.slug.$error }"
:error="nameError"
:label="$t('HELP_CENTER.PORTAL.ADD.NAME.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.NAME.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.NAME.HELP_TEXT')"
@input="onNameChange"
/>
</div>
<div class="form-item">
<woot-input
v-model.trim="slug"
:class="{ error: $v.slug.$error }"
:error="slugError"
:label="$t('HELP_CENTER.PORTAL.ADD.SLUG.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.SLUG.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.SLUG.HELP_TEXT')"
@input="$v.slug.$touch"
/>
</div>
<div class="form-item">
<woot-input
v-model.trim="domain"
:label="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.HELP_TEXT')"
/>
</div>
</div>
</div>
<div class="flex-end">
<woot-button
:is-loading="uiFlags.isCreating"
:is-disabled="$v.$invalid"
@click="updateBasicSettings"
>
{{
$t(
'HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.BASIC_SETTINGS_PAGE.CREATE_BASIC_SETTING_BUTTON'
)
}}
</woot-button>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import thumbnail from 'dashboard/components/widgets/Thumbnail';
import alertMixin from 'shared/mixins/alertMixin';
import { required, minLength } from 'vuelidate/lib/validators';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
export default {
components: {
thumbnail,
},
mixins: [alertMixin],
data() {
return {
name: '',
slug: '',
domain: '',
alertMessage: '',
};
},
validations: {
name: {
required,
minLength: minLength(2),
},
slug: {
required,
},
},
computed: {
...mapGetters({
uiFlags: 'portals/uiFlagsIn',
}),
nameError() {
if (this.$v.name.$error) {
return this.$t('HELP_CENTER.CATEGORY.ADD.NAME.ERROR');
}
return '';
},
slugError() {
if (this.$v.slug.$error) {
return this.$t('HELP_CENTER.CATEGORY.ADD.SLUG.ERROR');
}
return '';
},
domainError() {
return this.$v.domain.$error;
},
},
methods: {
onNameChange() {
this.slug = convertToCategorySlug(this.name);
},
async updateBasicSettings() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
await this.$store.dispatch('portals/create', {
portal: {
name: this.name,
slug: this.slug,
custom_domain: this.domain,
},
});
this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_BASIC'
);
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.PORTAL.ADD.API.ERROR_MESSAGE_FOR_BASIC');
} finally {
this.$router.push({
name: 'portal_customization',
params: { portal_slug: this.slug },
});
}
},
},
};
</script>
<style lang="scss" scoped>
.wizard-body {
padding-top: var(--space-slab);
border: 1px solid transparent;
}
.portal-form {
margin: var(--space-normal) 0;
border-bottom: 1px solid var(--s-25);
.form-item {
margin-bottom: var(--space-normal);
.logo-container {
display: flex;
align-items: center;
flex-direction: row;
.upload-button {
margin-left: var(--space-slab);
}
}
.logo-help--text {
margin-top: var(--space-smaller);
margin-bottom: 0;
font-size: var(--font-size-mini);
color: var(--s-600);
font-style: normal;
}
}
}
::v-deep {
input {
margin-bottom: var(--space-smaller);
}
.help-text {
margin-bottom: 0;
}
}
</style>

View file

@ -0,0 +1,43 @@
<template>
<div class="wizard-body height-auto small-9 columns">
<empty-state
:title="$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.FINISH_PAGE.TITLE')"
:message="
$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.FINISH_PAGE.MESSAGE')
"
>
<div class="medium-12 columns text-center">
<router-link
class="button success nice"
:to="{
name: 'list_all_portals',
}"
>
{{ $t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.FINISH_PAGE.FINISH') }}
</router-link>
</div>
</empty-state>
</div>
</template>
<script>
import EmptyState from 'dashboard/components/widgets/EmptyState';
export default {
components: {
EmptyState,
},
methods: {
changeRoute() {
this.$router.push({
name: 'list_all_portals',
});
},
},
};
</script>
<style lang="scss" scoped>
.wizard-body {
padding-top: var(--space-slab);
border: 1px solid transparent;
}
</style>

View file

@ -61,6 +61,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import validationMixin from './validationMixin'; import validationMixin from './validationMixin';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import validations from './validations'; import validations from './validations';
import { getRandomColor } from 'dashboard/helper/labelColor';
export default { export default {
mixins: [alertMixin, validationMixin], mixins: [alertMixin, validationMixin],
@ -79,20 +80,12 @@ export default {
}), }),
}, },
mounted() { mounted() {
this.color = this.getRandomColor(); this.color = getRandomColor();
}, },
methods: { methods: {
onClose() { onClose() {
this.$emit('close'); this.$emit('close');
}, },
getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i += 1) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
},
async addLabel() { async addLabel() {
try { try {
await this.$store.dispatch('labels/create', { await this.$store.dispatch('labels/create', {

View file

@ -32,13 +32,20 @@ export const actions = {
} }
}, },
create: async ({ commit }, params) => { create: async ({ commit, dispatch }, { portalSlug, ...articleObj }) => {
commit(types.SET_UI_FLAG, { isCreating: true }); commit(types.SET_UI_FLAG, { isCreating: true });
try { try {
const { data } = await articlesAPI.create(params); const {
const { id: articleId } = data; data: { payload },
commit(types.ADD_ARTICLE, data); } = await articlesAPI.createArticle({
portalSlug,
articleObj,
});
const { id: articleId, portal } = payload;
commit(types.ADD_ARTICLE, payload);
commit(types.ADD_ARTICLE_ID, articleId); commit(types.ADD_ARTICLE_ID, articleId);
commit(types.ADD_ARTICLE_FLAG, articleId);
dispatch('portals/updatePortal', portal, { root: true });
return articleId; return articleId;
} catch (error) { } catch (error) {
return throwErrorMessage(error); return throwErrorMessage(error);
@ -63,7 +70,7 @@ export const actions = {
} }
}, },
update: async ({ commit }, { portalSlug, articleId, ...articleObj }) => { update: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
commit(types.ADD_ARTICLE_FLAG, { commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: { uiFlags: {
isUpdating: true, isUpdating: true,
}, },
@ -85,7 +92,7 @@ export const actions = {
} catch (error) { } catch (error) {
return throwErrorMessage(error); return throwErrorMessage(error);
} finally { } finally {
commit(types.ADD_ARTICLE_FLAG, { commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: { uiFlags: {
isUpdating: false, isUpdating: false,
}, },
@ -94,7 +101,7 @@ export const actions = {
} }
}, },
delete: async ({ commit }, articleId) => { delete: async ({ commit }, articleId) => {
commit(types.ADD_ARTICLE_FLAG, { commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: { uiFlags: {
isDeleting: true, isDeleting: true,
}, },
@ -109,7 +116,7 @@ export const actions = {
} catch (error) { } catch (error) {
return throwErrorMessage(error); return throwErrorMessage(error);
} finally { } finally {
commit(types.ADD_ARTICLE_FLAG, { commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: { uiFlags: {
isDeleting: false, isDeleting: false,
}, },

View file

@ -26,6 +26,7 @@ export const mutations = {
articles.forEach(article => { articles.forEach(article => {
allArticles[article.id] = article; allArticles[article.id] = article;
}); });
Vue.set($state.articles, 'byId', allArticles); Vue.set($state.articles, 'byId', allArticles);
}, },
[types.ADD_MANY_ARTICLES_ID]($state, articleIds) { [types.ADD_MANY_ARTICLES_ID]($state, articleIds) {
@ -39,10 +40,13 @@ export const mutations = {
}, },
[types.ADD_ARTICLE_ID]: ($state, articleId) => { [types.ADD_ARTICLE_ID]: ($state, articleId) => {
if ($state.articles.allIds.includes(articleId)) return;
$state.articles.allIds.push(articleId); $state.articles.allIds.push(articleId);
}, },
[types.ADD_ARTICLE_FLAG]: ($state, { articleId, uiFlags }) => { [types.UPDATE_ARTICLE_FLAG]: ($state, { articleId, uiFlags }) => {
const flags = $state.articles.uiFlags.byId[articleId]; const flags =
Object.keys($state.articles.uiFlags.byId).includes(articleId) || {};
Vue.set($state.articles.uiFlags.byId, articleId, { Vue.set($state.articles.uiFlags.byId, articleId, {
...{ ...{
isFetching: false, isFetching: false,
@ -53,6 +57,16 @@ export const mutations = {
...uiFlags, ...uiFlags,
}); });
}, },
[types.ADD_ARTICLE_FLAG]: ($state, { articleId, uiFlags }) => {
Vue.set($state.articles.uiFlags.byId, articleId, {
...{
isFetching: false,
isUpdating: false,
isDeleting: false,
},
...uiFlags,
});
},
[types.UPDATE_ARTICLE]($state, article) { [types.UPDATE_ARTICLE]($state, article) {
const articleId = article.id; const articleId = article.id;
if (!$state.articles.allIds.includes(articleId)) return; if (!$state.articles.allIds.includes(articleId)) return;

View file

@ -9,6 +9,7 @@ const articleList = [
}, },
]; ];
const commit = jest.fn(); const commit = jest.fn();
const dispatch = jest.fn();
global.axios = axios; global.axios = axios;
jest.mock('axios'); jest.mock('axios');
@ -66,15 +67,17 @@ describe('#actions', () => {
describe('#create', () => { describe('#create', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: articleList[0] }); axios.post.mockResolvedValue({ data: { payload: articleList[0] } });
await actions.create({ commit }, articleList[0]); await actions.create({ commit, dispatch }, articleList[0]);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isCreating: true }], [types.default.SET_UI_FLAG, { isCreating: true }],
[types.default.ADD_ARTICLE, articleList[0]], [types.default.ADD_ARTICLE, articleList[0]],
[types.default.ADD_ARTICLE_ID, 1], [types.default.ADD_ARTICLE_ID, 1],
[types.default.ADD_ARTICLE_FLAG, 1],
[types.default.SET_UI_FLAG, { isCreating: false }], [types.default.SET_UI_FLAG, { isCreating: false }],
]); ]);
}); });
it('sends correct actions if API is error', async () => { it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' }); axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit }, articleList[0])).rejects.toThrow( await expect(actions.create({ commit }, articleList[0])).rejects.toThrow(
@ -100,12 +103,12 @@ describe('#actions', () => {
); );
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[ [
types.default.ADD_ARTICLE_FLAG, types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isUpdating: true }, articleId: 1 }, { uiFlags: { isUpdating: true }, articleId: 1 },
], ],
[types.default.UPDATE_ARTICLE, articleList[0]], [types.default.UPDATE_ARTICLE, articleList[0]],
[ [
types.default.ADD_ARTICLE_FLAG, types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isUpdating: false }, articleId: 1 }, { uiFlags: { isUpdating: false }, articleId: 1 },
], ],
]); ]);
@ -125,11 +128,11 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[ [
types.default.ADD_ARTICLE_FLAG, types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isUpdating: true }, articleId: 1 }, { uiFlags: { isUpdating: true }, articleId: 1 },
], ],
[ [
types.default.ADD_ARTICLE_FLAG, types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isUpdating: false }, articleId: 1 }, { uiFlags: { isUpdating: false }, articleId: 1 },
], ],
]); ]);
@ -142,13 +145,13 @@ describe('#actions', () => {
await actions.delete({ commit }, articleList[0].id); await actions.delete({ commit }, articleList[0].id);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[ [
types.default.ADD_ARTICLE_FLAG, types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isDeleting: true }, articleId: 1 }, { uiFlags: { isDeleting: true }, articleId: 1 },
], ],
[types.default.REMOVE_ARTICLE, articleList[0].id], [types.default.REMOVE_ARTICLE, articleList[0].id],
[types.default.REMOVE_ARTICLE_ID, articleList[0].id], [types.default.REMOVE_ARTICLE_ID, articleList[0].id],
[ [
types.default.ADD_ARTICLE_FLAG, types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isDeleting: false }, articleId: 1 }, { uiFlags: { isDeleting: false }, articleId: 1 },
], ],
]); ]);
@ -160,11 +163,11 @@ describe('#actions', () => {
).rejects.toThrow(Error); ).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[ [
types.default.ADD_ARTICLE_FLAG, types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isDeleting: true }, articleId: 1 }, { uiFlags: { isDeleting: true }, articleId: 1 },
], ],
[ [
types.default.ADD_ARTICLE_FLAG, types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isDeleting: false }, articleId: 1 }, { uiFlags: { isDeleting: false }, articleId: 1 },
], ],
]); ]);

View file

@ -49,12 +49,13 @@ export const actions = {
update: async ({ commit }, params) => { update: async ({ commit }, params) => {
const portalId = params.id; const portalId = params.id;
const portalSlug = params.slug;
commit(types.SET_HELP_PORTAL_UI_FLAG, { commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: true }, uiFlags: { isUpdating: true },
portalId, portalId,
}); });
try { try {
const { data } = await portalAPIs.update(params); const { data } = await portalAPIs.updatePortal({ portalSlug, params });
commit(types.UPDATE_PORTAL_ENTRY, data); commit(types.UPDATE_PORTAL_ENTRY, data);
} catch (error) { } catch (error) {
throwErrorMessage(error); throwErrorMessage(error);
@ -84,8 +85,10 @@ export const actions = {
}); });
} }
}, },
setPortalId: async ({ commit }, portalId) => { setPortalId: async ({ commit }, portalId) => {
commit(types.SET_SELECTED_PORTAL_ID, portalId); commit(types.SET_SELECTED_PORTAL_ID, portalId);
}, },
updatePortal: async ({ commit }, portal) => {
commit(types.UPDATE_PORTAL_ENTRY, portal);
},
}; };

View file

@ -41,7 +41,7 @@ export const mutations = {
[types.CLEAR_PORTALS]: $state => { [types.CLEAR_PORTALS]: $state => {
Vue.set($state.portals, 'byId', {}); Vue.set($state.portals, 'byId', {});
Vue.set($state.portals, 'allIds', []); Vue.set($state.portals, 'allIds', []);
Vue.set($state.portals, 'uiFlags', {}); Vue.set($state.portals, 'uiFlags.byId', {});
}, },
[types.SET_PORTALS_META]: ($state, data) => { [types.SET_PORTALS_META]: ($state, data) => {

View file

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

View file

@ -227,6 +227,7 @@ export default {
ADD_MANY_ARTICLES: 'ADD_MANY_ARTICLES', ADD_MANY_ARTICLES: 'ADD_MANY_ARTICLES',
ADD_MANY_ARTICLES_ID: 'ADD_MANY_ARTICLES_ID', ADD_MANY_ARTICLES_ID: 'ADD_MANY_ARTICLES_ID',
SET_ARTICLES_META: 'SET_ARTICLES_META', SET_ARTICLES_META: 'SET_ARTICLES_META',
UPDATE_ARTICLE_FLAG: 'UPDATE_ARTICLE_FLAG',
ADD_ARTICLE_FLAG: 'ADD_ARTICLE_FLAG', ADD_ARTICLE_FLAG: 'ADD_ARTICLE_FLAG',
UPDATE_ARTICLE: 'UPDATE_ARTICLE', UPDATE_ARTICLE: 'UPDATE_ARTICLE',
CLEAR_ARTICLES: 'CLEAR_ARTICLES', CLEAR_ARTICLES: 'CLEAR_ARTICLES',

View file

@ -5,9 +5,15 @@
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! </p> <p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! </p>
<% end %> <% end %>
<% if @resource.confirmed? %>
<p>You can login to your account through the link below:</p>
<% else %>
<p>You can confirm your account email through the link below:</p> <p>You can confirm your account email through the link below:</p>
<% end %>
<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %> <% if @resource.confirmed? %>
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
<% elsif account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p> <p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
<% else %> <% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p> <p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>

View file

@ -11,6 +11,7 @@ RSpec.describe 'Confirmation Instructions', type: :mailer do
before do before do
# to verify the token in email # to verify the token in email
confirmable_user.update!(confirmed_at: nil)
confirmable_user.send(:generate_confirmation_token) confirmable_user.send(:generate_confirmation_token)
end end
@ -61,5 +62,17 @@ RSpec.describe 'Confirmation Instructions', type: :mailer do
expect(confirmable_user.unconfirmed_email.blank?).to be false expect(confirmable_user.unconfirmed_email.blank?).to be false
end end
end end
context 'when user already confirmed' do
before do
confirmable_user.confirm
confirmable_user.account_users.last.destroy!
end
it 'send instructions with the link to login' do
confirmation_mail = Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {})
expect(confirmation_mail.body).to include('/auth/sign_in')
end
end
end end
end end