mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +00:00
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:
@@ -1,14 +1,19 @@
|
||||
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_article, except: [:index, :create, :attach_file]
|
||||
before_action :fetch_article, except: [:index, :create, :attach_file, :reorder]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@portal_articles = @portal.articles
|
||||
@all_articles = @portal_articles.search(list_params)
|
||||
@articles_count = @all_articles.count
|
||||
@articles = @all_articles.order_by_updated_at.page(@current_page)
|
||||
|
||||
@articles = if list_params[:category_slug].present?
|
||||
@all_articles.order_by_position.page(@current_page).per(50)
|
||||
else
|
||||
@all_articles.order_by_updated_at.page(@current_page)
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -43,6 +48,11 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
render json: { file_url: url_for(file_blob) }
|
||||
end
|
||||
|
||||
def reorder
|
||||
Article.update_positions(params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_article
|
||||
|
||||
@@ -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 '';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
--space-small: 0.8rem;
|
||||
--space-one: 1rem;
|
||||
--space-slab: 1.2rem;
|
||||
--space-snug: 1.4rem;
|
||||
--space-normal: 1.6rem;
|
||||
--space-two: 2rem;
|
||||
--space-medium: 2.4rem;
|
||||
@@ -21,6 +22,7 @@
|
||||
--space-minus-small: -0.8rem;
|
||||
--space-minus-one: -1rem;
|
||||
--space-minus-slab: -1.2rem;
|
||||
--space-minus-snug: -1.4rem;
|
||||
--space-minus-normal: -1.6rem;
|
||||
--space-minus-two: -2rem;
|
||||
--space-minus-medium: -2.4rem;
|
||||
|
||||
@@ -176,5 +176,13 @@
|
||||
],
|
||||
"translate-outline": "M16.953 5.303a1 1 0 0 0-1.906-.606c-.124.389-.236.899-.324 1.344-.565.012-1.12 0-1.652-.038a1 1 0 1 0-.142 1.995c.46.032.934.048 1.416.047a25.649 25.649 0 0 0-.24 1.698c-1.263.716-2.142 1.684-2.636 2.7-.624 1.283-.7 2.857.239 3.883.675.736 1.704.758 2.499.588.322-.068.654-.176.988-.32a1 1 0 0 0 1.746-.93 13.17 13.17 0 0 0-.041-.115 8.404 8.404 0 0 0 2.735-4.06c.286.251.507.55.658.864.284.594.334 1.271.099 1.91-.234.633-.78 1.313-1.84 1.843a1 1 0 0 0 .895 1.789c1.44-.72 2.385-1.758 2.821-2.94a4.436 4.436 0 0 0-.17-3.464 4.752 4.752 0 0 0-2.104-2.165C19.998 9.22 20 9.11 20 9a1 1 0 0 0-1.974-.23 5.984 5.984 0 0 0-1.796.138c.047-.305.102-.626.166-.964a20.142 20.142 0 0 0 2.842-.473 1 1 0 0 0-.476-1.942c-.622.152-1.286.272-1.964.358.048-.208.1-.409.155-.584Zm-3.686 8.015c.166-.34.414-.697.758-1.037.02.348.053.67.098.973.083.56.207 1.048.341 1.477a3.41 3.41 0 0 1-.674.227c-.429.092-.588.019-.614.006l-.004-.001c-.162-.193-.329-.774.095-1.645Zm4.498-2.562a6.362 6.362 0 0 1-1.568 2.73 7.763 7.763 0 0 1-.095-.525 10.294 10.294 0 0 1-.088-1.904c.033-.013.067-.024.1-.036l1.651-.265Zm0 0-1.651.265c.602-.212 1.155-.29 1.651-.265ZM7.536 6.29a6.342 6.342 0 0 0-4.456.331 1 1 0 0 0 .848 1.811 4.342 4.342 0 0 1 3.049-.222c.364.107.568.248.69.37.12.123.203.27.257.454.067.225.087.446.09.69a8.195 8.195 0 0 0-.555-.117c-1.146-.199-2.733-.215-4.262.64-1.271.713-1.796 2.168-1.682 3.448.12 1.326.94 2.679 2.572 3.136 1.48.414 2.913-.045 3.877-.507l.08-.04a1 1 0 0 0 1.96-.281V10.5c0-.053.002-.12.005-.2.012-.417.034-1.16-.168-1.838a3.043 3.043 0 0 0-.755-1.29c-.394-.398-.91-.694-1.547-.881h-.003Zm-.419 5.288c.344.06.647.143.887.222v2.197a7.021 7.021 0 0 1-.905.524c-.792.38-1.682.605-2.473.384-.698-.195-1.06-.742-1.119-1.389-.062-.693.243-1.286.667-1.523.987-.553 2.06-.569 2.943-.415Z",
|
||||
"eye-show-outline": "M12 9.005a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm0 1.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM12 5.5c4.613 0 8.596 3.15 9.701 7.564a.75.75 0 1 1-1.455.365 8.503 8.503 0 0 0-16.493.004.75.75 0 0 1-1.455-.363A10.003 10.003 0 0 1 12 5.5Z",
|
||||
"eye-hide-outline": "M2.22 2.22a.75.75 0 0 0-.073.976l.073.084 4.034 4.035a9.986 9.986 0 0 0-3.955 5.75.75.75 0 0 0 1.455.364 8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084-6.113-6.114.001-.002-1.2-1.198-2.87-2.87h.002L8.719 7.658l.001-.002-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0Zm7.984 9.045 3.535 3.536a2.5 2.5 0 0 1-3.535-3.535ZM12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5Zm.195 3.51 3.801 3.8a4.003 4.003 0 0 0-3.801-3.8Z"
|
||||
"eye-hide-outline": "M2.22 2.22a.75.75 0 0 0-.073.976l.073.084 4.034 4.035a9.986 9.986 0 0 0-3.955 5.75.75.75 0 0 0 1.455.364 8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084-6.113-6.114.001-.002-1.2-1.198-2.87-2.87h.002L8.719 7.658l.001-.002-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0Zm7.984 9.045 3.535 3.536a2.5 2.5 0 0 1-3.535-3.535ZM12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5Zm.195 3.51 3.801 3.8a4.003 4.003 0 0 0-3.801-3.8Z",
|
||||
"grab-handle-outline": [
|
||||
"M5 4C5 3.44772 5.44772 3 6 3H10C10.5523 3 11 3.44772 11 4V7C11 7.55228 10.5523 8 10 8H6C5.44772 8 5 7.55228 5 7V4Z",
|
||||
"M13 4C13 3.44772 13.4477 3 14 3H18C18.5523 3 19 3.44772 19 4V7C19 7.55228 18.5523 8 18 8H14C13.4477 8 13 7.55228 13 7V4Z",
|
||||
"M13 11C13 10.4477 13.4477 10 14 10H18C18.5523 10 19 10.4477 19 11V14C19 14.5523 18.5523 15 18 15H14C13.4477 15 13 14.5523 13 14V11Z",
|
||||
"M5 11C5 10.4477 5.44772 10 6 10H10C10.5523 10 11 10.4477 11 11V14C11 14.5523 10.5523 15 10 15H6C5.44772 15 5 14.5523 5 14V11Z",
|
||||
"M5 18C5 17.4477 5.44772 17 6 17H10C10.5523 17 11 17.4477 11 18V21C11 21.5523 10.5523 22 10 22H6C5.44772 22 5 21.5523 5 21V18Z",
|
||||
"M13 18C13 17.4477 13.4477 17 14 17H18C18.5523 17 19 17.4477 19 18V21C19 21.5523 18.5523 22 18 22H14C13.4477 22 13 21.5523 13 21V18Z"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ class Article < ApplicationRecord
|
||||
validates :title, presence: true
|
||||
validates :content, presence: true
|
||||
|
||||
# ensuring that the position is always set correctly
|
||||
before_create :add_position_to_article
|
||||
after_save :category_id_changed_action, if: :saved_change_to_category_id?
|
||||
|
||||
enum status: { draft: 0, published: 1, archived: 2 }
|
||||
|
||||
scope :search_by_category_slug, ->(category_slug) { where(categories: { slug: category_slug }) if category_slug.present? }
|
||||
@@ -61,6 +65,7 @@ class Article < ApplicationRecord
|
||||
scope :search_by_author, ->(author_id) { where(author_id: author_id) if author_id.present? }
|
||||
scope :search_by_status, ->(status) { where(status: status) if status.present? }
|
||||
scope :order_by_updated_at, -> { reorder(updated_at: :desc) }
|
||||
scope :order_by_position, -> { reorder(position: :asc) }
|
||||
|
||||
# TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS
|
||||
pg_search_scope(
|
||||
@@ -113,8 +118,50 @@ class Article < ApplicationRecord
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def self.update_positions(positions_hash)
|
||||
positions_hash.each do |article_id, new_position|
|
||||
# Find the article by its ID and update its position
|
||||
article = Article.find(article_id)
|
||||
article.update!(position: new_position)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def category_id_changed_action
|
||||
# We need to update the position of the article in the new category
|
||||
return unless persisted?
|
||||
|
||||
# this means the article is just created
|
||||
# and the category_id is newly set
|
||||
# and the position is already present
|
||||
return if created_at_before_last_save.nil? && position.present? && category_id_before_last_save.nil?
|
||||
|
||||
update_article_position_in_category
|
||||
end
|
||||
|
||||
def add_position_to_article
|
||||
# on creation if a position is already present, ignore it
|
||||
return if position.present?
|
||||
|
||||
update_article_position_in_category
|
||||
end
|
||||
|
||||
def update_article_position_in_category
|
||||
max_position = Article.where(category_id: category_id, account_id: account_id).maximum(:position)
|
||||
|
||||
new_position = max_position.present? ? max_position + 10 : 10
|
||||
|
||||
# update column to avoid validations if the article is already persisted
|
||||
if persisted?
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
update_column(:position, new_position)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
else
|
||||
self.position = new_position
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_account_id
|
||||
self.account_id = portal&.account_id
|
||||
end
|
||||
|
||||
@@ -27,6 +27,10 @@ class ArticlePolicy < ApplicationPolicy
|
||||
@account_user.administrator? || portal_member?
|
||||
end
|
||||
|
||||
def reorder?
|
||||
@account_user.administrator? || portal_member?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def portal_member?
|
||||
|
||||
@@ -196,6 +196,7 @@ Rails.application.routes.draw do
|
||||
resources :categories
|
||||
resources :articles do
|
||||
post :attach_file, on: :collection
|
||||
post :reorder, on: :collection
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
30
db/migrate/20230405104405_update_position_for_articles.rb
Normal file
30
db/migrate/20230405104405_update_position_for_articles.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
class UpdatePositionForArticles < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
# Get the unique combinations of account_id and category_id
|
||||
groups = Article.select(:account_id, :category_id).distinct
|
||||
|
||||
# Iterate through the groups
|
||||
groups.each do |group|
|
||||
# Get articles belonging to the current group
|
||||
articles = Article.where(account_id: group.account_id, category_id: group.category_id)
|
||||
|
||||
# Separate articles with a position set and those without
|
||||
articles_with_position = articles.where.not(position: nil).order(:position, :updated_at)
|
||||
articles_without_position = articles.where(position: nil).order(:updated_at)
|
||||
|
||||
# Set the position for articles with a position set, in multiples of 10
|
||||
# why multiples of 10? because we want to leave room for articles which can be added in between in case we are editing the order from the DB
|
||||
position_counter = 0
|
||||
articles_with_position.each do |article|
|
||||
position_counter += 10
|
||||
article.update(position: position_counter)
|
||||
end
|
||||
|
||||
# Set the position for articles without a position, starting from where the last position ended
|
||||
articles_without_position.each do |article|
|
||||
position_counter += 10
|
||||
article.update(position: position_counter)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -437,7 +437,7 @@ ActiveRecord::Schema.define(version: 2023_04_07_191457) do
|
||||
t.datetime "agent_last_seen_at"
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.bigint "contact_inbox_id"
|
||||
t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
|
||||
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
|
||||
t.string "identifier"
|
||||
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
|
||||
t.bigint "team_id"
|
||||
|
||||
Reference in New Issue
Block a user