Merge branch 'develop' into feat/add-locale

This commit is contained in:
Muhsin Keloth 2022-09-01 11:17:18 +05:30 committed by GitHub
commit 91bfc60e8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 837 additions and 238 deletions

View file

@ -184,3 +184,4 @@ AllCops:
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
- db/migrate/20220809104508_revert_cascading_indexes.rb

View file

@ -158,6 +158,10 @@ group :test do
gem 'webmock'
end
group :development, :test, :staging do
gem 'faker'
end
group :development, :test do
gem 'active_record_query_trace'
##--- gems for debugging and error reporting ---##
@ -167,7 +171,6 @@ group :development, :test do
gem 'byebug', platform: :mri
gem 'climate_control'
gem 'factory_bot_rails'
gem 'faker'
gem 'listen'
gem 'mock_redis'
gem 'pry-rails'

View file

@ -41,4 +41,9 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
def seed
Seeders::AccountSeeder.new(account: requested_resource).perform!
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
end
end

View file

@ -2,6 +2,8 @@ require 'administrate/field/base'
class AvatarField < Administrate::Field::Base
def avatar_url
data.presence&.gsub('?d=404', '?d=mp')
return data.presence if data.presence
resource.is_a?(User) ? '/assets/administrate/user/avatar.png' : '/assets/administrate/bot/avatar.png'
end
end

View file

