From f24e7eb231daa6aa6d44fd486688cc81739590fb Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Wed, 4 Mar 2026 21:28:47 +0530
Subject: [PATCH 1/3] fix: Missing required prop warning in account settings
page (#13711)
# Pull Request Template
## Description
This PR fixes the console warning in development: `[Vue warn]: Missing
required prop: "name"` on the account settings page.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Screenshot**
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../dashboard/routes/dashboard/settings/account/Index.vue | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
index 2ec298f98..5be704c24 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
@@ -160,6 +160,7 @@ export default {
@submit.prevent="updateAccount"
>
Date: Thu, 5 Mar 2026 07:26:55 +0530
Subject: [PATCH 2/3] chore(dev): add cleanup flow to force_run in Makefile
(#13093)
## Summary
Improve local dev restart reliability by enhancing `make force_run` to
run cleanup before starting Overmind.
## How To Reproduce
During local development, if `make run` is interrupted (for example with
Ctrl-C), stale state can remain (`.overmind.sock`, PID files, and
processes on ports `3000`/`3036`), which can block or complicate the
next restart.
## Changes
Updated `force_run` in `Makefile` to:
- print cleanup start/end messages
- kill processes on ports `3036` and `3000` (best-effort)
- remove `.overmind.sock`
- remove `tmp/pids/*.pid`
- then start `Procfile.dev` via Overmind
No other files are changed in this PR.
## Testing
- Verified branch diff against `develop` only touches `Makefile`.
- Ran `make -n force_run` to validate the command sequence and startup
flow.
---------
Co-authored-by: Sojan Jose
---
Makefile | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/Makefile b/Makefile
index 552ebe659..684adacc6 100644
--- a/Makefile
+++ b/Makefile
@@ -40,8 +40,12 @@ run:
fi
force_run:
- rm -f ./.overmind.sock
- rm -f tmp/pids/*.pid
+ @echo "Cleaning up Overmind processes..."
+ @lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
+ @lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
+ @rm -f ./.overmind.sock
+ @rm -f tmp/pids/*.pid
+ @echo "Cleanup complete"
overmind start -f Procfile.dev
force_run_tunnel:
From 42a244369d4bd9c13de96ca2ac87ec0dd8e599a2 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Wed, 4 Mar 2026 23:23:38 -0800
Subject: [PATCH 3/3] feat(help-center): enable drag-and-drop category
reordering (#13706)
---
.../api/v1/accounts/articles_controller.rb | 2 +-
.../api/v1/accounts/categories_controller.rb | 9 ++-
.../dashboard/api/helpCenter/categories.js | 6 ++
.../api/specs/helpCenter/categories.spec.js | 1 +
.../Pages/ArticlePage/ArticleList.vue | 20 +++--
.../Pages/CategoryPage/CategoriesPage.vue | 12 +++
.../Pages/CategoryPage/CategoryList.vue | 76 ++++++++++++++----
.../dashboard/i18n/locale/en/helpCenter.json | 12 ++-
.../pages/PortalsArticlesIndexPage.vue | 10 ++-
.../pages/PortalsCategoriesIndexPage.vue | 2 +-
.../modules/helpCenterArticles/actions.js | 17 +++-
.../modules/helpCenterArticles/getters.js | 10 +++
.../modules/helpCenterArticles/mutations.js | 12 +++
.../helpCenterArticles/specs/action.spec.js | 59 ++++++++++++++
.../helpCenterArticles/specs/getters.spec.js | 78 +++++++++++++++++++
.../helpCenterArticles/specs/mutation.spec.js | 57 +++++++++++++-
.../modules/helpCenterCategories/actions.js | 19 +++++
.../modules/helpCenterCategories/getters.js | 10 +++
.../modules/helpCenterCategories/mutations.js | 12 +++
.../specs/actions.spec.js | 59 ++++++++++++++
.../specs/getters.spec.js | 78 +++++++++++++++++++
.../specs/mutations.spec.js | 42 +++++++++-
.../dashboard/store/mutation-types.js | 2 +
app/models/article.rb | 12 +--
app/models/category.rb | 10 +++
app/models/portal.rb | 6 ++
app/policies/category_policy.rb | 4 +
config/routes.rb | 4 +-
.../policies/enterprise/category_policy.rb | 4 +
.../v1/accounts/articles_controller_spec.rb | 32 ++++++++
.../v1/accounts/categories_controller_spec.rb | 41 ++++++++++
.../v1/accounts/articles_controller_spec.rb | 14 ++++
.../v1/accounts/categories_controller_spec.rb | 23 ++++++
33 files changed, 708 insertions(+), 47 deletions(-)
diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb
index 8a6fd61f8..5e1609b64 100644
--- a/app/controllers/api/v1/accounts/articles_controller.rb
+++ b/app/controllers/api/v1/accounts/articles_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/accounts/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb
index 834b19ed9..686ffaeec 100644
--- a/app/controllers/api/v1/accounts/categories_controller.rb
+++ b/app/controllers/api/v1/accounts/categories_controller.rb
@@ -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
diff --git a/app/javascript/dashboard/api/helpCenter/categories.js b/app/javascript/dashboard/api/helpCenter/categories.js
index 01658497e..eda54aadb 100644
--- a/app/javascript/dashboard/api/helpCenter/categories.js
+++ b/app/javascript/dashboard/api/helpCenter/categories.js
@@ -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();
diff --git a/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js b/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js
index 2c56f4e00..febf6f7a1 100644
--- a/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js
+++ b/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js
@@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
expect(categoriesAPI).toHaveProperty('create');
expect(categoriesAPI).toHaveProperty('update');
expect(categoriesAPI).toHaveProperty('delete');
+ expect(categoriesAPI).toHaveProperty('reorder');
});
});
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue
index 756857fa0..cc7c97000 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue
@@ -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);
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue
index 12acd1bd8..1f4e4b347 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue
@@ -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'));
+ }
+};
@@ -122,6 +133,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
:categories="categories"
@click="openCategoryArticles"
@action="handleAction"
+ @reorder="reorderCategories"
/>
+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 }
+);
-
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index b47af9181..69a72f163 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -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"
}
},
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
index f7a109e58..7d636de70 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
@@ -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,
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsCategoriesIndexPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsCategoriesIndexPage.vue
index ca9514336..2e0b7540c 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsCategoriesIndexPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsCategoriesIndexPage.vue
@@ -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');
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
index 7d26103ac..143192a3c 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
@@ -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 '';
},
};
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/getters.js b/app/javascript/dashboard/store/modules/helpCenterArticles/getters.js
index 4087ca8e9..ecebbd58f 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/getters.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/getters.js
@@ -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 => {
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/mutations.js b/app/javascript/dashboard/store/modules/helpCenterArticles/mutations.js
index ad90e720f..7d18d28b0 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/mutations.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/mutations.js
@@ -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]) {
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
index 99b39cb55..064345694 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
@@ -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,
+ });
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/getters.spec.js b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/getters.spec.js
index b2d462b97..24f4f5137 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/getters.spec.js
@@ -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]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/mutation.spec.js b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/mutation.spec.js
index 8e04f31de..128b6f4f5 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/mutation.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/mutation.spec.js
@@ -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');
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/helpCenterCategories/actions.js b/app/javascript/dashboard/store/modules/helpCenterCategories/actions.js
index 2d212ded3..1d9152390 100644
--- a/app/javascript/dashboard/store/modules/helpCenterCategories/actions.js
+++ b/app/javascript/dashboard/store/modules/helpCenterCategories/actions.js
@@ -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;
+ }
+ },
};
diff --git a/app/javascript/dashboard/store/modules/helpCenterCategories/getters.js b/app/javascript/dashboard/store/modules/helpCenterCategories/getters.js
index 2561180a8..a34dae881 100644
--- a/app/javascript/dashboard/store/modules/helpCenterCategories/getters.js
+++ b/app/javascript/dashboard/store/modules/helpCenterCategories/getters.js
@@ -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 => {
diff --git a/app/javascript/dashboard/store/modules/helpCenterCategories/mutations.js b/app/javascript/dashboard/store/modules/helpCenterCategories/mutations.js
index 13349ad65..8865c44c9 100644
--- a/app/javascript/dashboard/store/modules/helpCenterCategories/mutations.js
+++ b/app/javascript/dashboard/store/modules/helpCenterCategories/mutations.js
@@ -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;
diff --git a/app/javascript/dashboard/store/modules/helpCenterCategories/specs/actions.spec.js b/app/javascript/dashboard/store/modules/helpCenterCategories/specs/actions.spec.js
index 528f2ebb5..7de06e78e 100644
--- a/app/javascript/dashboard/store/modules/helpCenterCategories/specs/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterCategories/specs/actions.spec.js
@@ -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 }
+ );
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/helpCenterCategories/specs/getters.spec.js b/app/javascript/dashboard/store/modules/helpCenterCategories/specs/getters.spec.js
index 2d094a01c..21a10daf6 100644
--- a/app/javascript/dashboard/store/modules/helpCenterCategories/specs/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterCategories/specs/getters.spec.js
@@ -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]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/helpCenterCategories/specs/mutations.spec.js b/app/javascript/dashboard/store/modules/helpCenterCategories/specs/mutations.spec.js
index d7ea4623e..988b04590 100644
--- a/app/javascript/dashboard/store/modules/helpCenterCategories/specs/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterCategories/specs/mutations.spec.js
@@ -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]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 989e08916..dcc64de8c 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -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',
diff --git a/app/models/article.rb b/app/models/article.rb
index b03c9ecde..cb6215157 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -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
diff --git a/app/models/category.rb b/app/models/category.rb
index 10cfe3e86..7c6cab3d6 100644
--- a/app/models/category.rb
+++ b/app/models/category.rb
@@ -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
diff --git a/app/models/portal.rb b/app/models/portal.rb
index c1d98a301..67b5427e5 100644
--- a/app/models/portal.rb
+++ b/app/models/portal.rb
@@ -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
diff --git a/app/policies/category_policy.rb b/app/policies/category_policy.rb
index 104022595..8b6adab42 100644
--- a/app/policies/category_policy.rb
+++ b/app/policies/category_policy.rb
@@ -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')
diff --git a/config/routes.rb b/config/routes.rb
index 1efd9f526..0992d21a2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/enterprise/app/policies/enterprise/category_policy.rb b/enterprise/app/policies/enterprise/category_policy.rb
index b58a3806d..b4fb3634e 100644
--- a/enterprise/app/policies/enterprise/category_policy.rb
+++ b/enterprise/app/policies/enterprise/category_policy.rb
@@ -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
diff --git a/spec/controllers/api/v1/accounts/articles_controller_spec.rb b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
index e74be76e2..bf3ae2aa9 100644
--- a/spec/controllers/api/v1/accounts/articles_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
@@ -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
diff --git a/spec/controllers/api/v1/accounts/categories_controller_spec.rb b/spec/controllers/api/v1/accounts/categories_controller_spec.rb
index 48cc001e3..d87ffa858 100644
--- a/spec/controllers/api/v1/accounts/categories_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/categories_controller_spec.rb
@@ -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
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb
index 43d6fc02d..33d5eedf0 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb
@@ -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
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb
index f83542743..5bb45c436 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb
@@ -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