From 259560cad1b176ac1103d1cfc8f294bf580eb514 Mon Sep 17 00:00:00 2001
From: Gustavo Saiani <gus@metabase.com>
Date: Sat, 18 Feb 2023 14:15:33 -0300
Subject: [PATCH] Fix GroupSelect showing groups section title even if section
 is empty (#28348)

---
 .../GroupSelect.tsx}                          |  63 ++++++++---
 .../GroupSelect/GroupSelect.unit.spec.tsx     |  47 ++++++++
 .../people/components/GroupSelect/index.tsx   |   1 +
 .../AddMappingRow/AddMappingRow.tsx           |   2 +-
 .../DeleteGroupMappingModal.tsx               |   5 +-
 .../GroupMappingsWidget.unit.spec.tsx         |   9 +-
 .../GroupSelect/GroupSelect.tsx               | 107 +++++++++++++++---
 .../MappingRow/MappingRow.tsx                 |   7 +-
 .../widgets/GroupMappingsWidget => }/types.ts |   0
 .../admin/settings/sso/ldap.cy.spec.js        |   2 +-
 10 files changed, 199 insertions(+), 44 deletions(-)
 rename frontend/src/metabase/admin/people/components/{GroupSelect.jsx => GroupSelect/GroupSelect.tsx} (55%)
 create mode 100644 frontend/src/metabase/admin/people/components/GroupSelect/GroupSelect.unit.spec.tsx
 create mode 100644 frontend/src/metabase/admin/people/components/GroupSelect/index.tsx
 rename frontend/src/metabase/admin/{settings/components/widgets/GroupMappingsWidget => }/types.ts (100%)

diff --git a/frontend/src/metabase/admin/people/components/GroupSelect.jsx b/frontend/src/metabase/admin/people/components/GroupSelect/GroupSelect.tsx
similarity index 55%
rename from frontend/src/metabase/admin/people/components/GroupSelect.jsx
rename to frontend/src/metabase/admin/people/components/GroupSelect/GroupSelect.tsx
index 9972506be0e..76d53aa280f 100644
--- a/frontend/src/metabase/admin/people/components/GroupSelect.jsx
+++ b/frontend/src/metabase/admin/people/components/GroupSelect/GroupSelect.tsx
@@ -1,7 +1,7 @@
-/* eslint-disable react/prop-types */
 import React from "react";
 import { t } from "ttag";
 
+import { isNotNull } from "metabase/core/utils/types";
 import Icon from "metabase/components/Icon";
 import Select from "metabase/core/components/Select";
 import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
@@ -13,7 +13,39 @@ import {
   getGroupColor,
   getGroupNameLocalized,
 } from "metabase/lib/groups";
