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

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