feat(help-center): enable drag-and-drop category reordering (#13706)
This commit is contained in:
@@ -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 '';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user