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:
Shivam Mishra
2023-04-17 14:43:10 +05:30
committed by GitHub
parent 1886d4ce08
commit ca2506a941
13 changed files with 393 additions and 78 deletions

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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