This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 10c20d951 fix(config-ui): group connections by name, not by list
position (#9130)
10c20d951 is described below
commit 10c20d95167bde563245de8cef7d3451a844f676
Author: Ullrich Schäfer <[email protected]>
AuthorDate: Fri Sep 11 15:55:02 2026 +0200
fix(config-ui): group connections by name, not by list position (#9130)
The A-N and O-Z headings were produced by cutting the plugin list at the
first plugin id starting with a letter from o to z. That agrees with the
headings only while each config's `sort` value runs alphabetically, and it
stopped doing so as plugins were appended in the order they were added:
Asana, Kiro, Linear and incident.io are all listed after Opsgenie, so all
four appeared under O-Z.
Group by the first letter of the displayed name instead, which is what a
reader is matching against, and extract the decision into a pure function
with tests.
Generated-by: Claude Code (Claude Opus 5)
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../src/__tests__/connections-grouping.test.ts | 66 ++++++++++++++++++++++
config-ui/src/routes/connection/connections.tsx | 27 +++++----
2 files changed, 81 insertions(+), 12 deletions(-)
diff --git a/config-ui/src/__tests__/connections-grouping.test.ts
b/config-ui/src/__tests__/connections-grouping.test.ts
new file mode 100644
index 000000000..5bfd390d2
--- /dev/null
+++ b/config-ui/src/__tests__/connections-grouping.test.ts
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import { splitPluginsByInitial } from '@/routes/connection/connections';
+
+// The plugin list arrives ordered by each config's `sort` value, which is not
+// alphabetical: plugins have been appended in the order they were added.
+const byName: Record<string, string> = {
+ argocd: 'ArgoCD',
+ clickup: 'ClickUp',
+ opsgenie: 'Opsgenie',
+ pagerduty: 'PagerDuty',
+ asana: 'Asana',
+ linear: 'Linear',
+ zentao: 'ZenTao',
+ incidentio: 'incident.io',
+ azuredevops: 'Azure DevOps',
+};
+const nameOf = (plugin: string) => byName[plugin] ?? plugin;
+
+describe('splitPluginsByInitial', () => {
+ it('groups by the displayed name, not by list position', () => {
+ const [an, oz] = splitPluginsByInitial(
+ ['argocd', 'clickup', 'opsgenie', 'pagerduty', 'asana', 'linear',
'zentao'],
+ nameOf,
+ );
+ // asana and linear follow opsgenie in the list, and used to be filed O-Z.
+ expect(an).toEqual(['argocd', 'clickup', 'asana', 'linear']);
+ expect(oz).toEqual(['opsgenie', 'pagerduty', 'zentao']);
+ });
+
+ it('keeps every plugin, wherever it sits in the list', () => {
+ const plugins = Object.keys(byName);
+ const [an, oz] = splitPluginsByInitial(plugins, nameOf);
+ expect([...an, ...oz].sort()).toEqual([...plugins].sort());
+ });
+
+ it('compares case-insensitively, so a lowercase name still groups
correctly', () => {
+ const [an, oz] = splitPluginsByInitial(['incidentio', 'zentao'], nameOf);
+ expect(an).toEqual(['incidentio']);
+ expect(oz).toEqual(['zentao']);
+ });
+
+ it('falls back to the plugin id when a config has no name', () => {
+ const [an, oz] = splitPluginsByInitial(['unknown-plugin', 'another'], (p)
=> p);
+ expect(an).toEqual(['another']);
+ expect(oz).toEqual(['unknown-plugin']);
+ });
+});
diff --git a/config-ui/src/routes/connection/connections.tsx
b/config-ui/src/routes/connection/connections.tsx
index b660b070d..b9be34f37 100644
--- a/config-ui/src/routes/connection/connections.tsx
+++ b/config-ui/src/routes/connection/connections.tsx
@@ -29,6 +29,16 @@ import * as S from './styled';
const SORT_START_WITH = ['o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',
'y', 'z'];
+// Group by the displayed name's first letter. The headings used to be produced
+// by cutting the list at the first plugin id starting with o-z, which only
+// agrees with the headings while `sort` happens to run alphabetically — it
+// stopped doing so as plugins were appended in the order they were added, so
+// Asana, Kiro, Linear and incident.io all showed up under O-Z.
+export const splitPluginsByInitial = (plugins: string[], nameOf: (plugin:
string) => string) => {
+ const isOZ = (plugin: string) => SORT_START_WITH.includes((nameOf(plugin)[0]
?? '').toLowerCase());
+ return [plugins.filter((plugin) => !isOZ(plugin)), plugins.filter(isOZ)];
+};
+
export const Connections = () => {
const [type, setType] = useState<'list' | 'form'>();
const [plugin, setPlugin] = useState('');
@@ -46,18 +56,11 @@ export const Connections = () => {
const webhooks = useAppSelector(selectWebhooks);
const filterWebhookPlugins = plugins.filter((p) => p !== 'webhook');
- const index = filterWebhookPlugins.findIndex((p) =>
SORT_START_WITH.includes(p[0]));
-
- const [firstPlugins, secondPlugins] = useMemo(() => {
- if (index > 0) {
- // Split into A-N / O-Z at the first O-Z plugin. Must be a two-way
- // slice — `chunk(list, index)` produces equal-size groups and the
- // destructure keeps only the first two, silently dropping any plugins
- // in the tail once the list exceeds 2*index.
- return [filterWebhookPlugins.slice(0, index),
filterWebhookPlugins.slice(index)];
- }
- return [filterWebhookPlugins, []];
- }, [index]);
+
+ const [firstPlugins, secondPlugins] = useMemo(
+ () => splitPluginsByInitial(filterWebhookPlugins, (plugin) =>
getPluginConfig(plugin)?.name ?? plugin),
+ [filterWebhookPlugins],
+ );
const handleShowListDialog = (plugin: string) => {
setType('list');