feat(help-center): enable drag-and-drop category reordering (#13706)

This commit is contained in:
Sojan Jose
2026-03-04 23:23:38 -08:00
committed by GitHub
parent 3abe32a2c7
commit 42a244369d
33 changed files with 708 additions and 47 deletions

View File

@@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
Article.update_positions(params[:positions_hash])
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end

View File

@@ -1,7 +1,7 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_category, except: [:index, :create]
before_action :fetch_category, except: [:index, :create, :reorder]
before_action :set_current_page, only: [:index]
def index
@@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
head :ok
end
def reorder
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private
def fetch_category
@@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def related_categories_records

View File

@@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI {
delete({ portalSlug, categoryId }) {
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
}
reorder({ portalSlug, reorderedGroup }) {
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
positions_hash: reorderedGroup,
});
}
}
export default new CategoriesAPI();

View File

@@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
expect(categoriesAPI).toHaveProperty('create');
expect(categoriesAPI).toHaveProperty('update');
expect(categoriesAPI).toHaveProperty('delete');
expect(categoriesAPI).toHaveProperty('reorder');
});
});

View File

@@ -58,18 +58,22 @@ const openArticle = id => {
}
};
const onReorder = reorderedGroup => {
store.dispatch('articles/reorder', {
reorderedGroup,
portalSlug: route.params.portalSlug,
});
const onReorder = async reorderedGroup => {
try {
await store.dispatch('articles/reorder', {
reorderedGroup,
portalSlug: route.params.portalSlug,
});
} catch {
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
}
};
const onDragEnd = () => {
// Reuse existing positions to maintain order within the current group
// Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedArticlePositions = localArticles.value
.map(article => article.position)
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
.map((article, index) => article.position || index + 1)
.sort((a, b) => a - b);
const orderedArticles = localArticles.value.map(article => article.id);

View File

@@ -98,6 +98,17 @@ const handleAction = ({ action, id, category: categoryData }) => {
deleteCategory(categoryData);
}
};
const reorderCategories = async reorderedGroup => {
try {
await store.dispatch('categories/reorder', {
portalSlug: route.params.portalSlug,
reorderedGroup,
});
} catch {
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
}
};
</script>
<template>
@@ -122,6 +133,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
:categories="categories"
@click="openCategoryArticles"
@action="handleAction"
@reorder="reorderCategories"
/>
<CategoryEmptyState
v-else

View File

@@ -1,14 +1,22 @@
<script setup>
import { computed, ref, watch } from 'vue';
import Draggable from 'vuedraggable';
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
defineProps({
const props = defineProps({
categories: {
type: Array,
required: true,
},
});
const emit = defineEmits(['click', 'action']);
const emit = defineEmits(['click', 'action', 'reorder']);
const localCategories = ref(props.categories);
const dragEnabled = computed(() => {
return localCategories.value?.length > 1;
});
const handleClick = slug => {
emit('click', slug);
@@ -17,21 +25,57 @@ const handleClick = slug => {
const handleAction = ({ action, value, id }, category) => {
emit('action', { action, value, id, category });
};
const onDragEnd = () => {
// Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedPositions = localCategories.value
.map((category, index) => category.position || index + 1)
.sort((a, b) => a - b);
const reorderedGroup = localCategories.value.reduce(
(obj, category, index) => {
obj[category.id] = sortedPositions[index];
return obj;
},
{}
);
emit('reorder', reorderedGroup);
};
watch(
() => props.categories,
newCategories => {
localCategories.value = newCategories;
},
{ deep: true }
);
</script>
<template>
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
<CategoryCard
v-for="category in categories"
:id="category.id"
:key="category.id"
:title="category.name"
:icon="category.icon"
:description="category.description"
:articles-count="category.meta.articles_count || 0"
:slug="category.slug"
@click="handleClick(category.slug)"
@action="handleAction($event, category)"
/>
</ul>
<Draggable
v-model="localCategories"
:disabled="!dragEnabled"
item-key="id"
tag="ul"
role="list"
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
@end="onDragEnd"
>
<template #item="{ element }">
<li class="list-none">
<CategoryCard
:id="element.id"
:title="element.name"
:icon="element.icon"
:description="element.description"
:articles-count="element.meta?.articles_count || 0"
:slug="element.slug"
:class="{ 'cursor-grab': dragEnabled }"
@click="handleClick(element.slug)"
@action="handleAction($event, element)"
/>
</li>
</template>
</Draggable>
</template>

View File

@@ -374,6 +374,16 @@
"ERROR_MESSAGE": "Error while deleting article"
}
},
"REORDER_ARTICLE": {
"API": {
"ERROR_MESSAGE": "Unable to reorder articles. Please try again."
}
},
"REORDER_CATEGORY": {
"API": {
"ERROR_MESSAGE": "Unable to reorder categories. Please try again."
}
},
"CREATE_ARTICLE": {
"ERROR_MESSAGE": "Please add the article heading and content then only you can update the settings"
},
@@ -839,7 +849,7 @@
"STATUS": {
"UPLOADED": "Ready",
"PROCESSING": "Processing",
"PROCESSED": "Completed",
"PROCESSED": "Completed",
"FAILED": "Failed"
}
},

View File

@@ -11,7 +11,10 @@ const store = useStore();
const pageNumber = ref(1);
const articles = useMapGetter('articles/allArticles');
const allArticles = useMapGetter('articles/allArticles');
const articlesSortedByPosition = useMapGetter(
'articles/allArticlesSortedByPosition'
);
const categories = useMapGetter('categories/allCategories');
const meta = useMapGetter('articles/getMeta');
const portalMeta = useMapGetter('portals/getMeta');
@@ -58,6 +61,11 @@ const isCategoryArticles = computed(() => {
);
});
// Use position-sorted articles for category views and categories filter view (where drag reorder is enabled)
const articles = computed(() =>
isCategoryArticles.value ? articlesSortedByPosition.value : allArticles.value
);
const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
store.dispatch('articles/index', {
pageNumber: pageNumberParam || pageNumber.value,

View File

@@ -9,7 +9,7 @@ import CategoriesPage from 'dashboard/components-next/HelpCenter/Pages/CategoryP
const store = useStore();
const route = useRoute();
const categories = useMapGetter('categories/allCategories');
const categories = useMapGetter('categories/allCategoriesSortedByPosition');
const selectedPortalSlug = computed(() => route.params.portalSlug);
const getPortalBySlug = useMapGetter('portals/portalBySlug');

View File

@@ -167,7 +167,17 @@ export const actions = {
return fileUrl;
},
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
reorder: async (
{ commit, state },
{ portalSlug, categorySlug, reorderedGroup }
) => {
// Save old positions so we can rollback on failure
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
map[id] = state.articles.byId[id]?.position;
return map;
}, {});
// Update positions in the store immediately so subsequent mutations preserve correct positions
commit(types.SET_ARTICLE_POSITIONS, reorderedGroup);
try {
await articlesAPI.reorderArticles({
portalSlug,
@@ -175,9 +185,8 @@ export const actions = {
categorySlug,
});
} catch (error) {
throwErrorMessage(error);
commit(types.SET_ARTICLE_POSITIONS, oldPositions);
throw error;
}
return '';
},
};

View File

@@ -22,6 +22,16 @@ export const getters = {
.filter(article => article !== undefined);
return articles;
},
allArticlesSortedByPosition: (...getterArguments) => {
const [state, _getters] = getterArguments;
const articles = state.articles.allIds
.map(id => _getters.articleById(id))
.filter(article => article !== undefined);
// Sort by position so reordered articles stay in correct order after store updates
return articles.sort(
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
);
},
articleStatus:
(...getterArguments) =>
articleId => {

View File

@@ -64,6 +64,18 @@ export const mutations = {
...uiFlags,
};
},
[types.SET_ARTICLE_POSITIONS]: ($state, positionsHash) => {
const { byId, allIds } = $state.articles;
// Update position on each article record
Object.entries(positionsHash).forEach(([id, position]) => {
if (byId[id]) byId[id] = { ...byId[id], position };
});
// Re-sort allIds so every consumer sees the new order
allIds.sort(
(a, b) =>
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
);
},
[types.UPDATE_ARTICLE]: ($state, updatedArticle) => {
const articleId = updatedArticle.id;
if ($state.articles.byId[articleId]) {

View File

@@ -279,4 +279,63 @@ describe('#actions', () => {
).rejects.toThrow('Upload failed');
});
});
describe('#reorder', () => {
const state = {
articles: {
byId: {
1: { id: 1, title: 'Article 1', position: 10 },
2: { id: 2, title: 'Article 2', position: 20 },
3: { id: 3, title: 'Article 3', position: 30 },
},
},
};
it('commits SET_ARTICLE_POSITIONS and calls API when reorder is successful', async () => {
axios.post.mockResolvedValue({ data: {} });
const reorderedGroup = { 1: 1, 2: 2, 3: 3 };
await actions.reorder(
{ commit, state },
{
portalSlug: 'test-portal',
categorySlug: 'test-category',
reorderedGroup,
}
);
expect(commit).toHaveBeenCalledWith(
types.default.SET_ARTICLE_POSITIONS,
reorderedGroup
);
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/portals/test-portal/articles/reorder'),
{ positions_hash: reorderedGroup, category_slug: 'test-category' }
);
});
it('rolls back positions and throws when API call fails', async () => {
axios.post.mockRejectedValue({ message: 'Network error' });
const reorderedGroup = { 1: 1, 2: 2 };
await expect(
actions.reorder(
{ commit, state },
{
portalSlug: 'test-portal',
reorderedGroup,
}
)
).rejects.toEqual({ message: 'Network error' });
expect(commit).toHaveBeenCalledWith(
types.default.SET_ARTICLE_POSITIONS,
reorderedGroup
);
expect(commit).toHaveBeenCalledWith(types.default.SET_ARTICLE_POSITIONS, {
1: 10,
2: 20,
});
});
});
});

