feat: Articles store integration (#5133)

This commit is contained in:
Muhsin Keloth 2022-08-02 17:14:10 +05:30 committed by GitHub
parent 82207c0d3e
commit 5735a8e377
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 272 additions and 82 deletions

View file

@ -5,6 +5,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
def index def index
@articles_count = @portal.articles.count
@articles = @portal.articles @articles = @portal.articles
@articles = @articles.search(list_params) if list_params.present? @articles = @articles.search(list_params) if list_params.present?
end end

View file

@ -1,9 +1,16 @@
/* 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 });
} }
getArticles({ pageNumber, portalSlug, locale }) {
return axios.get(
`${this.url}/${portalSlug}/articles?page=${pageNumber}&locale=${locale}`
);
}
} }
export default new PortalsAPI(); export default new PortalsAPI();

View file

@ -4,7 +4,7 @@
<div class="row--article-block"> <div class="row--article-block">
<div class="article-block"> <div class="article-block">
<h6 class="sub-block-title text-truncate"> <h6 class="sub-block-title text-truncate">
<router-link class="article-name" :to="articlePath"> <router-link class="article-name" :to="articleUrl(id)">
{{ title }} {{ title }}
</router-link> </router-link>
</h6> </h6>
@ -24,16 +24,20 @@
</tr> </tr>
</template> </template>
<script> <script>
import { frontendURL } from 'dashboard/helper/URLHelper';
import Label from 'dashboard/components/ui/Label'; import Label from 'dashboard/components/ui/Label';
import timeMixin from 'dashboard/mixins/time'; import timeMixin from 'dashboard/mixins/time';
import portalMixin from '../mixins/portalMixin';
export default { export default {
components: { components: {
Label, Label,
}, },
mixins: [timeMixin], mixins: [timeMixin, portalMixin],
props: { props: {
id: {
type: Number,
required: true,
},
title: { title: {
type: String, type: String,
default: '', default: '',
@ -79,9 +83,6 @@ export default {
return 'success'; return 'success';
} }
}, },
articlePath() {
return frontendURL(`accounts/${this.accountId}/hc/articles/${this.id}`);
},
}, },
}; };
</script> </script>

View file

@ -16,20 +16,22 @@
<tbody> <tbody>
<ArticleItem <ArticleItem
v-for="article in articles" v-for="article in articles"
:id="article.id"
:key="article.id" :key="article.id"
:title="article.title" :title="article.title"
:author="article.author" :author="article.author"
:category="article.category" :category="article.category"
:read-count="article.readCount" :read-count="article.readCount"
:status="article.status" :status="article.status"
:updated-at="article.updatedAt" :updated-at="article.updated_at"
/> />
</tbody> </tbody>
</table> </table>
<table-footer <table-footer
:on-page-change="onPageChange" :on-page-change="onPageChange"
:current-page="Number(currentPage)" :current-page="Number(currentPage)"
:total-count="articleCount" :total-count="totalCount"
:page-size="pageSize"
/> />
</div> </div>
</template> </template>
@ -47,7 +49,7 @@ export default {
type: Array, type: Array,
default: () => {}, default: () => {},
}, },
articleCount: { totalCount: {
type: Number, type: Number,
default: 0, default: 0,
}, },
@ -55,10 +57,14 @@ export default {
type: Number, type: Number,
default: 1, default: 1,
}, },
pageSize: {
type: Number,
default: 15,
},
}, },
methods: { methods: {
onPageChange() { onPageChange(page) {
this.$emit('onPageChange'); this.$emit('on-page-change', page);
}, },
}, },
}; };

View file

@ -66,6 +66,12 @@ export default {
...mapGetters({ ...mapGetters({
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
}), }),
portalSlug() {
return this.$route.params.portalSlug;
},
locale() {
return this.$route.params.locale;
},
accessibleMenuItems() { accessibleMenuItems() {
return [ return [
{ {
@ -74,7 +80,7 @@ export default {
key: 'list_all_locale_articles', key: 'list_all_locale_articles',
count: 199, count: 199,
toState: frontendURL( toState: frontendURL(
`accounts/${this.accountId}/portals/:portalSlug/:locale/articles` `accounts/${this.accountId}/portals/${this.portalSlug}/${this.locale}/articles`
), ),
toolTip: 'All Articles', toolTip: 'All Articles',
toStateName: 'list_all_locale_articles', toStateName: 'list_all_locale_articles',
@ -85,7 +91,7 @@ export default {
key: 'mine_articles', key: 'mine_articles',
count: 112, count: 112,
toState: frontendURL( toState: frontendURL(
`accounts/${this.accountId}/portals/:portalSlug/:locale/articles/mine` `accounts/${this.accountId}/portals/${this.portalSlug}/${this.locale}/articles/mine`
), ),
toolTip: 'My articles', toolTip: 'My articles',
toStateName: 'mine_articles', toStateName: 'mine_articles',
@ -96,7 +102,7 @@ export default {
key: 'list_draft_articles', key: 'list_draft_articles',
count: 32, count: 32,
toState: frontendURL( toState: frontendURL(
`accounts/${this.accountId}/portals/:portalSlug/:locale/articles/draft` `accounts/${this.accountId}/portals/${this.portalSlug}/${this.locale}/articles/draft`
), ),
toolTip: 'Draft', toolTip: 'Draft',
toStateName: 'list_draft_articles', toStateName: 'list_draft_articles',
@ -107,7 +113,7 @@ export default {
key: 'list_archived_articles', key: 'list_archived_articles',
count: 10, count: 10,
toState: frontendURL( toState: frontendURL(
`accounts/${this.accountId}/portals/:portalSlug/:locale/articles/archived` `accounts/${this.accountId}/portals/${this.portalSlug}/${this.locale}/articles/archived`
), ),
toolTip: 'Archived', toolTip: 'Archived',
toStateName: 'list_archived_articles', toStateName: 'list_archived_articles',

View file

@ -0,0 +1,20 @@
import { mapGetters } from 'vuex';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
computed: {
...mapGetters({ accountId: 'getCurrentAccountId' }),
portalSlug() {
return this.$route.params.portalSlug;
},
locale() {
return this.$route.params.locale;
},
},
methods: {
articleUrl(id) {
return frontendURL(
`accounts/${this.accountId}/portals/${this.portalSlug}/${this.locale}/articles/${id}`
);
},
},
};

View file

@ -0,0 +1,68 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import portalMixin from '../portalMixin';
import Vuex from 'vuex';
import VueRouter from 'vue-router';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueRouter);
import ListAllArticles from '../../pages/portals/ListAllPortals.vue';
const router = new VueRouter({
routes: [
{
path: ':portalSlug/:locale/articles',
name: 'list_all_locale_articles',
component: ListAllArticles,
},
],
});
describe('portalMixin', () => {
let getters;
let store;
let wrapper;
beforeEach(() => {
getters = {
getCurrentAccountId: () => 1,
};
const Component = {
render() {},
title: 'TestComponent',
mixins: [portalMixin],
router,
};
store = new Vuex.Store({ getters });
wrapper = shallowMount(Component, { store, localVue });
});
it('return account id', () => {
expect(wrapper.vm.accountId).toBe(1);
});
it('returns portal url', () => {
router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'fur-rent', locale: 'en' },
});
expect(wrapper.vm.articleUrl(1)).toBe(
'/app/accounts/1/portals/fur-rent/en/articles/1'
);
});
it('returns portal locale', () => {
router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'fur-rent', locale: 'es' },
});
expect(wrapper.vm.portalSlug).toBe('fur-rent');
});
it('returns portal slug', () => {
router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'campaign', locale: 'es' },
});
expect(wrapper.vm.portalSlug).toBe('campaign');
});
});

View file

@ -2,26 +2,30 @@
<div class="container"> <div class="container">
<article-header <article-header
:header-title="headerTitle" :header-title="headerTitle"
:count="articleCount" :count="meta.count"
selected-value="Published" selected-value="Published"
@newArticlePage="newArticlePage" @newArticlePage="newArticlePage"
/> />
<article-table :articles="articles" :article-count="articles.length" /> <article-table
<empty-state :articles="articles"
v-if="showSearchEmptyState" :article-count="articles.length"
:title="$t('HELP_CENTER.TABLE.404')" :current-page="Number(meta.currentPage)"
:total-count="meta.count"
@on-page-change="onPageChange"
/> />
<empty-state <div v-if="isFetching" class="articles--loader">
v-else-if="!isLoading && !articles.length"
:title="$t('CONTACTS_PAGE.LIST.NO_CONTACTS')"
/>
<div v-if="isLoading" class="articles--loader">
<spinner /> <spinner />
<span>{{ $t('HELP_CENTER.TABLE.LOADING_MESSAGE') }}</span> <span>{{ $t('HELP_CENTER.TABLE.LOADING_MESSAGE') }}</span>
</div> </div>
<empty-state
v-else-if="!isFetching && !articles.length"
:title="$t('HELP_CENTER.TABLE.NO_ARTICLES')"
/>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import ArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/ArticleHeader'; import ArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/ArticleHeader';
import EmptyState from 'dashboard/components/widgets/EmptyState'; import EmptyState from 'dashboard/components/widgets/EmptyState';
@ -35,45 +39,18 @@ export default {
}, },
data() { data() {
return { return {
// Dummy data will remove once the state is implemented. pageNumber: 1,
articles: [
{
title: 'Setup your account',
author: {
name: 'John Doe',
},
readCount: 13,
category: 'Getting started',
status: 'published',
updatedAt: 1657255863,
},
{
title: 'Docker Configuration',
author: {
name: 'Sam Manuel',
},
readCount: 13,
category: 'Engineering',
status: 'draft',
updatedAt: 1656658046,
},
{
title: 'Campaigns',
author: {
name: 'Sam Manuel',
},
readCount: 28,
category: 'Engineering',
status: 'archived',
updatedAt: 1657590446,
},
],
articleCount: 12,
isLoading: false,
}; };
}, },
computed: { computed: {
showSearchEmptyState() { ...mapGetters({
articles: 'articles/allArticles',
uiFlags: 'articles/uiFlags',
meta: 'articles/getMeta',
isFetching: 'articles/isFetching',
}),
showEmptyState() {
return this.articles.length === 0; return this.articles.length === 0;
}, },
articleType() { articleType() {
@ -92,10 +69,23 @@ export default {
} }
}, },
}, },
mounted() {
this.fetchArticles({ pageNumber: this.pageNumber });
},
methods: { methods: {
newArticlePage() { newArticlePage() {
this.$router.push({ name: 'new_article' }); this.$router.push({ name: 'new_article' });
}, },
fetchArticles({ pageNumber }) {
this.$store.dispatch('articles/index', {
pageNumber,
portalSlug: this.$route.params.portalSlug,
locale: this.$route.params.locale,
});
},
onPageChange(page) {
this.fetchArticles({ pageNumber: page });
},
}, },
}; };
</script> </script>

View file

@ -35,6 +35,7 @@ import teamMembers from './modules/teamMembers';
import teams from './modules/teams'; import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings'; import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks'; import webhooks from './modules/webhooks';
import articles from './modules/helpCenterArticles';
Vue.use(Vuex); Vue.use(Vuex);
export default new Vuex.Store({ export default new Vuex.Store({
@ -73,5 +74,6 @@ export default new Vuex.Store({
teams, teams,
userNotificationSettings, userNotificationSettings,
webhooks, webhooks,
articles,
}, },
}); });

View file

@ -1,13 +1,22 @@
import articlesAPI from 'dashboard/api/helpCenter/articles.js'; import portalAPI from 'dashboard/api/helpCenter/portals';
import articlesAPI from 'dashboard/api/helpCenter/articles';
import { throwErrorMessage } from 'dashboard/store/utils/api'; import { throwErrorMessage } from 'dashboard/store/utils/api';
import types from '../../mutation-types'; import types from '../../mutation-types';
export const actions = { export const actions = {
index: async ({ commit }) => { index: async ({ commit }, { pageNumber, portalSlug, locale }) => {
try { try {
commit(types.SET_UI_FLAG, { isFetching: true }); commit(types.SET_UI_FLAG, { isFetching: true });
const { data } = await articlesAPI.get(); const {
const articleIds = data.map(article => article.id); data: { payload, meta },
commit(types.ADD_MANY_ARTICLES, data); } = await portalAPI.getArticles({
pageNumber,
portalSlug,
locale,
});
const articleIds = payload.map(article => article.id);
commit(types.CLEAR_ARTICLES);
commit(types.ADD_MANY_ARTICLES, payload);
commit(types.SET_ARTICLES_META, meta);
commit(types.ADD_MANY_ARTICLES_ID, articleIds); commit(types.ADD_MANY_ARTICLES_ID, articleIds);
return articleIds; return articleIds;
} catch (error) { } catch (error) {
@ -31,6 +40,22 @@ export const actions = {
commit(types.SET_UI_FLAG, { isCreating: false }); commit(types.SET_UI_FLAG, { isCreating: false });
} }
}, },
show: async ({ commit }, { id, portalSlug }) => {
commit(types.SET_UI_FLAG, { isFetching: true });
try {
const response = await portalAPI.getArticle({ id, portalSlug });
const {
data: { payload },
} = response;
const { id: articleId } = payload;
commit(types.ADD_ARTICLE, payload);
commit(types.ADD_ARTICLE_ID, articleId);
commit(types.SET_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.SET_UI_FLAG, { isFetching: false });
}
},
update: async ({ commit }, params) => { update: async ({ commit }, params) => {
const articleId = params.id; const articleId = params.id;
commit(types.ADD_ARTICLE_FLAG, { commit(types.ADD_ARTICLE_FLAG, {

View file

@ -1,16 +1,14 @@
export const getters = { export const getters = {
uiFlagsIn: state => helpCenterId => { uiFlags: state => helpCenterId => {
const uiFlags = state.articles.uiFlags.byId[helpCenterId]; const uiFlags = state.articles.uiFlags.byId[helpCenterId];
if (uiFlags) return uiFlags; if (uiFlags) return uiFlags;
return { isFetching: false, isUpdating: false, isDeleting: false }; return { isFetching: false, isUpdating: false, isDeleting: false };
}, },
isFetchingHelpCenterArticles: state => state.uiFlags.isFetching, isFetching: state => state.uiFlags.isFetching,
articleById: (...getterArguments) => articleId => { articleById: (...getterArguments) => articleId => {
const [state] = getterArguments; const [state] = getterArguments;
const article = state.articles.byId[articleId]; const article = state.articles.byId[articleId];
if (!article) return undefined; if (!article) return undefined;
return article; return article;
}, },
allArticles: (...getterArguments) => { allArticles: (...getterArguments) => {
@ -20,4 +18,7 @@ export const getters = {
}); });
return articles; return articles;
}, },
getMeta: state => {
return state.meta;
},
}; };

View file

@ -8,6 +8,10 @@ export const defaultHelpCenterFlags = {
isDeleting: false, isDeleting: false,
}; };
const state = { const state = {
meta: {
count: 0,
currentPage: 1,
},
articles: { articles: {
byId: {}, byId: {},
allIds: [], allIds: [],

View file

@ -16,19 +16,28 @@ export const mutations = {
...article, ...article,
}); });
}, },
[types.CLEAR_ARTICLES]: $state => {
Vue.set($state.articles, 'byId', {});
Vue.set($state.articles, 'allIds', []);
Vue.set($state.articles, 'uiFlags', {});
},
[types.ADD_MANY_ARTICLES]($state, articles) { [types.ADD_MANY_ARTICLES]($state, articles) {
const allArticles = { ...$state.articles.byId }; const allArticles = { ...$state.articles.byId };
articles.forEach(article => { articles.forEach(article => {
allArticles[article.id] = article; allArticles[article.id] = article;
}); });
Vue.set($state.articles, 'byId', { Vue.set($state.articles, 'byId', allArticles);
allArticles,
});
}, },
[types.ADD_MANY_ARTICLES_ID]($state, articleIds) { [types.ADD_MANY_ARTICLES_ID]($state, articleIds) {
$state.articles.allIds.push(...articleIds); $state.articles.allIds.push(...articleIds);
}, },
[types.SET_ARTICLES_META]: ($state, data) => {
const { articles_count: count, current_page: currentPage } = data;
Vue.set($state.meta, 'count', count);
Vue.set($state.meta, 'currentPage', currentPage);
},
[types.ADD_ARTICLE_ID]: ($state, articleId) => { [types.ADD_ARTICLE_ID]: ($state, articleId) => {
$state.articles.allIds.push(articleId); $state.articles.allIds.push(articleId);
}, },

View file

@ -15,10 +15,22 @@ jest.mock('axios');
describe('#actions', () => { describe('#actions', () => {
describe('#index', () => { describe('#index', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: articleList }); axios.get.mockResolvedValue({
await actions.index({ commit }); data: {
payload: articleList,
meta: {
current_page: '1',
articles_count: 5,
},
},
});
await actions.index(
{ commit },
{ pageNumber: 1, portalSlug: 'test', locale: 'en' }
);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isFetching: true }], [types.default.SET_UI_FLAG, { isFetching: true }],
[types.default.CLEAR_ARTICLES],
[ [
types.default.ADD_MANY_ARTICLES, types.default.ADD_MANY_ARTICLES,
[ [
@ -29,13 +41,22 @@ describe('#actions', () => {
}, },
], ],
], ],
[
types.default.SET_ARTICLES_META,
{ current_page: '1', articles_count: 5 },
],
[types.default.ADD_MANY_ARTICLES_ID, [1]], [types.default.ADD_MANY_ARTICLES_ID, [1]],
[types.default.SET_UI_FLAG, { isFetching: false }], [types.default.SET_UI_FLAG, { isFetching: false }],
]); ]);
}); });
it('sends correct actions if API is error', async () => { it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' }); axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.index({ commit })).rejects.toThrow(Error); await expect(
actions.index(
{ commit },
{ pageNumber: 1, portalSlug: 'test', locale: 'en' }
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isFetching: true }], [types.default.SET_UI_FLAG, { isFetching: true }],
[types.default.SET_UI_FLAG, { isFetching: false }], [types.default.SET_UI_FLAG, { isFetching: false }],

View file

@ -1,4 +1,8 @@
export default { export default {
meta: {
count: 123,
currentPage: 2,
},
articles: { articles: {
byId: { byId: {
1: { 1: {

View file

@ -5,8 +5,8 @@ describe('#getters', () => {
beforeEach(() => { beforeEach(() => {
state = articles; state = articles;
}); });
it('uiFlagsIn', () => { it('uiFlags', () => {
expect(getters.uiFlagsIn(state)(1)).toEqual({ expect(getters.uiFlags(state)(1)).toEqual({
isFetching: false, isFetching: false,
isUpdating: true, isUpdating: true,
isDeleting: false, isDeleting: false,
@ -34,7 +34,7 @@ describe('#getters', () => {
}); });
}); });
it('isFetchingHelpCenters', () => { it('isFetchingArticles', () => {
expect(getters.isFetchingHelpCenterArticles(state)).toEqual(true); expect(getters.isFetching(state)).toEqual(true);
}); });
}); });

View file

@ -46,6 +46,19 @@ describe('#mutations', () => {
}); });
}); });
describe('#ARTICLES_META', () => {
it('add meta to state', () => {
mutations[types.SET_ARTICLES_META](state, {
articles_count: 3,
current_page: 1,
});
expect(state.meta).toEqual({
count: 3,
currentPage: 1,
});
});
});
describe('#ADD_ARTICLE_ID', () => { describe('#ADD_ARTICLE_ID', () => {
it('add valid article id to state', () => { it('add valid article id to state', () => {
mutations[types.ADD_ARTICLE_ID](state, 3); mutations[types.ADD_ARTICLE_ID](state, 3);
@ -87,4 +100,13 @@ describe('#mutations', () => {
expect(state.articles.byId[2]).toEqual(undefined); expect(state.articles.byId[2]).toEqual(undefined);
}); });
}); });
describe('#CLEAR_ARTICLES', () => {
it('clears articles', () => {
mutations[types.CLEAR_ARTICLES](state);
expect(state.articles.allIds).toEqual([]);
expect(state.articles.byId).toEqual({});
expect(state.articles.uiFlags).toEqual({});
});
});
}); });

View file

@ -226,8 +226,10 @@ export default {
ADD_ARTICLE_ID: 'ADD_ARTICLE_ID', ADD_ARTICLE_ID: 'ADD_ARTICLE_ID',
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',
ADD_ARTICLE_FLAG: 'ADD_ARTICLE_FLAG', ADD_ARTICLE_FLAG: 'ADD_ARTICLE_FLAG',
UPDATE_ARTICLE: 'UPDATE_ARTICLE', UPDATE_ARTICLE: 'UPDATE_ARTICLE',
CLEAR_ARTICLES: 'CLEAR_ARTICLES',
REMOVE_ARTICLE: 'REMOVE_ARTICLE', REMOVE_ARTICLE: 'REMOVE_ARTICLE',
REMOVE_ARTICLE_ID: 'REMOVE_ARTICLE_ID', REMOVE_ARTICLE_ID: 'REMOVE_ARTICLE_ID',
SET_UI_FLAG: 'SET_UI_FLAG', SET_UI_FLAG: 'SET_UI_FLAG',

View file

@ -5,6 +5,7 @@ json.content article.content
json.description article.description json.description article.description
json.status article.status json.status article.status
json.account_id article.account_id json.account_id article.account_id
json.updated_at article.updated_at.to_i
if article.portal.present? if article.portal.present?
json.portal do json.portal do

View file

@ -4,5 +4,5 @@ end
json.meta do json.meta do
json.current_page @current_page json.current_page @current_page
json.articles_count @articles.size json.articles_count @articles_count
end end

View file

@ -190,7 +190,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body) json_response = JSON.parse(response.body)
expect(json_response['payload'].count).to be 1 expect(json_response['payload'].count).to be 1
expect(json_response['meta']['articles_count']).to be json_response['payload'].size expect(json_response['meta']['articles_count']).to be 2
end end
end end