feat: allow sorting of articles (#6833)
* feat: sort by position * chore: whitespace change * feat: add border bottom color to list item * feat: allow dragging articles * feat: add migration to reorder all articles * feat: add onsort method * feat: finish UI sorting * feat: show 50 per page in articles list * feat: add article sorting methods * feat: patch up reorder action with the API * refactor: better naming * chore: add comments * feat: attach position to article before create * feat: move article to end if moved between categories * chore: add comments * chore: update version * fix: don't change position if previous category was nil * fix: condition to trigger update on category change * refactor: store new_position * refactor: use grid instead of table * feat: add snug spacing * feat: add grab-icon * feat: add grab icon to list * refactor: show draggable only for category page * feat: add update_positions as a class method --------- Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
This commit is contained in:
@@ -60,6 +60,13 @@ class ArticlesAPI extends PortalsAPI {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
reorderArticles({ portalSlug, reorderedGroup, categorySlug }) {
|
||||
return axios.post(`${this.url}/${portalSlug}/articles/reorder`, {
|
||||
positions_hash: reorderedGroup,
|
||||
category_slug: categorySlug,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ArticlesAPI();
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
<template>
|
||||
<tr class="row--article-block">
|
||||
<td>
|
||||
<div class="article-content-wrap">
|
||||
<div class="article-block">
|
||||
<router-link :to="articleUrl(id)">
|
||||
<h6 :title="title" class="sub-block-title text-truncate">
|
||||
{{ title }}
|
||||
</h6>
|
||||
</router-link>
|
||||
<div class="author">
|
||||
<span class="by">{{ $t('HELP_CENTER.TABLE.COLUMNS.BY') }}</span>
|
||||
<span class="name">{{ articleAuthorName }}</span>
|
||||
</div>
|
||||
<div class="article-container--row">
|
||||
<span class="article-column article-title">
|
||||
<emoji-or-icon class="icon-grab" icon="grab-handle" />
|
||||
<div class="article-block">
|
||||
<router-link :to="articleUrl(id)">
|
||||
<h6 :title="title" class="sub-block-title text-truncate">
|
||||
{{ title }}
|
||||
</h6>
|
||||
</router-link>
|
||||
<div class="author">
|
||||
<span class="by">{{ $t('HELP_CENTER.TABLE.COLUMNS.BY') }}</span>
|
||||
<span class="name">{{ articleAuthorName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
</span>
|
||||
<span class="article-column article-category">
|
||||
<router-link
|
||||
class="fs-small button clear link secondary"
|
||||
:to="getCategoryRoute(category.slug)"
|
||||
@@ -27,13 +26,13 @@
|
||||
{{ category.name }}
|
||||
</span>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
</span>
|
||||
<span class="article-column article-read-count">
|
||||
<span class="fs-small" :title="formattedViewCount">
|
||||
{{ readableViewCount }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
</span>
|
||||
<span class="article-column article-status">
|
||||
<div>
|
||||
<woot-label
|
||||
:title="status"
|
||||
@@ -42,22 +41,25 @@
|
||||
:color-scheme="labelColor"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
</span>
|
||||
<span class="article-column article-last-edited">
|
||||
<span class="fs-small">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import timeMixin from 'dashboard/mixins/time';
|
||||
import portalMixin from '../mixins/portalMixin';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import EmojiOrIcon from '../../../../../shared/components/EmojiOrIcon.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EmojiOrIcon,
|
||||
},
|
||||
mixins: [timeMixin, portalMixin],
|
||||
|
||||
props: {
|
||||
id: {
|
||||
type: Number,
|
||||
@@ -130,19 +132,79 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
td {
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--s-700);
|
||||
font-size: var(--font-size-mini);
|
||||
padding-left: 0;
|
||||
}
|
||||
.row--article-block {
|
||||
border-bottom-color: transparent;
|
||||
.article-content-wrap {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
text-align: left;
|
||||
.article-container--row {
|
||||
background: var(--white);
|
||||
border-bottom: 1px solid var(--s-50);
|
||||
display: grid;
|
||||
gap: var(--space-normal);
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
margin: 0 var(--space-minus-normal);
|
||||
padding: 0 var(--space-normal);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
&.draggable {
|
||||
span.article-column.article-title {
|
||||
margin-left: var(--space-minus-small);
|
||||
|
||||
.icon-grab {
|
||||
display: block;
|
||||
cursor: move;
|
||||
height: var(--space-normal);
|
||||
margin-top: var(--space-smaller);
|
||||
width: var(--space-normal);
|
||||
|
||||
color: var(--s-100);
|
||||
|
||||
&:hover {
|
||||
color: var(--s-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.article-column {
|
||||
color: var(--s-700);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-bold);
|
||||
padding: var(--space-small) 0;
|
||||
text-align: right;
|
||||
text-transform: capitalize;
|
||||
|
||||
&.article-title {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
gap: var(--space-small);
|
||||
grid-column: span 4 / span 4;
|
||||
text-align: left;
|
||||
text-align: left;
|
||||
|
||||
.icon-grab {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// for screen sizes smaller than 1024px
|
||||
@media (max-width: 63.9375em) {
|
||||
&.article-read-count {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 47.9375em) {
|
||||
&.article-read-count,
|
||||
&.article-last-edited {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.article-block {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -170,8 +232,10 @@ td {
|
||||
}
|
||||
}
|
||||
|
||||
.category-link-content {
|
||||
max-width: 16rem;
|
||||
line-height: 1.5;
|
||||
span {
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--s-700);
|
||||
font-size: var(--font-size-mini);
|
||||
padding-left: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,48 @@
|
||||
<template>
|
||||
<div class="article-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}</th>
|
||||
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}</th>
|
||||
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}</th>
|
||||
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}</th>
|
||||
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.LAST_EDITED') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td colspan="100%" class="horizontal-line" />
|
||||
</tr>
|
||||
<tbody>
|
||||
<ArticleItem
|
||||
v-for="article in articles"
|
||||
:id="article.id"
|
||||
:key="article.id"
|
||||
:title="article.title"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:views="article.views"
|
||||
:status="article.status"
|
||||
:updated-at="article.updated_at"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="article-container--header"
|
||||
:class="{ draggable: onCategoryPage }"
|
||||
>
|
||||
<div class="heading-item heading-title">
|
||||
{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}
|
||||
</div>
|
||||
<div class="heading-item heading-category">
|
||||
{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}
|
||||
</div>
|
||||
<div class="heading-item heading-read-count">
|
||||
{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}
|
||||
</div>
|
||||
<div class="heading-item heading-status">
|
||||
{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}
|
||||
</div>
|
||||
<div class="heading-item heading-last-edited">
|
||||
{{ $t('HELP_CENTER.TABLE.HEADERS.LAST_EDITED') }}
|
||||
</div>
|
||||
</div>
|
||||
<draggable
|
||||
tag="div"
|
||||
class="article-container--border"
|
||||
:disabled="!dragEnabled"
|
||||
:list="localArticles"
|
||||
ghost-class="article-ghost-class"
|
||||
@start="dragging = true"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<ArticleItem
|
||||
v-for="article in localArticles"
|
||||
:id="article.id"
|
||||
:key="article.id"
|
||||
:class="{ draggable: onCategoryPage }"
|
||||
:title="article.title"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:views="article.views"
|
||||
:status="article.status"
|
||||
:updated-at="article.updated_at"
|
||||
/>
|
||||
</draggable>
|
||||
|
||||
<table-footer
|
||||
v-if="articles.length"
|
||||
:current-page="currentPage"
|
||||
@@ -40,10 +56,13 @@
|
||||
<script>
|
||||
import ArticleItem from './ArticleItem.vue';
|
||||
import TableFooter from 'dashboard/components/widgets/TableFooter';
|
||||
import draggable from 'vuedraggable';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ArticleItem,
|
||||
TableFooter,
|
||||
draggable,
|
||||
},
|
||||
props: {
|
||||
articles: {
|
||||
@@ -63,7 +82,56 @@ export default {
|
||||
default: 25,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localArticles: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dragEnabled() {
|
||||
// dragging allowed only on category page
|
||||
return (
|
||||
this.articles.length > 1 && !this.isFetching && this.onCategoryPage
|
||||
);
|
||||
},
|
||||
onCategoryPage() {
|
||||
return this.$route.name === 'show_category';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
articles() {
|
||||
this.localArticles = [...this.articles];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onDragEnd() {
|
||||
// why reuse the same positons array, instead of creating a new one?
|
||||
// this ensures that the shuffling happens within the same group
|
||||
// itself and does not create any new positions and avoid conflict with existing articles
|
||||
// so if a user sorts on page number 2, and the positions are say [550, 560, 570, 580, 590]
|
||||
// the new sorted items will be in the same position range as well
|
||||
const sortedArticlePositions = this.localArticles
|
||||
.map(article => article.position)
|
||||
.sort((a, b) => {
|
||||
// Why sort like this? Glad you asked!
|
||||
// because JavaScript is the doom of my existence, and if a `compareFn` is not supplied,
|
||||
// all non-undefined array elements are sorted by converting them to strings
|
||||
// and comparing strings in UTF-16 code units order.
|
||||
//
|
||||
// so an array [20, 10000, 10, 30, 40] will be sorted as [10, 10000, 20, 30, 40]
|
||||
|
||||
return a - b;
|
||||
});
|
||||
|
||||
const orderedArticles = this.localArticles.map(article => article.id);
|
||||
|
||||
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
|
||||
obj[key] = sortedArticlePositions[index];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
this.$emit('reorder', reorderedGroup);
|
||||
},
|
||||
onPageChange(page) {
|
||||
this.$emit('page-change', page);
|
||||
},
|
||||
@@ -74,19 +142,69 @@ export default {
|
||||
.article-container {
|
||||
width: 100%;
|
||||
|
||||
table thead th {
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-transform: capitalize;
|
||||
color: var(--s-700);
|
||||
font-size: var(--font-size-small);
|
||||
padding-left: 0;
|
||||
& > :not([hidden]) ~ :not([hidden]) {
|
||||
border-top-width: 1px;
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
.horizontal-line {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
.article-container--header {
|
||||
margin: 0 var(--space-minus-normal);
|
||||
padding: 0 var(--space-normal);
|
||||
display: grid;
|
||||
gap: var(--space-normal);
|
||||
border-bottom: 1px solid var(--s-100);
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
&.draggable {
|
||||
div.heading-item.heading-title {
|
||||
padding: var(--space-small) var(--space-snug);
|
||||
}
|
||||
}
|
||||
|
||||
div.heading-item {
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-transform: capitalize;
|
||||
color: var(--s-700);
|
||||
font-size: var(--font-size-small);
|
||||
text-align: right;
|
||||
padding: var(--space-small) 0;
|
||||
|
||||
&.heading-title {
|
||||
text-align: left;
|
||||
grid-column: span 4 / span 4;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
&.heading-read-count {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
&.heading-read-count,
|
||||
&.heading-last-edited {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.article-ghost-class {
|
||||
opacity: 0.5;
|
||||
background-color: var(--s-50);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-count="Number(meta.count)"
|
||||
@page-change="onPageChange"
|
||||
@reorder="onReorder"
|
||||
/>
|
||||
<div v-if="shouldShowLoader" class="articles--loader">
|
||||
<spinner />
|
||||
@@ -29,6 +30,7 @@ import Spinner from 'shared/components/Spinner.vue';
|
||||
import ArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/ArticleHeader';
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState';
|
||||
import ArticleTable from '../../components/ArticleTable';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ArticleHeader,
|
||||
@@ -137,6 +139,12 @@ export default {
|
||||
onPageChange(pageNumber) {
|
||||
this.fetchArticles({ pageNumber });
|
||||
},
|
||||
onReorder(reorderedGroup) {
|
||||
this.$store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: this.$route.params.portalSlug,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -69,6 +69,7 @@ export const actions = {
|
||||
commit(types.SET_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
|
||||
commit(types.UPDATE_ARTICLE_FLAG, {
|
||||
uiFlags: {
|
||||
@@ -100,6 +101,7 @@ export const actions = {
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ commit }, { portalSlug, articleId }) => {
|
||||
commit(types.UPDATE_ARTICLE_FLAG, {
|
||||
uiFlags: {
|
||||
@@ -138,4 +140,18 @@ export const actions = {
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
|
||||
try {
|
||||
await articlesAPI.reorderArticles({
|
||||
portalSlug,
|
||||
reorderedGroup,
|
||||
categorySlug,
|
||||
});
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user