Files
chatwoot/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleTable.vue
Shivam Mishra ca2506a941 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>
2023-04-17 14:43:10 +05:30

211 lines
5.2 KiB
Vue

<template>
<div class="article-container">
<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"
:total-count="totalCount"
:page-size="pageSize"
@page-change="onPageChange"
/>
</div>
</template>
<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: {
type: Array,
default: () => {},
},
totalCount: {
type: Number,
default: 0,
},
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
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);
},
},
};
</script>
<style lang="scss" scoped>
.article-container {
width: 100%;
& > :not([hidden]) ~ :not([hidden]) {
border-top-width: 1px;
border-bottom-width: 0px;
}
.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>