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');

Reply via email to