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

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

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

View File

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

View File

@@ -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"
]
}

View File

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

View File

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

View File

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

View 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

View File

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