-import GroupSummary from "./GroupSummary";
+import { GroupIds, UserGroupType, UserGroupsType } from "metabase/admin/types";
+import GroupSummary from "../GroupSummary";
+
+type GroupSelectProps = {
+  groups: UserGroupsType;
+  selectedGroupIds: GroupIds;
+  onGroupChange: (group: UserGroupType, selected: boolean) => void;
+  isCurrentUser?: boolean;
+  emptyListMessage?: string;
+};
+
+function getSections(groups: UserGroupsType) {
+  const adminGroup = groups.find(isAdminGroup);
+  const defaultGroup = groups.find(isDefaultGroup);
+  const topGroups = [defaultGroup, adminGroup].filter(g => g != null);
+  const groupsExceptDefaultAndAdmin = groups.filter(
+    g => !isAdminGroup(g) && !isDefaultGroup(g),
+  );
+
+  if (topGroups.length === 0) {
+    return [{ items: groupsExceptDefaultAndAdmin }];
+  }
+
+  return [
+    { items: topGroups },
+    groupsExceptDefaultAndAdmin.length > 0
+      ? {
+          items: groupsExceptDefaultAndAdmin as any,
+          name: t`Groups`,
+        }
+      : null,
+  ].filter(isNotNull);
+}
 
 export const GroupSelect = ({
   groups,
@@ -21,7 +53,7 @@ export const GroupSelect = ({
   onGroupChange,
   isCurrentUser = false,
   emptyListMessage = t`No groups`,
-}) => {
+}: GroupSelectProps) => {
   const triggerElement = (
     <div className="flex align-center">
       <span className="mr1 text-medium">
@@ -38,35 +70,32 @@ export const GroupSelect = ({
       </PopoverWithTrigger>
     );
   }
-  const other = groups.filter(g => !isAdminGroup(g) && !isDefaultGroup(g));
-  const adminGroup = groups.find(isAdminGroup);
-  const defaultGroup = groups.find(isDefaultGroup);
-  const topGroups = [defaultGroup, adminGroup].filter(g => g != null);
+
+  const sections = getSections(groups);
 
   return (
     <Select
       triggerElement={triggerElement}
-      onChange={({ target: { value } }) => {
+      onChange={({ target: { value } }: { target: { value: any } }) => {
         groups
           .filter(
             // find the differing groups between the new `value` on previous `selectedGroupIds`
             group =>
-              selectedGroupIds.includes(group.id) ^ value.includes(group.id),
+              (selectedGroupIds.includes(group.id) as any) ^
+              value.includes(group.id),
           )
           .forEach(group => onGroupChange(group, value.includes(group.id)));
       }}
-      optionDisabledFn={group =>
+      optionDisabledFn={(group: UserGroupType) =>
         (isAdminGroup(group) && isCurrentUser) || !canEditMembership(group)
       }
-      optionValueFn={group => group.id}
+      optionValueFn={(group: UserGroupType) => group.id}
       optionNameFn={getGroupNameLocalized}
-      optionStylesFn={group => ({ color: getGroupColor(group) })}
+      optionStylesFn={(group: UserGroupType) => ({
+        color: getGroupColor(group),
+      })}
       value={selectedGroupIds}
-      sections={
-        topGroups.length > 0
-          ? [{ items: topGroups }, { items: other, name: t`Groups` }]
-          : [{ items: other }]
-      }
+      sections={sections}
       multiple
     />
   );
diff --git a/frontend/src/metabase/admin/people/components/GroupSelect/GroupSelect.unit.spec.tsx b/frontend/src/metabase/admin/people/components/GroupSelect/GroupSelect.unit.spec.tsx
new file mode 100644
index 00000000000..964207b309c
--- /dev/null
+++ b/frontend/src/metabase/admin/people/components/GroupSelect/GroupSelect.unit.spec.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import GroupSelect from "./GroupSelect";
+
+const adminGroup = { id: 2, name: "Administrators", member_count: 1 };
+
+const setup = ({ groups = [adminGroup], selectedGroupIds = [] } = {}) => {
+  const onGroupChangeSpy = jest.fn();
+
+  render(
+    <GroupSelect
+      groups={groups}
+      selectedGroupIds={selectedGroupIds}
+      onGroupChange={onGroupChangeSpy}
+    />,
+  );
+
+  return { onGroupChangeSpy };
+};
+
+describe("GroupSelect", () => {
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  describe("when only the Administrators group is passed", () => {
+    it("should not show a Groups section (metabase#27728)", () => {
+      setup();
+
+      userEvent.click(screen.getByText("Default"));
+
+      expect(screen.queryByText("Groups")).not.toBeInTheDocument();
+    });
+
+    it("should allow you to select the Administrators group", () => {
+      const { onGroupChangeSpy } = setup();
+
+      userEvent.click(screen.getByText("Default"));
+      userEvent.click(screen.getByText("Administrators"));
+
+      expect(onGroupChangeSpy).toHaveBeenCalledTimes(1);
+      expect(onGroupChangeSpy).toHaveBeenCalledWith(adminGroup, true);
+    });
+  });
+});
diff --git a/frontend/src/metabase/admin/people/components/GroupSelect/index.tsx b/frontend/src/metabase/admin/people/components/GroupSelect/index.tsx
new file mode 100644
index 00000000000..f30bebc756f
--- /dev/null
+++ b/frontend/src/metabase/admin/people/components/GroupSelect/index.tsx
@@ -0,0 +1 @@
+export { default } from "./GroupSelect";
diff --git a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/AddMappingRow/AddMappingRow.tsx b/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/AddMappingRow/AddMappingRow.tsx
index 03484585c00..3073b393a08 100644
--- a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/AddMappingRow/AddMappingRow.tsx
+++ b/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/AddMappingRow/AddMappingRow.tsx
@@ -4,7 +4,7 @@ import { t } from "ttag";
 
 import Button from "metabase/core/components/Button";
 
-import type { MappingsType } from "../types";
+import type { MappingsType } from "metabase/admin/types";
 
 type AddMappingRowProps = {
   mappings: MappingsType;
diff --git a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/DeleteGroupMappingModal/DeleteGroupMappingModal.tsx b/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/DeleteGroupMappingModal/DeleteGroupMappingModal.tsx
index a404b35b753..c3f2dedf83b 100644
--- a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/DeleteGroupMappingModal/DeleteGroupMappingModal.tsx
+++ b/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/DeleteGroupMappingModal/DeleteGroupMappingModal.tsx
@@ -6,7 +6,10 @@ import { ModalFooter } from "metabase/components/ModalContent";
 import Radio from "metabase/core/components/Radio";
 import Button from "metabase/core/components/Button";
 
-import type { DeleteMappingModalValueType, GroupIds } from "../types";
+import type {
+  DeleteMappingModalValueType,
+  GroupIds,
+} from "metabase/admin/types";
 import {
   ModalHeader,
   ModalSubtitle,
diff --git a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/GroupMappingsWidget.unit.spec.tsx b/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/GroupMappingsWidget.unit.spec.tsx
index 406644c429a..e5bf152fb3f 100644
--- a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/GroupMappingsWidget.unit.spec.tsx
+++ b/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/GroupMappingsWidget.unit.spec.tsx
@@ -63,9 +63,12 @@ describe("GroupMappingsWidget", () => {
       // Confirm remove
       userEvent.click(screen.getByText("Yes"));
 
-      await waitFor(() => {
-        expect(onChangeSettingSpy).toHaveBeenCalledTimes(1);
-      });
+      await waitFor(
+        () => {
+          expect(onChangeSettingSpy).toHaveBeenCalledTimes(1);
+        },
+        { timeout: 10000 },
+      );
     });
   });
 
diff --git a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/GroupSelect/GroupSelect.tsx b/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/GroupSelect/GroupSelect.tsx
index 7015d9bc508..f6df0694730 100644
--- a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/GroupSelect/GroupSelect.tsx
+++ b/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/GroupSelect/GroupSelect.tsx
@@ -1,31 +1,104 @@
 import React from "react";
 import { t } from "ttag";
 
-import LoadingSpinner from "metabase/components/LoadingSpinner";
-import GroupSelect from "metabase/admin/people/components/GroupSelect";
+import { isNotNull } from "metabase/core/utils/types";
+import Icon from "metabase/components/Icon";
+import Select from "metabase/core/components/Select";
+import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
 
-import type { GroupIds, UserGroupsType } from "../types";
+import {
+  isDefaultGroup,
+  isAdminGroup,
+  canEditMembership,
+  getGroupColor,
+  getGroupNameLocalized,
+} from "metabase/lib/groups";
+import { GroupIds, UserGroupType, UserGroupsType } from "metabase/admin/types";
+import GroupSummary from "metabase/admin/people/components/GroupSummary";
 
-type Props = {
+type GroupSelectProps = {
   groups: UserGroupsType;
   selectedGroupIds: GroupIds;
-  onGroupChange: () => void;
+  onGroupChange: (group: UserGroupType, selected: boolean) => void;
+  isCurrentUser?: boolean;
+  emptyListMessage?: string;
 };
 
-const GroupMappingsWidgetGroupSelect = ({
+function getSections(groups: UserGroupsType) {
+  const adminGroup = groups.find(isAdminGroup);
+  const defaultGroup = groups.find(isDefaultGroup);
+  const topGroups = [defaultGroup, adminGroup].filter(g => g != null);
+  const groupsExceptDefaultAndAdmin = groups.filter(
+    g => !isAdminGroup(g) && !isDefaultGroup(g),
+  );
+
+  if (topGroups.length === 0) {
+    return [{ items: groupsExceptDefaultAndAdmin }];
+  }
+
+  return [
+    { items: topGroups },
+    groupsExceptDefaultAndAdmin.length > 0
+      ? {
+          items: groupsExceptDefaultAndAdmin as any,
+          name: t`Groups`,
+        }
+      : null,
+  ].filter(isNotNull);
+}
+
+export const GroupSelect = ({
   groups,
-  selectedGroupIds,
+  selectedGroupIds = [],
   onGroupChange,
-}: Props) =>
-  groups ? (
-    <GroupSelect
-      groups={groups}
-      selectedGroupIds={selectedGroupIds}
-      onGroupChange={onGroupChange}
-      emptyListMessage={t`No mappable groups`}
+  isCurrentUser = false,
+  emptyListMessage = t`No groups`,
+}: GroupSelectProps) => {
+  const triggerElement = (
+    <div className="flex align-center">
+      <span className="mr1 text-medium">
+        <GroupSummary groups={groups} selectedGroupIds={selectedGroupIds} />
+      </span>
+      <Icon className="text-light" name="chevrondown" size={10} />
+    </div>
+  );
+
+  if (groups.length === 0) {
+    return (
+      <PopoverWithTrigger triggerElement={triggerElement}>
+        <span className="p1">{emptyListMessage}</span>
+      </PopoverWithTrigger>
+    );
+  }
+
+  const sections = getSections(groups);
+
+  return (
+    <Select
+      triggerElement={triggerElement}
+      onChange={({ target: { value } }: { target: { value: any } }) => {
+        groups
+          .filter(
+            // find the differing groups between the new `value` on previous `selectedGroupIds`
+            group =>
+              (selectedGroupIds.includes(group.id) as any) ^
+              value.includes(group.id),
+          )
+          .forEach(group => onGroupChange(group, value.includes(group.id)));
+      }}
+      optionDisabledFn={(group: UserGroupType) =>
+        (isAdminGroup(group) && isCurrentUser) || !canEditMembership(group)
+      }
+      optionValueFn={(group: UserGroupType) => group.id}
+      optionNameFn={getGroupNameLocalized}
+      optionStylesFn={(group: UserGroupType) => ({
+        color: getGroupColor(group),
+      })}
+      value={selectedGroupIds}
+      sections={sections}
+      multiple
     />
-  ) : (
-    <LoadingSpinner />
   );
+};
 
-export default GroupMappingsWidgetGroupSelect;
+export default GroupSelect;
diff --git a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/MappingRow/MappingRow.tsx b/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/MappingRow/MappingRow.tsx
index c7a44d99e9e..19dd9c608cb 100644
--- a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/MappingRow/MappingRow.tsx
+++ b/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/MappingRow/MappingRow.tsx
@@ -8,14 +8,13 @@ import Tooltip from "metabase/core/components/Tooltip";
 import Icon from "metabase/components/Icon";
 import Confirm from "metabase/components/Confirm";
 
-import Selectbox from "../GroupSelect";
-import DeleteGroupMappingModal from "../DeleteGroupMappingModal";
-
 import type {
   DeleteMappingModalValueType,
   GroupIds,
   UserGroupsType,
-} from "../types";
+} from "metabase/admin/types";
+import Selectbox from "../GroupSelect";
+import DeleteGroupMappingModal from "../DeleteGroupMappingModal";
 
 import { DeleteMappingButton } from "./MappingRow.styled";
 
diff --git a/frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/types.ts b/frontend/src/metabase/admin/types.ts
similarity index 100%
rename from frontend/src/metabase/admin/settings/components/widgets/GroupMappingsWidget/types.ts
rename to frontend/src/metabase/admin/types.ts
diff --git a/frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js b/frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js
index c0294cd104c..b2891770f3b 100644
--- a/frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js
+++ b/frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js
@@ -182,7 +182,7 @@ const enterLdapSettings = () => {
 
 const createMapping = name => {
   cy.button("New mapping").click();
-  cy.focused().type(name);
+  cy.findByPlaceholderText("cn=People,ou=Groups,dc=metabase,dc=com").type(name);
   cy.button("Add").click();
 };
 
-- 
GitLab