View File

@@ -41,4 +41,82 @@ describe('#getters', () => {
it('isFetchingArticles', () => {
expect(getters.isFetching(state)).toEqual(true);
});
describe('allArticlesSortedByPosition', () => {
it('returns articles sorted by position in ascending order', () => {
const stateWithPositions = {
...state,
articles: {
...state.articles,
byId: {
1: { id: 1, title: 'Article 1', position: 3 },
2: { id: 2, title: 'Article 2', position: 1 },
3: { id: 3, title: 'Article 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
articleById: getters.articleById(stateWithPositions),
};
const result = getters.allArticlesSortedByPosition(
stateWithPositions,
boundGetters
);
expect(result.map(a => a.id)).toEqual([2, 3, 1]);
expect(result.map(a => a.position)).toEqual([1, 2, 3]);
});
it('places articles with null position at the end', () => {
const stateWithNullPositions = {
...state,
articles: {
...state.articles,
byId: {
1: { id: 1, title: 'Article 1', position: 1 },
2: { id: 2, title: 'Article 2', position: null },
3: { id: 3, title: 'Article 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
articleById: getters.articleById(stateWithNullPositions),
};
const result = getters.allArticlesSortedByPosition(
stateWithNullPositions,
boundGetters
);
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
});
it('handles articles with undefined position', () => {
const stateWithUndefinedPositions = {
...state,
articles: {
...state.articles,
byId: {
1: { id: 1, title: 'Article 1', position: 1 },
2: { id: 2, title: 'Article 2' },
3: { id: 3, title: 'Article 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
articleById: getters.articleById(stateWithUndefinedPositions),
};
const result = getters.allArticlesSortedByPosition(
stateWithUndefinedPositions,
boundGetters
);
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
});
});
});

View File

@@ -5,7 +5,7 @@ import types from '../../../mutation-types';
describe('#mutations', () => {
let state = {};
beforeEach(() => {
state = article;
state = JSON.parse(JSON.stringify(article));
});
describe('#SET_UI_FLAG', () => {
@@ -93,9 +93,9 @@ describe('#mutations', () => {
mutations[types.ADD_ARTICLE_ID](state, 3);
expect(state.articles.allIds).toEqual([1, 2, 3]);
});
it('Does not invalid article with empty data passed', () => {
mutations[types.ADD_ARTICLE_ID](state, {});
expect(state).toEqual(article);
it('does not add duplicate article id to state', () => {
mutations[types.ADD_ARTICLE_ID](state, 1);
expect(state.articles.allIds).toEqual([1, 2]);
});
});
@@ -154,4 +154,53 @@ describe('#mutations', () => {
});
});
});
describe('#SET_ARTICLE_POSITIONS', () => {
it('updates positions for articles in the store', () => {
const positionsHash = { 1: 1, 2: 2 };
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
expect(state.articles.byId[1].position).toEqual(1);
expect(state.articles.byId[2].position).toEqual(2);
});
it('does not update articles that are not in the store', () => {
const positionsHash = { 999: 5 };
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
expect(state.articles.byId[999]).toBeUndefined();
});
it('preserves other article properties when updating position', () => {
const originalTitle = state.articles.byId[1].title;
const positionsHash = { 1: 3 };
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
expect(state.articles.byId[1].position).toEqual(3);
expect(state.articles.byId[1].title).toEqual(originalTitle);
});
it('re-sorts allIds by position after update', () => {
state.articles.byId[1].position = 1;
state.articles.byId[2].position = 2;
state.articles.allIds = [1, 2];
mutations[types.SET_ARTICLE_POSITIONS](state, { 1: 3, 2: 1 });
expect(state.articles.allIds).toEqual([2, 1]);
});
it('UPDATE_ARTICLE preserves reordered position after SET_ARTICLE_POSITIONS', () => {
mutations[types.SET_ARTICLE_POSITIONS](state, { 2: 1 });
expect(state.articles.byId[2].position).toEqual(1);
mutations[types.UPDATE_ARTICLE](state, {
id: 2,
title: 'Updated Title',
status: 'published',
});
expect(state.articles.byId[2].position).toEqual(1);
expect(state.articles.byId[2].title).toEqual('Updated Title');
});
});
});

View File

@@ -92,4 +92,23 @@ export const actions = {
});
}
},
reorder: async ({ commit, state }, { portalSlug, reorderedGroup }) => {
// Save old positions so we can rollback on failure
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
map[id] = state.categories.byId[id]?.position;
return map;
}, {});
// Update positions in the store immediately so subsequent mutations preserve correct positions
commit(types.SET_CATEGORY_POSITIONS, reorderedGroup);
try {
await categoriesAPI.reorder({
portalSlug,
reorderedGroup,
});
} catch (error) {
commit(types.SET_CATEGORY_POSITIONS, oldPositions);
throw error;
}
},
};

View File

@@ -21,6 +21,16 @@ export const getters = {
});
return categories;
},
allCategoriesSortedByPosition: (...getterArguments) => {
const [state, _getters] = getterArguments;
const categories = state.categories.allIds
.map(id => _getters.categoryById(id))
.filter(category => category !== undefined);
// Sort by position so reordered categories stay in correct order after store updates
return categories.sort(
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
);
},
categoriesByLocaleCode:
(...getterArguments) =>
localeCode => {

View File

@@ -49,6 +49,18 @@ export const mutations = {
...uiFlags,
};
},
[types.SET_CATEGORY_POSITIONS]: ($state, positionsHash) => {
const { byId, allIds } = $state.categories;
// Update position on each category record
Object.entries(positionsHash).forEach(([id, position]) => {
if (byId[id]) byId[id] = { ...byId[id], position };
});
// Re-sort allIds so every consumer sees the new order
allIds.sort(
(a, b) =>
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
);
},
[types.UPDATE_CATEGORY]($state, category) {
const categoryId = category.id;

View File

@@ -161,4 +161,63 @@ describe('#actions', () => {
]);
});
});
describe('#reorder', () => {
const state = {
categories: {
byId: {
1: { id: 1, name: 'Category 1', position: 10 },
2: { id: 2, name: 'Category 2', position: 20 },
},
},
};
it('commits SET_CATEGORY_POSITIONS and calls API when reorder is successful', async () => {
axios.post.mockResolvedValue({ data: {} });
const reorderedGroup = { 2: 1, 1: 2 };
await actions.reorder(
{ commit, state },
{
portalSlug: 'room-rental',
reorderedGroup,
}
);
expect(commit).toHaveBeenCalledWith(
types.default.SET_CATEGORY_POSITIONS,
reorderedGroup
);
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/portals/room-rental/categories/reorder'),
{
positions_hash: { 2: 1, 1: 2 },
}
);
});
it('rolls back positions and throws when API call fails', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
const reorderedGroup = { 2: 1, 1: 2 };
await expect(
actions.reorder(
{ commit, state },
{
portalSlug: 'room-rental',
reorderedGroup,
}
)
).rejects.toEqual({ message: 'Incorrect header' });
expect(commit).toHaveBeenCalledWith(
types.default.SET_CATEGORY_POSITIONS,
reorderedGroup
);
expect(commit).toHaveBeenCalledWith(
types.default.SET_CATEGORY_POSITIONS,
{ 1: 10, 2: 20 }
);
});
});
});