@ -42,6 +42,10 @@ class ArticlesAPI extends PortalsAPI {
category_id,
});
}
deleteArticle({ articleId, portalSlug }) {
return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`);
}
}
export default new ArticlesAPI();

View file

@ -52,4 +52,15 @@ describe('#PortalAPI', () => {
);
});
});
describeWithAPIMock('API calls', context => {
it('#deleteArticle', () => {
articlesAPI.deleteArticle({
articleId: 1,
portalSlug: 'room-rental',
});
expect(context.axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles/1'
);
});
});
});

View file

@ -242,6 +242,26 @@
"ERROR": "Error while saving article"
}
},
"ARCHIVE_ARTICLE": {
"API": {
"ERROR": "Error while archiving article",
"SUCCESS": "Article archived successfully"
}
},
"DELETE_ARTICLE": {
"MODAL": {
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure to delete the article?",
"YES": "Yes, Delete",
"NO": "No, Keep it"
}
},
"API": {
"SUCCESS_MESSAGE": "Article deleted successfully",
"ERROR_MESSAGE": "Error while deleting article"
}
},
"CREATE_ARTICLE": {
"ERROR_MESSAGE": "Please add the article heading and content then only you can update the settings"
},

View file

@ -67,7 +67,6 @@
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { required, minLength } from 'vuelidate/lib/validators';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
@ -105,11 +104,8 @@ export default {
},
},
computed: {
...mapGetters({
selectedPortal: 'portals/getSelectedPortal',
}),
selectedPortalSlug() {
return this.selectedPortal?.slug;
return this.$route.params.portalSlug;
},
nameError() {
if (this.$v.name.$error) {

View file

@ -32,7 +32,7 @@
<portal-popover
v-if="showPortalPopover"
:portals="portals"
:active-portal="selectedPortal"
:active-portal-slug="selectedPortalSlug"
@close-popover="closePortalPopover"
/>
<add-category
@ -77,18 +77,24 @@ export default {
showNotificationPanel: false,
showPortalPopover: false,
showAddCategoryModal: false,
lastActivePortalSlug: '',
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
selectedPortal: 'portals/getSelectedPortal',
portals: 'portals/allPortals',
categories: 'categories/allCategories',
meta: 'portals/getMeta',
isFetching: 'portals/isFetchingPortals',
}),
selectedPortal() {
const slug = this.$route.params.portalSlug || this.lastActivePortalSlug;
if (slug) return this.$store.getters['portals/portalBySlug'](slug);
return this.$store.getters['portals/allPortals'][0];
},
sidebarClassName() {
if (this.isOnDesktop) {
return '';
@ -111,12 +117,15 @@ export default {
return this.selectedPortal ? this.selectedPortal.name : '';
},
selectedPortalSlug() {
return this.portalSlug || this.selectedPortal?.slug;
return this.selectedPortal ? this.selectedPortal?.slug : '';
},
selectedPortalLocale() {
return this.locale || this.selectedPortal?.meta?.default_locale;
return this.selectedPortal
? this.selectedPortal?.meta?.default_locale
: '';
},
accessibleMenuItems() {
if (!this.selectedPortal) return [];
const {
meta: {
all_articles_count: allArticlesCount,
@ -192,22 +201,30 @@ export default {
];
},
currentRoute() {
return ' ';
return ' ';
},
headerTitle() {
return this.selectedPortal.name;
return this.selectedPortal ? this.selectedPortal.name : '';
},
},
mounted() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
bus.$on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
const slug = this.$route.params.portalSlug;
if (slug) this.lastActivePortalSlug = slug;
this.fetchPortalsAndItsCategories();
},
beforeDestroy() {
bus.$off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
window.removeEventListener('resize', this.handleResize);
},
updated() {
const slug = this.$route.params.portalSlug;
if (slug) this.lastActivePortalSlug = slug;
},
methods: {
handleResize() {
if (window.innerWidth > 1200) {

View file

@ -194,7 +194,7 @@ export default {
this.$emit('open-site');
},
openSettings() {
this.$emit('open');
this.navigateToPortalEdit();
},
swapLocale() {
this.$emit('swap');
@ -202,6 +202,12 @@ export default {
deleteLocale() {
this.$emit('delete');
},
navigateToPortalEdit() {
this.$router.push({
name: 'edit_portal_information',
params: { portalSlug: this.portal.slug },
});
},
},
};
</script>

View file

@ -10,7 +10,7 @@
color-scheme="secondary"
icon="settings"
size="small"
@click="openPortalPage"
@click="openPortalArticles"
>
{{ $t('HELP_CENTER.PORTAL.POPOVER.PORTAL_SETTINGS') }}
</woot-button>
@ -24,7 +24,7 @@
v-for="portal in portals"
:key="portal.id"
:portal="portal"
:active="portal.id === activePortal.id"
:active="portal.slug === activePortalSlug"
@open-portal-page="openPortalPage"
/>
</div>
@ -32,7 +32,7 @@
<woot-button variant="link" @click="closePortalPopover">
{{ $t('HELP_CENTER.PORTAL.POPOVER.CANCEL_BUTTON_LABEL') }}
</woot-button>
<woot-button>
<woot-button @click="() => {}">
{{ $t('HELP_CENTER.PORTAL.POPOVER.CHOOSE_LOCALE_BUTTON') }}
</woot-button>
</footer>
@ -52,19 +52,26 @@ export default {
type: Array,
default: () => [],
},
activePortal: {
type: Object,
default: () => ({}),
activePortalSlug: {
type: String,
default: '',
},
},
methods: {
closePortalPopover() {
this.$emit('close-popover');
},
openPortalPage() {
openPortalArticles({ slug, locale }) {
this.$emit('close-popover');
const portal = this.portals.find(p => p.slug === slug);
this.$store.dispatch('portals/setPortalId', portal.id);
this.$router.push({
name: 'list_all_portals',
name: 'list_all_locale_articles',
params: {
portalSlug: slug,
locale: locale,
},
});
},
},

View file

@ -127,7 +127,7 @@ export default {
props: {
article: {
type: Object,
required: true,
default: () => ({}),
},
},
data() {

View file

@ -27,6 +27,17 @@
v-if="showArticleSettings"
:article="article"
@save-article="saveArticle"
@delete-article="openDeletePopup"
@archive-article="archiveArticle"
/>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('HELP_CENTER.DELETE_ARTICLE.MODAL.CONFIRM.TITLE')"
:message="$t('HELP_CENTER.DELETE_ARTICLE.MODAL.CONFIRM.MESSAGE')"
:confirm-text="$t('HELP_CENTER.DELETE_ARTICLE.MODAL.CONFIRM.YES')"
:reject-text="$t('HELP_CENTER.DELETE_ARTICLE.MODAL.CONFIRM.NO')"
/>
</div>
</template>
@ -52,13 +63,13 @@ export default {
isSaved: false,
showArticleSettings: false,
alertMessage: '',
showDeleteConfirmationPopup: false,
};
},
computed: {
...mapGetters({
isFetching: 'articles/isFetching',
articles: 'articles/articles',
selectedPortal: 'portals/getSelectedPortal',
}),
article() {
return this.$store.getters['articles/articleById'](this.articleId);
@ -67,7 +78,7 @@ export default {
return this.$route.params.articleSlug;
},
selectedPortalSlug() {
return this.portalSlug || this.selectedPortal?.slug;
return this.$route.params.portalSlug;
},
},
mounted() {
@ -83,6 +94,16 @@ export default {
portalSlug: this.selectedPortalSlug,
});
},
openDeletePopup() {
this.showDeleteConfirmationPopup = true;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
confirmDeletion() {
this.closeDeletePopup();
this.deleteArticle();
},
async saveArticle({ ...values }) {
this.isUpdating = true;
try {
@ -93,8 +114,7 @@ export default {
});
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.EDIT_ARTICLE.API.ERROR_MESSAGE');
error?.message || this.$t('HELP_CENTER.EDIT_ARTICLE.API.ERROR');
this.showAlert(this.alertMessage);
} finally {
setTimeout(() => {
@ -103,6 +123,45 @@ export default {
}, 1500);
}
},
async deleteArticle() {
try {
await this.$store.dispatch('articles/delete', {
portalSlug: this.selectedPortalSlug,
articleId: this.articleId,
});
this.alertMessage = this.$t(
'HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'
);
this.$router.push({
name: 'list_all_locale_articles',
params: {
portalSlug: this.selectedPortalSlug,
locale: this.locale,
},
});
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE');
} finally {
this.showAlert(this.alertMessage);
}
},
async archiveArticle() {
try {
await this.$store.dispatch('articles/update', {
portalSlug: this.selectedPortalSlug,
articleId: this.articleId,
status: 2,
});
this.alertMessage = this.$t('HELP_CENTER.ARCHIVE_ARTICLE.API.SUCCESS');
} catch (error) {
this.alertMessage =
error?.message || this.$t('HELP_CENTER.ARCHIVE_ARTICLE.API.ERROR');
} finally {
this.showAlert(this.alertMessage);
}
},
openArticleSettings() {
this.showArticleSettings = true;
},

View file

@ -46,7 +46,6 @@ export default {
...mapGetters({
articles: 'articles/allArticles',
categories: 'categories/allCategories',
selectedPortal: 'portals/getSelectedPortal',
uiFlags: 'articles/uiFlags',
meta: 'articles/getMeta',
isFetching: 'articles/isFetching',
@ -64,7 +63,7 @@ export default {
return this.isFetching && !this.articles.length;
},
selectedPortalSlug() {
return this.selectedPortal?.slug;
return this.$route.params.portalSlug;
},
selectedCategorySlug() {
const { categorySlug } = this.$route.params;

View file

@ -46,7 +46,6 @@ export default {
},
computed: {
...mapGetters({
selectedPortal: 'portals/getSelectedPortal',
currentUserID: 'getCurrentUserID',
articles: 'articles/articles',
categories: 'categories/allCategories',
@ -58,7 +57,7 @@ export default {
return { title: this.articleTitle, content: this.articleContent };
},
selectedPortalSlug() {
return this.portalSlug || this.selectedPortal?.slug;
return this.$route.params.portalSlug;
},
categoryId() {
return this.categories.length ? this.categories[0].id : null;

View file

@ -86,7 +86,7 @@ export default {
}),
createdPortalSlug() {
const {
params: { portal_slug: slug },
params: { portalSlug: slug },
} = this.$route;
return slug;
},

View file

@ -100,7 +100,7 @@ export const actions = {
});
}
},
delete: async ({ commit }, articleId) => {
delete: async ({ commit }, { portalSlug, articleId }) => {
commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: {
isDeleting: true,
@ -108,8 +108,7 @@ export const actions = {
articleId,
});
try {
await articlesAPI.delete(articleId);
await articlesAPI.deleteArticle({ portalSlug, articleId });
commit(types.REMOVE_ARTICLE, articleId);
commit(types.REMOVE_ARTICLE_ID, articleId);
return articleId;

View file

@ -13,9 +13,11 @@ export const getters = {
},
allArticles: (...getterArguments) => {
const [state, _getters] = getterArguments;
const articles = state.articles.allIds.map(id => {
return _getters.articleById(id);
});
const articles = state.articles.allIds
.map(id => {
return _getters.articleById(id);
})
.filter(article => article !== undefined);
return articles;
},
getMeta: state => {

View file

@ -142,7 +142,11 @@ describe('#actions', () => {
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: articleList[0] });
await actions.delete({ commit }, articleList[0].id);
await actions.delete(
{ commit },
{ portalSlug: 'test', articleId: articleList[0].id }
);
expect(commit.mock.calls).toEqual([
[
types.default.UPDATE_ARTICLE_FLAG,
@ -159,7 +163,10 @@ describe('#actions', () => {
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.delete({ commit }, articleList[0].id)
actions.delete(
{ commit },
{ portalSlug: 'test', articleId: articleList[0].id }
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[

View file

@ -3,21 +3,17 @@ import { throwErrorMessage } from 'dashboard/store/utils/api';
import { types } from './mutations';
const portalAPIs = new PortalAPI();
export const actions = {
index: async ({ commit, state, dispatch }) => {
index: async ({ commit }) => {
try {
commit(types.SET_UI_FLAG, { isFetching: true });
const {
data: { payload, meta },
} = await portalAPIs.get();
commit(types.CLEAR_PORTALS);
const portalIds = payload.map(portal => portal.id);
const portalSlugs = payload.map(portal => portal.slug);
commit(types.ADD_MANY_PORTALS_ENTRY, payload);
commit(types.ADD_MANY_PORTALS_IDS, portalIds);
const { selectedPortalId } = state;
// Check if selected portal is still in the portals list
if (!portalIds.includes(selectedPortalId)) {
dispatch('setPortalId', portalIds[0]);
}
commit(types.ADD_MANY_PORTALS_IDS, portalSlugs);
commit(types.SET_PORTALS_META, meta);
} catch (error) {
throwErrorMessage(error);
@ -26,20 +22,13 @@ export const actions = {
}
},
create: async ({ commit, state, dispatch }, params) => {
create: async ({ commit }, params) => {
commit(types.SET_UI_FLAG, { isCreating: true });
try {
const { data } = await portalAPIs.create(params);
const { id: portalId } = data;
const { slug: portalSlug } = data;
commit(types.ADD_PORTAL_ENTRY, data);
commit(types.ADD_PORTAL_ID, portalId);
const {
portals: { selectedPortalId },
} = state;
// Check if there are any selected portal
if (!selectedPortalId) {
dispatch('setPortalId', portalId);
}
commit(types.ADD_PORTAL_ID, portalSlug);
} catch (error) {
throwErrorMessage(error);
} finally {
@ -47,47 +36,46 @@ export const actions = {
}
},
update: async ({ commit }, params) => {
const portalId = params.id;
const portalSlug = params.slug;
update: async ({ commit }, { portalObj }) => {
const portalSlug = portalObj.slug;
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: true },
portalId,
portalSlug,
});
try {
const { data } = await portalAPIs.updatePortal({ portalSlug, params });
const { data } = await portalAPIs.updatePortal({
portalSlug,
portalObj,
});
commit(types.UPDATE_PORTAL_ENTRY, data);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: false },
portalId,
portalSlug,
});
}
},
delete: async ({ commit }, portalId) => {
delete: async ({ commit }, { portalSlug }) => {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isDeleting: true },
portalId,
portalSlug,
});
try {
await portalAPIs.delete(portalId);
commit(types.REMOVE_PORTAL_ENTRY, portalId);
commit(types.REMOVE_PORTAL_ID, portalId);
await portalAPIs.delete(portalSlug);
commit(types.REMOVE_PORTAL_ENTRY, portalSlug);
commit(types.REMOVE_PORTAL_ID, portalSlug);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isDeleting: false },
portalId,
portalSlug,
});
}
},
setPortalId: async ({ commit }, portalId) => {
commit(types.SET_SELECTED_PORTAL_ID, portalId);
},
updatePortal: async ({ commit }, portal) => {
commit(types.UPDATE_PORTAL_ENTRY, portal);
},

View file

@ -6,18 +6,16 @@ export const getters = {
},
isFetchingPortals: state => state.uiFlags.isFetching,
portalById: (...getterArguments) => portalId => {
portalBySlug: (...getterArguments) => portalId => {
const [state] = getterArguments;
const portal = state.portals.byId[portalId];
return {
...portal,
};
return portal;
},
allPortals: (...getterArguments) => {
const [state, _getters] = getterArguments;
const portals = state.portals.allIds.map(id => {
return _getters.portalById(id);
return _getters.portalBySlug(id);
});
return portals;
},
@ -25,9 +23,4 @@ export const getters = {
getMeta: state => {
return state.meta;
},
getSelectedPortal: (...getterArguments) => {
const [state, _getters] = getterArguments;
const { selectedPortalId } = state.portals;
return _getters.portalById(selectedPortalId);
},
};

View file

@ -25,7 +25,6 @@ const state = {
meta: {
byId: {},
},
selectedPortalId: null,
},
uiFlags: {
allFetched: false,

View file

@ -9,7 +9,6 @@ export const types = {
ADD_PORTAL_ID: 'addPortalId',
CLEAR_PORTALS: 'clearPortals',
ADD_MANY_PORTALS_IDS: 'addManyPortalsIds',
SET_SELECTED_PORTAL_ID: 'setSelectedPortalId',
UPDATE_PORTAL_ENTRY: 'updatePortalEntry',
REMOVE_PORTAL_ENTRY: 'removePortalEntry',
REMOVE_PORTAL_ID: 'removePortalId',
@ -25,7 +24,7 @@ export const mutations = {
},
[types.ADD_PORTAL_ENTRY]($state, portal) {
Vue.set($state.portals.byId, portal.id, {
Vue.set($state.portals.byId, portal.slug, {
...portal,
});
},
@ -33,7 +32,7 @@ export const mutations = {
[types.ADD_MANY_PORTALS_ENTRY]($state, portals) {
const allPortals = { ...$state.portals.byId };
portals.forEach(portal => {
allPortals[portal.id] = portal;
allPortals[portal.slug] = portal;
});
Vue.set($state.portals, 'byId', allPortals);
},
@ -41,7 +40,7 @@ export const mutations = {
[types.CLEAR_PORTALS]: $state => {
Vue.set($state.portals, 'byId', {});
Vue.set($state.portals, 'allIds', []);
Vue.set($state.portals, 'uiFlags.byId', {});
Vue.set($state.portals.uiFlags, 'byId', {});
},
[types.SET_PORTALS_META]: ($state, data) => {
@ -50,41 +49,39 @@ export const mutations = {
Vue.set($state.meta, 'currentPage', currentPage);
},
[types.SET_SELECTED_PORTAL_ID]: ($state, portalId) => {
Vue.set($state.portals, 'selectedPortalId', portalId);
[types.ADD_PORTAL_ID]($state, portalSlug) {
$state.portals.allIds.push(portalSlug);
},
[types.ADD_PORTAL_ID]($state, portalId) {
$state.portals.allIds.push(portalId);
},
[types.ADD_MANY_PORTALS_IDS]($state, portalIds) {
$state.portals.allIds.push(...portalIds);
[types.ADD_MANY_PORTALS_IDS]($state, portalSlugs) {
$state.portals.allIds.push(...portalSlugs);
},
[types.UPDATE_PORTAL_ENTRY]($state, portal) {
const portalId = portal.id;
if (!$state.portals.allIds.includes(portalId)) return;
const portalSlug = portal.slug;
if (!$state.portals.allIds.includes(portalSlug)) return;
Vue.set($state.portals.byId, portalId, {
Vue.set($state.portals.byId, portalSlug, {
...portal,
});
},
[types.REMOVE_PORTAL_ENTRY]($state, portalId) {
if (!portalId) return;
[types.REMOVE_PORTAL_ENTRY]($state, portalSlug) {
if (!portalSlug) return;
const { [portalId]: toBeRemoved, ...newById } = $state.portals.byId;
const { [portalSlug]: toBeRemoved, ...newById } = $state.portals.byId;
Vue.set($state.portals, 'byId', newById);
},
[types.REMOVE_PORTAL_ID]($state, portalId) {
$state.portals.allIds = $state.portals.allIds.filter(id => id !== portalId);
[types.REMOVE_PORTAL_ID]($state, portalSlug) {
$state.portals.allIds = $state.portals.allIds.filter(
slug => slug !== portalSlug
);
},
[types.SET_HELP_PORTAL_UI_FLAG]($state, { portalId, uiFlags }) {
const flags = $state.portals.uiFlags.byId[portalId];
Vue.set($state.portals.uiFlags.byId, portalId, {
[types.SET_HELP_PORTAL_UI_FLAG]($state, { portalSlug, uiFlags }) {
const flags = $state.portals.uiFlags.byId[portalSlug];
Vue.set($state.portals.uiFlags.byId, portalSlug, {
...defaultPortalFlags,
...flags,
...uiFlags,

View file

@ -15,16 +15,13 @@ describe('#actions', () => {
await actions.index({
commit,
dispatch,
state: {
selectedPortalId: 4,
},
state: {},
});
expect(dispatch.mock.calls).toMatchObject([['setPortalId', 1]]);
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isFetching: true }],
[types.CLEAR_PORTALS],
[types.ADD_MANY_PORTALS_ENTRY, apiResponse.payload],
[types.ADD_MANY_PORTALS_IDS, [1, 2]],
[types.ADD_MANY_PORTALS_IDS, ['domain', 'campaign']],
[types.SET_PORTALS_META, { current_page: 1, portals_count: 1 }],
[types.SET_UI_FLAG, { isFetching: false }],
]);
@ -43,7 +40,7 @@ describe('#actions', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: apiResponse.payload[1] });
await actions.create(
{ commit, dispatch, state: { portals: { selectedPortalId: null } } },
{ commit, dispatch, state: { portals: {} } },
{
color: 'red',
custom_domain: 'domain_for_help',
@ -53,17 +50,14 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isCreating: true }],
[types.ADD_PORTAL_ENTRY, apiResponse.payload[1]],
[types.ADD_PORTAL_ID, 2],
[types.ADD_PORTAL_ID, 'campaign'],
[types.SET_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.create(
{ commit, dispatch, state: { portals: { selectedPortalId: null } } },
{}
)
actions.create({ commit, dispatch, state: { portals: {} } }, {})
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isCreating: true }],
@ -75,32 +69,32 @@ describe('#actions', () => {
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: apiResponse.payload[1] });
await actions.update({ commit }, apiResponse.payload[1]);
await actions.update({ commit }, { portalObj: apiResponse.payload[1] });
expect(commit.mock.calls).toEqual([
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isUpdating: true }, portalId: 2 },
{ uiFlags: { isUpdating: true }, portalSlug: 'campaign' },
],
[types.UPDATE_PORTAL_ENTRY, apiResponse.payload[1]],
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isUpdating: false }, portalId: 2 },
{ uiFlags: { isUpdating: false }, portalSlug: 'campaign' },
],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.update({ commit }, apiResponse.payload[1])
actions.update({ commit }, { portalObj: apiResponse.payload[1] })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isUpdating: true }, portalId: 2 },
{ uiFlags: { isUpdating: true }, portalSlug: 'campaign' },
],
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isUpdating: false }, portalId: 2 },
{ uiFlags: { isUpdating: false }, portalSlug: 'campaign' },
],
]);
});
@ -109,40 +103,35 @@ describe('#actions', () => {
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.delete({ commit }, 2);
await actions.delete({ commit }, { portalSlug: 'campaign' });
expect(commit.mock.calls).toEqual([
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isDeleting: true }, portalId: 2 },
{ uiFlags: { isDeleting: true }, portalSlug: 'campaign' },
],
[types.REMOVE_PORTAL_ENTRY, 2],
[types.REMOVE_PORTAL_ID, 2],
[types.REMOVE_PORTAL_ENTRY, 'campaign'],
[types.REMOVE_PORTAL_ID, 'campaign'],
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isDeleting: false }, portalId: 2 },
{ uiFlags: { isDeleting: false }, portalSlug: 'campaign' },
],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.delete({ commit }, 2)).rejects.toThrow(Error);
await expect(
actions.delete({ commit }, { portalSlug: 'campaign' })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isDeleting: true }, portalId: 2 },
{ uiFlags: { isDeleting: true }, portalSlug: 'campaign' },
],
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isDeleting: false }, portalId: 2 },
{ uiFlags: { isDeleting: false }, portalSlug: 'campaign' },
],
]);
});
});
describe('#setPortalId', () => {
it('sends correct actions', async () => {
axios.delete.mockResolvedValue({});
await actions.setPortalId({ commit }, 1);
expect(commit.mock.calls).toEqual([[types.SET_SELECTED_PORTAL_ID, 1]]);
});
});
});

View file

@ -40,7 +40,6 @@ export default {
1: { isFetching: false, isUpdating: true, isDeleting: false },
},
},
selectedPortalId: 1,
},
uiFlags: {
allFetched: false,

View file

@ -16,9 +16,9 @@ describe('#getters', () => {
expect(getters.isFetchingPortals(state)).toEqual(true);
});
it('portalById', () => {
it('portalBySlug', () => {
const state = portal;
expect(getters.portalById(state)(1)).toEqual({
expect(getters.portalBySlug(state)(1)).toEqual({
id: 1,
color: 'red',
custom_domain: 'domain_for_help',

View file

@ -31,13 +31,13 @@ describe('#mutations', () => {
expect(state).toEqual(portal);
});
it('does adds helpcenter object to state', () => {
mutations[types.ADD_PORTAL_ENTRY](state, { id: 3 });
expect(state.portals.byId[3]).toEqual({ id: 3 });
mutations[types.ADD_PORTAL_ENTRY](state, { slug: 'new' });
expect(state.portals.byId.new).toEqual({ slug: 'new' });
});
});
describe('[types.ADD_PORTAL_ID]', () => {
it('adds helpcenter id to state', () => {
it('adds helpcenter slug to state', () => {
mutations[types.ADD_PORTAL_ID](state, 12);
expect(state.portals.allIds).toEqual([1, 2, 12]);
});
@ -48,13 +48,13 @@ describe('#mutations', () => {
mutations[types.UPDATE_PORTAL_ENTRY](state, {});
expect(state).toEqual(portal);
});
it('does not updates if object id is not present ', () => {
mutations[types.UPDATE_PORTAL_ENTRY](state, { id: 5 });
it('does not updates if object slug is not present ', () => {
mutations[types.UPDATE_PORTAL_ENTRY](state, { slug: 5 });
expect(state).toEqual(portal);
});
it(' updates if object with id already present in the state', () => {
it(' updates if object with slug already present in the state', () => {
mutations[types.UPDATE_PORTAL_ENTRY](state, {
id: 2,
slug: 2,
name: 'Updated name',
});
expect(state.portals.byId[2].name).toEqual('Updated name');
@ -62,7 +62,7 @@ describe('#mutations', () => {
});
describe('[types.REMOVE_PORTAL_ENTRY]', () => {
it('does not remove object entry if no id is passed', () => {
it('does not remove object entry if no slug is passed', () => {
mutations[types.REMOVE_PORTAL_ENTRY](state, undefined);
expect(state).toEqual({ ...portal });
});
@ -73,7 +73,7 @@ describe('#mutations', () => {
});
describe('[types.REMOVE_PORTAL_ID]', () => {
it('removes id from state', () => {
it('removes slug from state', () => {
mutations[types.REMOVE_PORTAL_ID](state, 2);
expect(state.portals.allIds).toEqual([1, 12]);
});
@ -82,12 +82,12 @@ describe('#mutations', () => {
describe('[types.SET_HELP_PORTAL_UI_FLAG]', () => {
it('sets correct flag in state', () => {
mutations[types.SET_HELP_PORTAL_UI_FLAG](state, {
portalId: 1,
portalSlug: 'domain',
uiFlags: { isFetching: true },
});
expect(state.portals.uiFlags.byId[1]).toEqual({
expect(state.portals.uiFlags.byId.domain).toEqual({
isFetching: true,
isUpdating: true,
isUpdating: false,
isDeleting: false,
});
});
@ -99,9 +99,7 @@ describe('#mutations', () => {
expect(state.portals.allIds).toEqual([]);
expect(state.portals.byId).toEqual({});
expect(state.portals.uiFlags).toEqual({
byId: {
'1': { isFetching: true, isUpdating: true, isDeleting: false },
},
byId: {},
});
});
});
@ -118,11 +116,4 @@ describe('#mutations', () => {
});
});
});
describe('#SET_SELECTED_PORTAL_ID', () => {
it('set selected portal id', () => {
mutations[types.SET_SELECTED_PORTAL_ID](state, 4);
expect(state.portals.selectedPortalId).toEqual(4);
});
});
});

View file

@ -0,0 +1,14 @@
<% if !Rails.env.production? %>
<section class="main-content__body">
<hr/>
<%= form_for([:seed, namespace, page.resource], method: :post, html: { class: "form" }) do |f| %>
<div class="form-actions">
<div><p> Click the button to generate seed data into this account for demos.</p>
<p class="text-color-red">Note: This will clear all the existing data in this account.</p>
</div>
<%= f.submit 'Generate Seed Data' %>
</div>
<% end %>
</section>
<% end %>

View file

@ -85,3 +85,5 @@ as well as a link to its edit page.
<% end %>
</section>
<%= render partial: "seed_data", locals: {page: page} %>

View file

@ -340,7 +340,9 @@ Rails.application.routes.draw do
resource :app_config, only: [:show, :create]
# order of resources affect the order of sidebar navigation in super admin
resources :accounts
resources :accounts, only: [:index, :new, :create, :show, :edit, :update] do
post :seed, on: :member
end
resources :users, only: [:index, :new, :create, :show, :edit, :update]
resources :access_tokens, only: [:index, :show]
resources :installation_configs, only: [:index, :new, :create, :show, :edit, :update]

View file

@ -11,6 +11,12 @@ end
## Seeds for Local Development
unless Rails.env.production?
# Enables creating additional accounts from dashboard
installation_config = InstallationConfig.find_by(name: 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD')
installation_config.value = true
installation_config.save!
GlobalConfig.clear_cache
account = Account.create!(
name: 'Acme Inc'
)
@ -35,12 +41,6 @@ unless Rails.env.production?
role: :administrator
)
# Enables creating additional accounts from dashboard
installation_config = InstallationConfig.find_by(name: 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD')
installation_config.value = true
installation_config.save!
GlobalConfig.clear_cache
web_widget = Channel::WebWidget.create!(account: account, website_url: 'https://acme.inc')
inbox = Inbox.create!(channel: web_widget, account: account, name: 'Acme Support')

View file

@ -1,89 +1,105 @@
## Class to generate sample data for a chatwoot test Account.
## Class to generate sample data for a chatwoot test @Account.
############################################################
### Usage #####
#
# # Seed an account with all data types in this class
# Seeders::AccountSeeder.new(account: account).seed!
# Seeders::AccountSeeder.new(account: @Account.find(1)).perform!
#
# # When you want to seed only a specific type of data
# Seeders::AccountSeeder.new(account: account).seed_canned_responses
# # Seed specific number of objects
# Seeders::AccountSeeder.new(account: account).seed_canned_responses(count: 10)
#
############################################################
class Seeders::AccountSeeder
pattr_initialize [:account!]
def initialize(account:)
raise 'Account Seeding is not allowed in production.' if Rails.env.production?
def seed!
@account_data = HashWithIndifferentAccess.new(YAML.safe_load(File.read(Rails.root.join('lib/seeders/seed_data.yml'))))
@account = account
end
def perform!
set_up_account
seed_teams
set_up_users
seed_labels
seed_canned_responses
seed_inboxes
seed_contacts
end
def set_up_account
@account.teams.destroy_all
@account.conversations.destroy_all
@account.labels.destroy_all
@account.inboxes.destroy_all
@account.contacts.destroy_all
end
def seed_teams
@account_data['teams'].each do |team_name|
@account.teams.create!(name: team_name)
end
end
def seed_labels
@account_data['labels'].each do |label|
@account.labels.create!(label)
end
end
def set_up_users
@account_data['users'].each do |user|
user_record = User.create_with(name: user['name'], password: 'Password1!.').find_or_create_by!(email: (user['email']).to_s)
user_record.skip_confirmation!
user_record.save!
Avatar::AvatarFromUrlJob.perform_later(user_record, "https://xsgames.co/randomusers/avatar.php?g=#{user['gender']}")
AccountUser.create_with(role: (user['role'] || 'agent')).find_or_create_by!(account_id: @account.id, user_id: user_record.id)
next if user['team'].blank?
add_user_to_teams(user: user_record, teams: user['team'])
end
end
def add_user_to_teams(user:, teams:)
teams.each do |team|
team_record = @account.teams.where('name LIKE ?', "%#{team.downcase}%").first if team.present?
TeamMember.find_or_create_by!(team_id: team_record.id, user_id: user.id) unless team_record.nil?
end
end
def seed_canned_responses(count: 50)
count.times do
account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
@account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
end
end
def seed_contacts
@account_data['contacts'].each do |contact_data|
contact = @account.contacts.create!(contact_data.slice('name', 'email'))
Avatar::AvatarFromUrlJob.perform_later(contact, "https://xsgames.co/randomusers/avatar.php?g=#{contact_data['gender']}")
contact_data['conversations'].each do |conversation_data|
inbox = @account.inboxes.find_by(channel_type: conversation_data['channel'])
contact_inbox = inbox.contact_inboxes.create!(contact: contact, source_id: (conversation_data['source_id'] || SecureRandom.hex))
create_conversation(contact_inbox: contact_inbox, conversation_data: conversation_data)
end
end
end
def create_conversation(contact_inbox:, conversation_data:)
assignee = User.find_by(email: conversation_data['assignee']) if conversation_data['assignee'].present?
conversation = contact_inbox.conversations.create!(account: contact_inbox.inbox.account, contact: contact_inbox.contact,
inbox: contact_inbox.inbox, assignee: assignee)
create_messages(conversation: conversation, messages: conversation_data['messages'])
end
def create_messages(conversation:, messages:)
messages.each do |message_data|
sender = User.find_by(email: message_data['sender']) if message_data['sender'].present?
conversation.messages.create!(message_data.slice('content', 'message_type').merge(account: conversation.inbox.account, sender: sender,
inbox: conversation.inbox))
end
end
def seed_inboxes
seed_website_inbox
seed_facebook_inbox
seed_twitter_inbox
seed_whatsapp_inbox
seed_sms_inbox
seed_email_inbox
seed_api_inbox
seed_telegram_inbox
seed_line_inbox
end
def seed_website_inbox
channel = Channel::WebWidget.create!(account: account, website_url: 'https://acme.inc')
Inbox.create!(channel: channel, account: account, name: 'Acme Website')
end
def seed_facebook_inbox
channel = Channel::FacebookPage.create!(account: account, user_access_token: 'test', page_access_token: 'test', page_id: 'test')
Inbox.create!(channel: channel, account: account, name: 'Acme Facebook')
end
def seed_twitter_inbox
channel = Channel::TwitterProfile.create!(account: account, twitter_access_token: 'test', twitter_access_token_secret: 'test', profile_id: '123')
Inbox.create!(channel: channel, account: account, name: 'Acme Twitter')
end
def seed_whatsapp_inbox
channel = Channel::Whatsapp.create!(account: account, phone_number: '+123456789')
Inbox.create!(channel: channel, account: account, name: 'Acme Whatsapp')
end
def seed_sms_inbox
channel = Channel::Sms.create!(account: account, phone_number: '+123456789')
Inbox.create!(channel: channel, account: account, name: 'Acme SMS')
end
def seed_email_inbox
channel = Channel::Email.create!(account: account, email: 'test@acme.inc', forward_to_email: 'test_fwd@acme.inc')
Inbox.create!(channel: channel, account: account, name: 'Acme Email')
end
def seed_api_inbox
channel = Channel::Api.create!(account: account)
Inbox.create!(channel: channel, account: account, name: 'Acme API')
end
def seed_telegram_inbox
# rubocop:disable Rails/SkipsModelValidations
Channel::Telegram.insert({ account_id: account.id, bot_name: 'Acme', bot_token: 'test', created_at: Time.now.utc, updated_at: Time.now.utc },
returning: %w[id])
channel = Channel::Telegram.find_by(bot_token: 'test')
Inbox.create!(channel: channel, account: account, name: 'Acme Telegram')
# rubocop:enable Rails/SkipsModelValidations
end
def seed_line_inbox
channel = Channel::Line.create!(account: account, line_channel_id: 'test', line_channel_secret: 'test', line_channel_token: 'test')
Inbox.create!(channel: channel, account: account, name: 'Acme Line')
Seeders::InboxSeeder.new(account: @account, company_data: @account_data[:company]).perform!
end
end

105
lib/seeders/inbox_seeder.rb Normal file
View file

@ -0,0 +1,105 @@
## Class to generate sample inboxes for a chatwoot test @Account.
############################################################
### Usage #####
#
# # Seed an account with all data types in this class
# Seeders::InboxSeeder.new(account: @Account.find(1), company_data: {name: 'PaperLayer', doamin: 'paperlayer.test'}).perform!
#
#
############################################################
class Seeders::InboxSeeder
def initialize(account:, company_data:)
raise 'Inbox Seeding is not allowed in production.' if Rails.env.production?
@account = account
@company_data = company_data
end
def perform!
seed_website_inbox
seed_facebook_inbox
seed_twitter_inbox
seed_whatsapp_inbox
seed_sms_inbox
seed_email_inbox
seed_api_inbox
seed_telegram_inbox
seed_line_inbox
end
def seed_website_inbox
channel = Channel::WebWidget.create!(account: @account, website_url: "https://#{@company_data['domain']}")
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Website")
end
def seed_facebook_inbox
channel = Channel::FacebookPage.create!(account: @account, user_access_token: SecureRandom.hex, page_access_token: SecureRandom.hex,
page_id: SecureRandom.hex)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Facebook")
end
def seed_twitter_inbox
channel = Channel::TwitterProfile.create!(account: @account, twitter_access_token: SecureRandom.hex,
twitter_access_token_secret: SecureRandom.hex, profile_id: '123')
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Twitter")
end
def seed_whatsapp_inbox
# rubocop:disable Rails/SkipsModelValidations
Channel::Whatsapp.insert(
{
account_id: @account.id,
phone_number: Faker::PhoneNumber.cell_phone_in_e164,
created_at: Time.now.utc,
updated_at: Time.now.utc
},
returning: %w[id]
)
# rubocop:enable Rails/SkipsModelValidations
channel = Channel::Whatsapp.find_by(account_id: @account.id)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Whatsapp")
end
def seed_sms_inbox
channel = Channel::Sms.create!(account: @account, phone_number: Faker::PhoneNumber.cell_phone_in_e164)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Mobile")
end
def seed_email_inbox
channel = Channel::Email.create!(account: @account, email: "test#{SecureRandom.hex}@#{@company_data['domain']}",
forward_to_email: "test_fwd#{SecureRandom.hex}@#{@company_data['domain']}")
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Email")
end
def seed_api_inbox
channel = Channel::Api.create!(account: @account)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} API")
end
def seed_telegram_inbox
# rubocop:disable Rails/SkipsModelValidations
bot_token = SecureRandom.hex
Channel::Telegram.insert(
{
account_id: @account.id,
bot_name: (@company_data['name']).to_s,
bot_token: bot_token,
created_at: Time.now.utc,
updated_at: Time.now.utc
},
returning: %w[id]
)
channel = Channel::Telegram.find_by(bot_token: bot_token)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Telegram")
# rubocop:enable Rails/SkipsModelValidations
end
def seed_line_inbox
channel = Channel::Line.create!(account: @account, line_channel_id: SecureRandom.hex, line_channel_secret: SecureRandom.hex,
line_channel_token: SecureRandom.hex)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Line")
end
end

367
lib/seeders/seed_data.yml Normal file
View file

@ -0,0 +1,367 @@
company:
name: 'PaperLayer'
domain: 'paperlayer.test'
users:
- name: 'Michael Scott'
gender: male
email: 'michale@paperlayer.test'
team:
- 'sales'
- 'management'
- 'administration'
- 'warehouse'
role: 'administrator'
- name: 'David Wallace'
gender: male
email: 'david@paperlayer.test'
team:
- 'Management'
- name: 'Deangelo Vickers'
gender: male
email: 'deangelo@paperlayer.test'
team:
- 'Management'
- name: 'Jo Bennett'
gender: female
email: 'jo@paperlayer.test'
team:
- 'Management'
- name: 'Josh Porter'
gender: male
email: 'josh@paperlayer.test'
team:
- 'Management'
- name: 'Charles Miner'
gender: male
email: 'charles@paperlayer.test'
team:
- 'Management'
- name: 'Ed Truck'
gender: male
email: 'ed@paperlayer.test'
team:
- 'Management'
- name: 'Dan Gore'
gender: male
email: 'dan@paperlayer.test'
team:
- 'Management'
- name: 'Craig D'
gender: male
email: 'craig@paperlayer.test'
team:
- 'Management'
- name: 'Troy Underbridge'
gender: male
email: 'troy@paperlayer.test'
team:
- 'Management'
- name: 'Karen Filippelli'
gender: female
email: 'karn@paperlayer.test'
team:
- 'Sales'
- name: 'Danny Cordray'
gender: female
email: 'danny@paperlayer.test'
team:
- 'Sales'
- name: 'Ben Nugent'
gender: male
email: 'ben@paperlayer.test'
team:
- 'Sales'
- name: 'Todd Packer'
gender: male
email: 'todd@paperlayer.test'
team:
- 'Sales'
- name: 'Cathy Simms'
gender: female
email: 'cathy@paperlayer.test'
team:
- 'Administration'
- name: 'Hunter Jo'
gender: male
email: 'hunter@paperlayer.test'
team:
- 'Administration'
- name: 'Rolando Silva'
gender: male
email: 'rolando@paperlayer.test'
team:
- 'Administration'
- name: 'Stephanie Wilson'
gender: female
email: 'stephanie@paperlayer.test'
team:
- 'Administration'
- name: 'Jordan Garfield'
gender: male
email: 'jorodan@paperlayer.test'
team:
- 'Administration'
- name: 'Ronni Carlo'
gender: male
email: 'ronni@paperlayer.test'
team:
- 'Administration'
- name: 'Lonny Collins'
gender: female
email: 'lonny@paperlayer.test'
team:
- 'Warehouse'
- name: 'Madge Madsen'
gender: female
email: 'madge@paperlayer.test'
team:
- 'Warehouse'
- name: 'Glenn Max'
gender: female
email: 'glenn@paperlayer.test'
team:
- 'Warehouse'
- name: 'Jerry DiCanio'
gender: male
email: 'jerry@paperlayer.test'
team:
- 'Warehouse'
- name: 'Phillip Martin'
gender: male
email: 'phillip@paperlayer.test'
team:
- 'Warehouse'
- name: 'Michael Josh'
gender: male
email: 'michale_josh@paperlayer.test'
team:
- 'Warehouse'
- name: 'Matt Hudson'
gender: male
email: 'matt@paperlayer.test'
team:
- 'Warehouse'
- name: 'Gideon'
gender: male
email: 'gideon@paperlayer.test'
team:
- 'Warehouse'
- name: 'Bruce'
gender: male
email: 'bruce@paperlayer.test'
team:
- 'Warehouse'
- name: 'Frank'
gender: male
email: 'frank@paperlayer.test'
team:
- 'Warehouse'
- name: 'Louanne Kelley'
gender: female
email: 'louanne@paperlayer.test'
- name: 'Devon White'
gender: male
email: 'devon@paperlayer.test'
- name: 'Kendall'
gender: male
email: 'kendall@paperlayer.test'
- email: 'sadiq@paperlayer.test'
name: 'Sadiq'
gender: male
teams:
- '💰 Sales'
- '💼 Management'
- '👩‍💼 Administration'
- '🚛 Warehouse'
labels:
- title: 'billing'
color: '#28AD21'
show_on_sidebar: true
- title: 'software'
color: '#8F6EF2'
show_on_sidebar: true
- title: 'delivery'
color: '#A2FDD5'
show_on_sidebar: true
- title: 'ops-handover'
color: '#A53326'
show_on_sidebar: true
- title: 'premium-customer'
color: '#6FD4EF'
show_on_sidebar: true
- title: 'lead'
color: '#F161C8'
show_on_sidebar: true
contacts:
- name: "Lorrie Trosdall"
email: "ltrosdall0@bravesites.test"
gender: 'female'
conversations:
- channel: Channel::WebWidget
messages:
- message_type: incoming
content: hello world
- name: "Tiffanie Cloughton"
email: "tcloughton1@newyorker.test"
gender: 'female'
conversations:
- channel: Channel::FacebookPage
messages:
- message_type: incoming
content: hello world
- name: "Melonie Keatch"
email: "mkeatch2@reuters.test"
gender: 'female'
conversations:
- channel: Channel::TwitterProfile
messages:
- message_type: incoming
content: hello world
- name: "Olin Canniffe"
email: "ocanniffe3@feedburner.test"
gender: 'male'
conversations:
- channel: Channel::Whatsapp
messages:
- message_type: incoming
content: hello world
- name: "Viviene Corp"
email: "vcorp4@instagram.test"
gender: 'female'
conversations:
- channel: Channel::Sms
source_id: "+1234567"
messages:
- message_type: incoming
content: hello world
- name: "Drake Pittway"
email: "dpittway5@chron.test"
gender: 'male'
conversations:
- channel: Channel::Line
messages:
- message_type: incoming
content: hello world
- name: "Klaus Crawley"
email: "kcrawley6@narod.ru"
gender: 'male'
conversations:
- channel: Channel::WebWidget
messages:
- message_type: incoming
content: hello world
- name: "Bing Cusworth"
email: "bcusworth7@arstechnica.test"
gender: 'male'
conversations:
- channel: Channel::TwitterProfile
messages:
- message_type: incoming
content: hello world
- name: "Claus Jira"
email: "cjira8@comcast.net"
gender: 'male'
conversations:
- channel: Channel::Whatsapp
messages:
- message_type: incoming
content: hello world
- name: "Quent Dalliston"
email: "qdalliston9@zimbio.test"
gender: 'male'
conversations:
- channel: Channel::Whatsapp
messages:
- message_type: incoming
content: hello world
- name: "Coreen Mewett"
email: "cmewetta@home.pl"
gender: 'female'
conversations:
- channel: Channel::FacebookPage
messages:
- message_type: incoming
content: hello world
- name: "Benyamin Janeway"
email: "bjanewayb@ustream.tv"
gender: 'male'
conversations:
- channel: Channel::Line
messages:
- message_type: incoming
content: hello world
- name: "Cordell Dalinder"
email: "cdalinderc@msn.test"
gender: 'male'
conversations:
- channel: Channel::Email
source_id: "cdalinderc@msn.test"
messages:
- message_type: incoming
content: hello world
- name: "Merrile Petruk"
email: "mpetrukd@wunderground.test"
gender: 'female'
conversations:
- channel: Channel::Email
source_id: "mpetrukd@wunderground.test"
messages:
- message_type: incoming
content: hello world
- name: "Nathaniel Vannuchi"
email: "nvannuchie@photobucket.test"
gender: 'male'
conversations:
- channel: Channel::FacebookPage
messages:
- message_type: incoming
content: "Hey there,I need some help with billing, my card is not working on the website."
- name: "Olia Olenchenko"
email: "oolenchenkof@bluehost.test"
gender: 'female'
conversations:
- channel: Channel::WebWidget
assignee: michael_scott@paperlayer.test
messages:
- message_type: incoming
content: "Billing section is not working, it throws some error."
- name: "Elisabeth Derington"
email: "ederingtong@printfriendly.test"
gender: 'female'
conversations:
- channel: Channel::Whatsapp
messages:
- message_type: incoming
content: "Hey \n I didn't get the product delivered, but it shows it is delivered to my address. Please check"
- name: "Willy Castelot"
email: "wcasteloth@exblog.jp"
gender: 'male'
conversations:
- channel: Channel::WebWidget
messages:
- message_type: incoming
content: "Hey there, \n I need some help with the product, my button is not working on the website."
- name: "Ophelia Folkard"
email: "ofolkardi@taobao.test"
gender: 'female'
conversations:
- channel: Channel::WebWidget
assignee: michael_scott@paperlayer.test
messages:
- message_type: incoming
content: "Hey, \n My card is not working on your website. Please help"
- name: "Candice Matherson"
email: "cmathersonj@va.gov"
gender: 'female'
conversations:
- channel: Channel::Email
source_id: "cmathersonj@va.gov"
assignee: michael_scott@paperlayer.test
messages:
- message_type: incoming
content: "Hey, \n I'm looking for some help to figure out if it is the right product for me."
- message_type: outgoing
content: Welcome to PaperLayer. Our Team will be getting back you shortly.
- message_type: outgoing
content: How may i help you ?
sender: michael_scott@paperlayer.test

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB