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 00000000000..5bfd390d202 --- /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 = { + 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 b660b070df1..b9be34f3762 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');