View File

@@ -25,4 +25,82 @@ describe('#getters', () => {
it('isFetchingCategories', () => {
expect(getters.isFetching(state)).toEqual(true);
});
describe('allCategoriesSortedByPosition', () => {
it('returns categories sorted by position in ascending order', () => {
const stateWithPositions = {
...state,
categories: {
...state.categories,
byId: {
1: { id: 1, name: 'Category 1', position: 3 },
2: { id: 2, name: 'Category 2', position: 1 },
3: { id: 3, name: 'Category 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
categoryById: getters.categoryById(stateWithPositions),
};
const result = getters.allCategoriesSortedByPosition(
stateWithPositions,
boundGetters
);
expect(result.map(c => c.id)).toEqual([2, 3, 1]);
expect(result.map(c => c.position)).toEqual([1, 2, 3]);
});
it('places categories with null position at the end', () => {
const stateWithNullPositions = {
...state,
categories: {
...state.categories,
byId: {
1: { id: 1, name: 'Category 1', position: 1 },
2: { id: 2, name: 'Category 2', position: null },
3: { id: 3, name: 'Category 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
categoryById: getters.categoryById(stateWithNullPositions),
};
const result = getters.allCategoriesSortedByPosition(
stateWithNullPositions,
boundGetters
);
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
});
it('handles categories with undefined position', () => {
const stateWithUndefinedPositions = {
...state,
categories: {
...state.categories,
byId: {
1: { id: 1, name: 'Category 1', position: 1 },
2: { id: 2, name: 'Category 2' },
3: { id: 3, name: 'Category 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
categoryById: getters.categoryById(stateWithUndefinedPositions),
};
const result = getters.allCategoriesSortedByPosition(
stateWithUndefinedPositions,
boundGetters
);
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
});
});
});

View File

@@ -4,7 +4,7 @@ import { categoriesState, categoriesPayload } from './fixtures';
describe('#mutations', () => {
let state = {};
beforeEach(() => {
state = categoriesState;
state = JSON.parse(JSON.stringify(categoriesState));
});
describe('#SET_UI_FLAG', () => {
@@ -53,9 +53,9 @@ describe('#mutations', () => {
mutations[types.ADD_CATEGORY_ID](state, 3);
expect(state.categories.allIds).toEqual([1, 2, 3]);
});
it('Does not invalid category with empty data passed', () => {
it('pushes the given id to allIds', () => {
mutations[types.ADD_CATEGORY_ID](state, {});
expect(state).toEqual(categoriesState);
expect(state.categories.allIds).toEqual([1, 2, {}]);
});
});
@@ -98,4 +98,40 @@ describe('#mutations', () => {
// expect(state.categories.uiFlags).toEqual({});
// });
// });
describe('#SET_CATEGORY_POSITIONS', () => {
it('updates positions for categories in the store', () => {
const positionsHash = { 1: 1, 2: 2 };
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
expect(state.categories.byId[1].position).toEqual(1);
expect(state.categories.byId[2].position).toEqual(2);
});
it('does not update categories that are not in the store', () => {
const positionsHash = { 999: 5 };
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
expect(state.categories.byId[999]).toBeUndefined();
});
it('preserves other category properties when updating position', () => {
const originalName = state.categories.byId[1].name;
const positionsHash = { 1: 3 };
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
expect(state.categories.byId[1].position).toEqual(3);
expect(state.categories.byId[1].name).toEqual(originalName);
});
it('re-sorts allIds by position after update', () => {
state.categories.byId[1].position = 1;
state.categories.byId[2].position = 2;
state.categories.allIds = [1, 2];
mutations[types.SET_CATEGORY_POSITIONS](state, { 1: 3, 2: 1 });
expect(state.categories.allIds).toEqual([2, 1]);
});
});
});

View File

@@ -290,6 +290,7 @@ export default {
REMOVE_ARTICLE: 'REMOVE_ARTICLE',
REMOVE_ARTICLE_ID: 'REMOVE_ARTICLE_ID',
SET_UI_FLAG: 'SET_UI_FLAG',
SET_ARTICLE_POSITIONS: 'SET_ARTICLE_POSITIONS',
// Help Center -- Categories
ADD_CATEGORY: 'ADD_CATEGORY',
@@ -301,6 +302,7 @@ export default {
UPDATE_CATEGORY: 'UPDATE_CATEGORY',
REMOVE_CATEGORY: 'REMOVE_CATEGORY',
REMOVE_CATEGORY_ID: 'REMOVE_CATEGORY_ID',
SET_CATEGORY_POSITIONS: 'SET_CATEGORY_POSITIONS',
// Agent Bots
SET_AGENT_BOT_UI_FLAG: 'SET_AGENT_BOT_UI_FLAG',

View File

@@ -132,11 +132,13 @@ 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)
def self.update_positions(portal:, positions_hash:)
return if positions_hash.blank?
transaction do
positions_hash.each do |article_id, new_position|
portal.articles.find(article_id).update!(position: new_position)
end
end
end

View File

@@ -73,6 +73,16 @@ class Category < ApplicationRecord
params[:page] || 1
end
def self.update_positions(portal:, positions_hash:)
return if positions_hash.blank?
transaction do
positions_hash.each do |category_id, new_position|
portal.categories.find(category_id).update!(position: new_position)
end
end
end
private
def ensure_account_id

View File

@@ -27,6 +27,8 @@
class Portal < ApplicationRecord
include Rails.application.routes.url_helpers
DEFAULT_COLOR = '#1f93ff'.freeze
belongs_to :account
has_many :categories, dependent: :destroy_async
has_many :folders, through: :categories
@@ -62,6 +64,10 @@ class Portal < ApplicationRecord
config['default_locale'] || 'en'
end
def color
self[:color].presence || DEFAULT_COLOR
end
private
def config_json_format

View File

@@ -22,6 +22,10 @@ class CategoryPolicy < ApplicationPolicy
def destroy?
@account_user.administrator?
end
def reorder?
@account_user.administrator?
end
end
CategoryPolicy.prepend_mod_with('CategoryPolicy')

View File

@@ -348,7 +348,9 @@ Rails.application.routes.draw do
post :send_instructions
get :ssl_status
end
resources :categories
resources :categories do
post :reorder, on: :collection
end
resources :articles do
post :reorder, on: :collection
end

View File

@@ -22,4 +22,8 @@ module Enterprise::CategoryPolicy
def destroy?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def reorder?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
end

View File

@@ -192,6 +192,38 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
end
end
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/articles/reorder' do
let!(:article_2) do
create(:article, category: category, portal: portal, account_id: account.id, author_id: agent.id, position: 20)
end
let(:positions_hash) do
{
article.id => 20,
article_2.id => 10
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/reorder",
params: { positions_hash: positions_hash }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'reorders articles' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/reorder",
params: { positions_hash: positions_hash },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(article.reload.position).to eq(20)
expect(article_2.reload.position).to eq(10)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/portals/{portal.slug}/articles' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do

View File

@@ -237,6 +237,47 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
end
end
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/categories/reorder' do
let(:positions_hash) do
{
category.id => 40,
category_to_associate.id => 10,
related_category_1.id => 30,
related_category_2.id => 20
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/reorder",
params: { positions_hash: positions_hash }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'reorders categories' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/reorder",
params: { positions_hash: positions_hash },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(category.reload.position).to eq(40)
expect(category_to_associate.reload.position).to eq(10)
expect(related_category_1.reload.position).to eq(30)
expect(related_category_2.reload.position).to eq(20)
end
it 'returns not found when portal does not exist' do
post "/api/v1/accounts/#{account.id}/portals/invalid-portal-slug/categories/reorder",
params: { positions_hash: positions_hash },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:not_found)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/portals/{portal.slug}/categories' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do

View File

@@ -100,4 +100,18 @@ RSpec.describe 'Enterprise Articles API', type: :request do
end
end
end
describe 'POST /api/v1/accounts/:account_id/portals/:portal_slug/articles/reorder' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/reorder",
params: { positions_hash: { article.id => 20 } },
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(article.reload.position).to eq(20)
end
end
end
end

View File

@@ -108,4 +108,27 @@ RSpec.describe 'Enterprise Categories API', type: :request do
end
end
end
describe 'POST /api/v1/accounts/:account_id/portals/:portal_slug/categories/reorder' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/reorder",
params: { positions_hash: { category.id => 20 } },
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(category.reload.position).to eq(20)
end
it 'returns not found for invalid portal slug' do
post "/api/v1/accounts/#{account.id}/portals/invalid-portal-slug/categories/reorder",
params: { positions_hash: { category.id => 20 } },
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end