From ad5da72d758e1bed851cda4189fad97945b6874e Mon Sep 17 00:00:00 2001
From: Ryan Laurie <30528226+iethree@users.noreply.github.com>
Date: Thu, 5 Oct 2023 07:22:44 -0600
Subject: [PATCH] Convert collection permissions to typescript and add unit
 tests (#34186)

---
 .../src/metabase-types/api/permissions.ts     |  18 +-
 frontend/src/metabase-types/store/admin.ts    |   8 +-
 .../src/metabase-types/store/collection.ts    |  17 +
 frontend/src/metabase-types/store/index.ts    |   1 +
 .../src/metabase-types/store/mocks/admin.ts   |   2 +
 .../PermissionsSidebar/PermissionsSidebar.tsx |   2 +-
 .../PermissionsSidebarContent.tsx             |  10 +-
 .../PermissionsTable/PermissionsTable.jsx     |   6 +-
 ...Page.jsx => CollectionPermissionsPage.tsx} |  72 +++--
 .../CollectionPermissionsPage.unit.spec.tsx   | 305 ++++++++++++++++++
 .../src/metabase/admin/permissions/routes.jsx |   2 +-
 ...rmissions.js => collection-permissions.ts} | 137 ++++++--
 .../metabase/entities/collections/utils.ts    |   2 +-
 .../__support__/server-mocks/permissions.ts   |  19 +-
 14 files changed, 531 insertions(+), 70 deletions(-)
 create mode 100644 frontend/src/metabase-types/store/collection.ts
 rename frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/{CollectionPermissionsPage.jsx => CollectionPermissionsPage.tsx} (66%)
 create mode 100644 frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.unit.spec.tsx
 rename frontend/src/metabase/admin/permissions/selectors/{collection-permissions.js => collection-permissions.ts} (62%)

diff --git a/frontend/src/metabase-types/api/permissions.ts b/frontend/src/metabase-types/api/permissions.ts
index 49bfb04f19f..c9a7d6ca0d2 100644
--- a/frontend/src/metabase-types/api/permissions.ts
+++ b/frontend/src/metabase-types/api/permissions.ts
@@ -1,4 +1,9 @@
-import type { DatabaseId, TableId, SchemaName } from "metabase-types/api";
+import type {
+  DatabaseId,
+  TableId,
+  SchemaName,
+  CollectionId,
+} from "metabase-types/api";
 import type { GroupId } from "./group";
 import type { UserAttribute } from "./user";
 
@@ -78,6 +83,17 @@ export type FieldsPermissions =
       query: "segmented";
     };
 
+export type CollectionPermissionsGraph = {
+  groups: CollectionPermissions;
+  revision: number;
+};
+
+export type CollectionPermissions = {
+  [key: GroupId]: Partial<Record<CollectionId, CollectionPermission>>;
+};
+
+export type CollectionPermission = "write" | "read" | "none";
+
 // FIXME: is there a more suitable type for this?
 export type DimensionRef = ["dimension", any[]];
 
diff --git a/frontend/src/metabase-types/store/admin.ts b/frontend/src/metabase-types/store/admin.ts
index ffc7c656268..aabcf046c0d 100644
--- a/frontend/src/metabase-types/store/admin.ts
+++ b/frontend/src/metabase-types/store/admin.ts
@@ -1,4 +1,8 @@
-import type { GroupsPermissions, SettingDefinition } from "metabase-types/api";
+import type {
+  CollectionPermissions,
+  GroupsPermissions,
+  SettingDefinition,
+} from "metabase-types/api";
 
 export type AdminPathKey =
   | "data-model"
@@ -21,6 +25,8 @@ export interface AdminState {
   permissions: {
     dataPermissions: GroupsPermissions;
     originalDataPermissions: GroupsPermissions;
+    collectionPermissions: CollectionPermissions;
+    originalCollectionPermissions: CollectionPermissions;
     saveError?: string;
     isHelpReferenceOpen: boolean;
   };
diff --git a/frontend/src/metabase-types/store/collection.ts b/frontend/src/metabase-types/store/collection.ts
new file mode 100644
index 00000000000..42445f6b745
--- /dev/null
+++ b/frontend/src/metabase-types/store/collection.ts
@@ -0,0 +1,17 @@
+import type { Collection, CollectionId } from "metabase-types/api";
+import type { IconName } from "metabase/core/components/Icon";
+
+// see entities/collections/getExpandedCollectionsById.js
+export type ExpandedCollection = Collection & {
+  path: string;
+  parent: ExpandedCollection;
+  children: ExpandedCollection[];
+  is_personal: boolean;
+};
+
+export type CollectionTreeItem = {
+  id: CollectionId;
+  name: string;
+  icon: IconName | any;
+  children?: CollectionTreeItem[];
+};
diff --git a/frontend/src/metabase-types/store/index.ts b/frontend/src/metabase-types/store/index.ts
index c4268a931e0..4bce48841c9 100644
--- a/frontend/src/metabase-types/store/index.ts
+++ b/frontend/src/metabase-types/store/index.ts
@@ -1,6 +1,7 @@
 export * from "./admin";
 export * from "./app";
 export * from "./auth";
+export * from "./collection";
 export * from "./dashboard";
 export * from "./embed";
 export * from "./entities";
diff --git a/frontend/src/metabase-types/store/mocks/admin.ts b/frontend/src/metabase-types/store/mocks/admin.ts
index c1f23a36d6f..adc5731f34e 100644
--- a/frontend/src/metabase-types/store/mocks/admin.ts
+++ b/frontend/src/metabase-types/store/mocks/admin.ts
@@ -23,6 +23,8 @@ export const createMockPermissionsState = (
   return {
     dataPermissions: {},
     originalDataPermissions: {},
+    collectionPermissions: {},
+    originalCollectionPermissions: {},
     isHelpReferenceOpen: false,
     ...opts,
   };
diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.tsx b/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.tsx
index 82ee6484200..5c432863806 100644
--- a/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.tsx
+++ b/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.tsx
@@ -5,7 +5,7 @@ import { SidebarRoot } from "./PermissionsSidebar.styled";
 
 interface PermissionsSidebarProps extends PermissionsSidebarContentProps {
   isLoading?: boolean;
-  error: string;
+  error?: string;
 }
 
 export const PermissionsSidebar = ({
diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebarContent.tsx b/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebarContent.tsx
index bc718a5bcfd..be17fc1885d 100644
--- a/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebarContent.tsx
+++ b/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebarContent.tsx
@@ -19,11 +19,11 @@ export interface PermissionsSidebarContentProps {
   description?: string;
   filterPlaceholder: string;
   onSelect: (item: ITreeNodeItem) => void;
-  onBack: () => void;
-  selectedId: ITreeNodeItem["id"];
+  onBack?: () => void;
+  selectedId?: ITreeNodeItem["id"];
   entityGroups: ITreeNodeItem[][];
-  onEntityChange: (entity: string) => void;
-  entityViewFocus: "database" | "group";
+  onEntityChange?: (entity: string) => void;
+  entityViewFocus?: "database" | "group";
 }
 
 export const PermissionsSidebarContent = memo<PermissionsSidebarContentProps>(
@@ -50,7 +50,7 @@ export const PermissionsSidebarContent = memo<PermissionsSidebarContentProps>(
             <SidebarContentTitle>{title}</SidebarContentTitle>
           )}
           {description && <Text color="text-dark">{description}</Text>}
-          {entityViewFocus && (
+          {entityViewFocus && onEntityChange && (
             <EntityViewSwitch
               value={entityViewFocus}
               onChange={onEntityChange}
diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.jsx
index 31b4ff73fac..1a10db10ff0 100644
--- a/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.jsx
+++ b/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.jsx
@@ -120,9 +120,11 @@ export function PermissionsTable({
                   )}
                 </PermissionsTableCell>
 
-                {entity.permissions.map(permission => {
+                {entity.permissions.map((permission, index) => {
                   return (
-                    <PermissionsTableCell key={permission.type}>
+                    <PermissionsTableCell
+                      key={permission.type ?? String(index)}
+                    >
                       <PermissionsSelect
                         {...permission}
                         onChange={(value, toggleState) =>
diff --git a/frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.jsx b/frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.tsx
similarity index 66%
rename from frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.jsx
rename to frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.tsx
index a30c88d3bc8..9cf65a18080 100644
--- a/frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.jsx
+++ b/frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.tsx
@@ -1,10 +1,13 @@
 import { useEffect, useCallback } from "react";
-import PropTypes from "prop-types";
 import { push } from "react-router-redux";
 import { connect } from "react-redux";
+import type { Route } from "react-router";
 import { t } from "ttag";
 import _ from "underscore";
 
+import type { Collection, CollectionId, GroupId } from "metabase-types/api";
+import type { State } from "metabase-types/store";
+
 import Groups from "metabase/entities/groups";
 import Collections from "metabase/entities/collections";
 import { CollectionPermissionsHelp } from "metabase/admin/permissions/components/CollectionPermissionsHelp";
@@ -12,8 +15,8 @@ import { CollectionPermissionsHelp } from "metabase/admin/permissions/components
 import {
   PermissionsEditor,
   PermissionsEditorEmptyState,
-  permissionEditorPropTypes,
 } from "../../components/PermissionsEditor";
+
 import PermissionsPageLayout from "../../components/PermissionsPageLayout";
 import {
   initializeCollectionPermissions,
@@ -21,6 +24,12 @@ import {
   saveCollectionPermissions,
   loadCollectionPermissions,
 } from "../../permissions";
+
+import type {
+  CollectionIdProps,
+  CollectionPermissionEditorType,
+  CollectionSidebarType,
+} from "../../selectors/collection-permissions";
 import {
   getCollectionsSidebar,
   getCollectionsPermissionEditor,
@@ -28,43 +37,54 @@ import {
   getIsDirty,
   collectionsQuery,
 } from "../../selectors/collection-permissions";
+
 import { PermissionsSidebar } from "../../components/PermissionsSidebar";
 
 const mapDispatchToProps = {
   initialize: initializeCollectionPermissions,
   loadPermissions: loadCollectionPermissions,
-  navigateToItem: ({ id }) => push(`/admin/permissions/collections/${id}`),
+  navigateToItem: ({ id }: { id: CollectionId }) =>
+    push(`/admin/permissions/collections/${id}`),
   updateCollectionPermission,
   savePermissions: saveCollectionPermissions,
 };
 
-const mapStateToProps = (state, props) => {
+const mapStateToProps = (state: State, props: CollectionIdProps) => {
   return {
     sidebar: getCollectionsSidebar(state, props),
     permissionEditor: getCollectionsPermissionEditor(state, props),
-    isDirty: getIsDirty(state, props),
+    isDirty: getIsDirty(state),
     collection: getCollectionEntity(state, props),
   };
 };
 
-const propTypes = {
-  params: PropTypes.shape({
-    collectionId: PropTypes.string,
-  }),
-  children: PropTypes.node.isRequired,
-  sidebar: PropTypes.object,
-  permissionEditor: PropTypes.shape(permissionEditorPropTypes),
-  collection: PropTypes.object,
-  navigateToItem: PropTypes.func.isRequired,
-  updateCollectionPermission: PropTypes.func.isRequired,
-  isDirty: PropTypes.bool,
-  savePermissions: PropTypes.func.isRequired,
-  loadPermissions: PropTypes.func.isRequired,
-  initialize: PropTypes.func.isRequired,
-  route: PropTypes.object,
+type UpdateCollectionPermissionParams = {
+  groupId: GroupId;
+  collection: Collection;
+  value: unknown;
+  shouldPropagate: boolean;
+};
+
+type CollectionPermissionsPageProps = {
+  params: CollectionIdProps["params"];
+  sidebar: CollectionSidebarType;
+  permissionEditor: CollectionPermissionEditorType;
+  collection: Collection;
+  navigateToItem: (item: any) => void;
+  updateCollectionPermission: ({
+    groupId,
+    collection,
+    value,
+    shouldPropagate,
+  }: UpdateCollectionPermissionParams) => void;
+  isDirty: boolean;
+  savePermissions: () => void;
+  loadPermissions: () => void;
+  initialize: () => void;
+  route: Route;
 };
 
-function CollectionsPermissionsPage({
+function CollectionsPermissionsPageView({
   sidebar,
   permissionEditor,
   collection,
@@ -75,7 +95,7 @@ function CollectionsPermissionsPage({
   navigateToItem,
   initialize,
   route,
-}) {
+}: CollectionPermissionsPageProps) {
   useEffect(() => {
     initialize();
   }, [initialize]);
@@ -112,6 +132,8 @@ function CollectionsPermissionsPage({
 
       {permissionEditor && (
         <PermissionsEditor
+          isLoading={undefined}
+          error={undefined}
           {...permissionEditor}
           onChange={handlePermissionChange}
         />
@@ -120,12 +142,10 @@ function CollectionsPermissionsPage({
   );
 }
 
-CollectionsPermissionsPage.propTypes = propTypes;
-
-export default _.compose(
+export const CollectionPermissionsPage = _.compose(
   Collections.loadList({
     entityQuery: collectionsQuery,
   }),
   Groups.loadList(),
   connect(mapStateToProps, mapDispatchToProps),
-)(CollectionsPermissionsPage);
+)(CollectionsPermissionsPageView);
diff --git a/frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.unit.spec.tsx b/frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.unit.spec.tsx
new file mode 100644
index 00000000000..b237e413eeb
--- /dev/null
+++ b/frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.unit.spec.tsx
@@ -0,0 +1,305 @@
+import { Route } from "react-router";
+import { within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import fetchMock from "fetch-mock";
+
+import { renderWithProviders, screen } from "__support__/ui";
+import { createMockState } from "metabase-types/store/mocks";
+
+import { createMockCollection } from "metabase-types/api/mocks";
+
+import {
+  setupCollectionPermissionsGraphEndpoint,
+  setupCollectionsEndpoints,
+  setupGroupsEndpoint,
+} from "__support__/server-mocks";
+
+import type { CollectionPermissionsGraph } from "metabase-types/api";
+import { CollectionPermissionsPage } from "./CollectionPermissionsPage";
+
+const personalCollection = createMockCollection({
+  id: "personal",
+  name: "Personal",
+  personal_owner_id: 1,
+});
+
+const nestedCollectionOne = createMockCollection({
+  id: 3,
+  name: "Nested One",
+  location: "/1/",
+  children: [],
+});
+const nestedCollectionTwo = createMockCollection({
+  id: 4,
+  name: "Nested Two",
+  location: "/2/",
+  children: [],
+});
+
+const collectionOne = createMockCollection({
+  id: 1,
+  name: "Collection One",
+  children: [nestedCollectionOne],
+});
+const collectionTwo = createMockCollection({
+  id: 2,
+  name: "Collection Two",
+  children: [nestedCollectionTwo],
+});
+
+const rootCollection = createMockCollection({
+  id: "root",
+  name: "Our analytics",
+  children: [collectionOne, collectionTwo],
+});
+
+const permissionGroups = [
+  { id: 1, name: "All Users", member_count: 40 },
+  { id: 2, name: "Administrators", member_count: 2 },
+  { id: 3, name: "Other Users", member_count: 33 },
+];
+
+const permissionsGraph: CollectionPermissionsGraph = {
+  revision: 23,
+  groups: {
+    1: {
+      // all users
+      1: "write", // one
+      2: "write", // two
+      3: "read", // nested one
+      4: "none", // nested two
+      root: "read",
+    },
+    2: {
+      // Administrators
+      1: "write", // one
+      2: "write", // two
+      3: "write", // nested one
+      4: "write", // nested two
+      root: "write",
+    },
+    3: {
+      // Other users
+      1: "read", // one
+      2: "read", // two
+      3: "none", // nested one
+      4: "none", // nested two
+      root: "read",
+    },
+  },
+};
+
+function setup() {
+  setupCollectionsEndpoints({
+    collections: [collectionOne, collectionTwo, personalCollection],
+    rootCollection: rootCollection,
+  });
+
+  setupCollectionPermissionsGraphEndpoint(permissionsGraph);
+
+  setupGroupsEndpoint(permissionGroups);
+
+  const initialState = createMockState();
+
+  renderWithProviders(
+    <>
+      <Route
+        path="/admin/permissions/collections/root"
+        component={CollectionPermissionsPage}
+      />
+      <Route
+        path="/admin/permissions/collections/:collectionId"
+        component={CollectionPermissionsPage}
+      />
+    </>,
+    {
+      storeInitialState: initialState,
+      withRouter: true,
+      initialRoute: "/admin/permissions/collections/root",
+    },
+  );
+}
+
+describe("Admin > CollectionPermissionsPage", () => {
+  describe("CollectionPermissionsPage", () => {
+    it("should show a collections tree in the sidebar", async () => {
+      await setup();
+
+      expect(
+        await screen.findByText("Select a collection to see its permissions"),
+      ).toBeVisible();
+      expect(await screen.findByText("Our analytics")).toBeVisible();
+      expect(await screen.findByText("Collection One")).toBeVisible();
+      expect(await screen.findByText("Collection Two")).toBeVisible();
+    });
+
+    it("should allow expansion of nested collections", async () => {
+      await setup();
+
+      const collection1 = await screen.findByText("Collection One");
+      expect(screen.queryByText("Nested One")).not.toBeInTheDocument();
+      userEvent.click(collection1);
+      expect(await screen.findByText("Nested One")).toBeInTheDocument();
+    });
+
+    it("should not show personal collection", async () => {
+      await setup();
+
+      expect(await screen.findByText("Collection One")).toBeInTheDocument();
+      expect(screen.queryByText("Personal")).not.toBeInTheDocument();
+    });
+
+    it("should show a permissions table for the selected collection", async () => {
+      await setup();
+
+      expect(
+        await screen.findByText("Select a collection to see its permissions"),
+      ).toBeVisible();
+
+      userEvent.click(await screen.findByText("Collection One"));
+      userEvent.click(await screen.findByText("Nested One"));
+
+      expect(
+        await screen.findByText("Permissions for Nested One"),
+      ).toBeVisible();
+      expect(await screen.findByText("Administrators")).toBeVisible();
+      expect(await screen.findByText("All Users")).toBeVisible();
+      expect(await screen.findByText("Other Users")).toBeVisible();
+
+      // 1 groups has write, 1 has read, 1 has none
+      expect(await screen.findByText("Curate")).toBeInTheDocument();
+      expect(await screen.findByText("View")).toBeInTheDocument();
+      expect(await screen.findByText("No access")).toBeInTheDocument();
+    });
+
+    it("can change group permissions", async () => {
+      await setup();
+
+      expect(
+        await screen.findByText("Select a collection to see its permissions"),
+      ).toBeVisible();
+
+      userEvent.click(await screen.findByText("Collection One"));
+      userEvent.click(await screen.findByText("Nested One"));
+
+      // change Other users from no access to view
+      userEvent.click(await screen.findByText("No access"));
+      const listbox = await screen.findByRole("listbox");
+      userEvent.click(within(listbox).getByText("View"));
+
+      expect(
+        await screen.findByText("You've made changes to permissions."),
+      ).toBeInTheDocument();
+
+      userEvent.click(await screen.findByText("Save changes"));
+
+      // are you sure you want to save?
+      userEvent.click(await screen.findByText("Yes"));
+
+      expect(
+        await screen.findByText("You've made changes to permissions."),
+      ).not.toBeInTheDocument();
+
+      expect(await screen.findByText("Curate")).toBeInTheDocument();
+      expect(await screen.findAllByText("View")).toHaveLength(2);
+      expect(screen.queryByText("No access")).not.toBeInTheDocument();
+
+      const lastRequest = await fetchMock
+        .lastCall("path:/api/collection/graph", {
+          method: "PUT",
+        })
+        ?.request?.json();
+
+      expect(lastRequest).toEqual({
+        ...permissionsGraph,
+        groups: {
+          ...permissionsGraph.groups,
+          3: {
+            ...permissionsGraph.groups[3],
+            3: "read",
+          },
+        },
+      });
+    });
+
+    it("can propagate permissions changes to sub-collection", async () => {
+      await setup();
+
+      expect(
+        await screen.findByText("Select a collection to see its permissions"),
+      ).toBeVisible();
+
+      userEvent.click(await screen.findByText("Collection One"));
+
+      // change other users from read to curate on collection one
+      // should also change permissions on nested one from no access to curate
+      const otherUsersRow = await screen
+        .findAllByRole("row")
+        .then(rows => rows[3]);
+
+      expect(within(otherUsersRow).getByText("Other Users")).toBeVisible();
+      userEvent.click(within(otherUsersRow).getByText("View"));
+
+      const popover = await screen.findByTestId("popover");
+      userEvent.click(within(popover).getByRole("switch")); // propagate switch
+      userEvent.click(within(popover).getByText("Curate"));
+
+      expect(
+        await screen.findByText("You've made changes to permissions."),
+      ).toBeInTheDocument();
+
+      userEvent.click(await screen.findByText("Save changes"));
+
+      // are you sure you want to save?
+      userEvent.click(await screen.findByText("Yes"));
+
+      expect(
+        await screen.findByText("You've made changes to permissions."),
+      ).not.toBeInTheDocument();
+
+      expect(await screen.findAllByText("Curate")).toHaveLength(3);
+      expect(screen.queryByText("No access")).not.toBeInTheDocument();
+
+      const lastRequest = await fetchMock
+        .lastCall("path:/api/collection/graph", {
+          method: "PUT",
+        })
+        ?.request?.json();
+
+      expect(lastRequest).toEqual({
+        ...permissionsGraph,
+        groups: {
+          ...permissionsGraph.groups,
+          3: {
+            ...permissionsGraph.groups[3],
+            1: "write",
+            3: "write",
+          },
+        },
+      });
+    });
+
+    it("should show toggle to change sub-collection permissions if the collection has sub-collections", async () => {
+      await setup();
+
+      userEvent.click(await screen.findByText("Collection One"));
+      userEvent.click(await screen.findByText("View"));
+
+      expect(
+        await screen.findByText("Also change sub-collections"),
+      ).toBeInTheDocument();
+    });
+
+    it("should not show toggle to change sub-collection permissions if the collection does not have sub-collections", async () => {
+      await setup();
+
+      userEvent.click(await screen.findByText("Collection One"));
+      userEvent.click(await screen.findByText("Nested One"));
+      userEvent.click(await screen.findByText("View"));
+
+      expect(
+        screen.queryByText("Also change sub-collections"),
+      ).not.toBeInTheDocument();
+    });
+  });
+});
diff --git a/frontend/src/metabase/admin/permissions/routes.jsx b/frontend/src/metabase/admin/permissions/routes.jsx
index e369a209ae0..086ed8bfcbb 100644
--- a/frontend/src/metabase/admin/permissions/routes.jsx
+++ b/frontend/src/metabase/admin/permissions/routes.jsx
@@ -9,7 +9,7 @@ import {
   PLUGIN_ADMIN_PERMISSIONS_DATABASE_ROUTES,
   PLUGIN_ADMIN_PERMISSIONS_DATABASE_GROUP_ROUTES,
 } from "metabase/plugins";
-import CollectionPermissionsPage from "./pages/CollectionPermissionsPage/CollectionPermissionsPage";
+import { CollectionPermissionsPage } from "./pages/CollectionPermissionsPage/CollectionPermissionsPage";
 import DatabasesPermissionsPage from "./pages/DatabasePermissionsPage/DatabasesPermissionsPage";
 import GroupsPermissionsPage from "./pages/GroupDataPermissionsPage/GroupsPermissionsPage";
 import DataPermissionsPage from "./pages/DataPermissionsPage";
diff --git a/frontend/src/metabase/admin/permissions/selectors/collection-permissions.js b/frontend/src/metabase/admin/permissions/selectors/collection-permissions.ts
similarity index 62%
rename from frontend/src/metabase/admin/permissions/selectors/collection-permissions.js
rename to frontend/src/metabase/admin/permissions/selectors/collection-permissions.ts
index c56b2bdb1e1..de0a159b929 100644
--- a/frontend/src/metabase/admin/permissions/selectors/collection-permissions.js
+++ b/frontend/src/metabase/admin/permissions/selectors/collection-permissions.ts
@@ -16,6 +16,17 @@ import {
   isDefaultGroup,
 } from "metabase/lib/groups";
 
+import type {
+  State,
+  ExpandedCollection,
+  CollectionTreeItem,
+} from "metabase-types/store";
+import type {
+  Collection,
+  Group as GroupType,
+  CollectionPermissions,
+  CollectionId,
+} from "metabase-types/api";
 import { COLLECTION_OPTIONS } from "../constants/collections-permissions";
 import { UNABLE_TO_CHANGE_ADMIN_PERMISSIONS } from "../constants/messages";
 import { getPermissionWarningModal } from "./confirmations";
@@ -27,20 +38,30 @@ export const collectionsQuery = {
 };
 
 export const getIsDirty = createSelector(
-  state => state.admin.permissions.collectionPermissions,
-  state => state.admin.permissions.originalCollectionPermissions,
-  (permissions, originalPermissions) =>
-    JSON.stringify(permissions) !== JSON.stringify(originalPermissions),
+  (state: State) => state.admin.permissions.collectionPermissions,
+  (state: State) => state.admin.permissions.originalCollectionPermissions,
+  (
+    permissions: CollectionPermissions,
+    originalPermissions: CollectionPermissions,
+  ) => JSON.stringify(permissions) !== JSON.stringify(originalPermissions),
 );
 
-export const getCurrentCollectionId = (_state, props) => {
+export type CollectionIdProps = {
+  params: { collectionId: CollectionId };
+  namespace?: string;
+};
+
+export const getCurrentCollectionId = (
+  _state: State,
+  props: CollectionIdProps,
+) => {
   if (props.params.collectionId == null) {
-    return null;
+    return undefined;
   }
 
   return props.params.collectionId === ROOT_COLLECTION.id
     ? ROOT_COLLECTION.id
-    : parseInt(props.params.collectionId);
+    : parseInt(String(props.params.collectionId));
 };
 
 const getRootCollectionTreeItem = () => {
@@ -52,7 +73,7 @@ const getRootCollectionTreeItem = () => {
   };
 };
 
-const getCollections = state =>
+const getCollections = (state: State) =>
   (
     Collections.selectors.getList(state, {
       entityQuery: collectionsQuery,
@@ -63,24 +84,35 @@ const getCollectionsTree = createSelector([getCollections], collections => {
   return [getRootCollectionTreeItem(), ...buildCollectionTree(collections)];
 });
 
-export function buildCollectionTree(collections) {
+export function buildCollectionTree(
+  collections: Collection[] | null,
+): CollectionTreeItem[] {
   if (collections == null) {
     return [];
   }
-  return collections.map(collection => {
+  return collections.map((collection: Collection) => {
     return {
       id: collection.id,
       name: collection.name,
       icon: getCollectionIcon(collection),
-      children: buildCollectionTree(collection.children),
+      children: collection?.children
+        ? buildCollectionTree(collection.children)
+        : [],
     };
   });
 }
 
+export type CollectionSidebarType = {
+  selectedId?: CollectionId;
+  title: string;
+  entityGroups: [CollectionTreeItem[]];
+  filterPlaceholder: string;
+};
+
 export const getCollectionsSidebar = createSelector(
   getCollectionsTree,
   getCurrentCollectionId,
-  (collectionsTree, collectionId) => {
+  (collectionsTree, collectionId): CollectionSidebarType => {
     return {
       selectedId: collectionId,
       title: t`Collections`,
@@ -90,10 +122,13 @@ export const getCollectionsSidebar = createSelector(
   },
 );
 
-const getCollectionsPermissions = state =>
+const getCollectionsPermissions = (state: State) =>
   state.admin.permissions.collectionPermissions;
 
-const findCollection = (collections, collectionId) => {
+const findCollection = (
+  collections: Collection[],
+  collectionId: CollectionId,
+): Collection | null => {
   if (collections.length === 0) {
     return null;
   }
@@ -107,7 +142,7 @@ const findCollection = (collections, collectionId) => {
   }
 
   return findCollection(
-    collections.map(collection => collection.children).flat(),
+    collections.map(collection => collection.children ?? []).flat(),
     collectionId,
   );
 };
@@ -130,35 +165,64 @@ const getCollection = createSelector(
   },
 );
 
-const getFolder = (state, props) => {
+const getFolder = (state: State, props: CollectionIdProps) => {
   const folderId = getCurrentCollectionId(state, props);
   const folders = SnippetCollections.selectors.getList(state);
 
-  return folders.find(folder => folder.id === folderId);
+  return folders.find((folder: Collection) => folder.id === folderId);
 };
 
-export const getCollectionEntity = (state, props) => {
+export const getCollectionEntity = (state: State, props: CollectionIdProps) => {
   return props.namespace === "snippets"
     ? getFolder(state, props)
     : getCollection(state, props);
 };
 
-const getCollectionPermission = (permissions, groupId, collectionId) =>
-  getIn(permissions, [groupId, collectionId]);
+const getCollectionPermission = (
+  permissions: CollectionPermissions,
+  groupId: number,
+  collectionId: CollectionId,
+) => getIn(permissions, [groupId, collectionId]);
 
-const getNamespace = (_state, props) => props.namespace;
+const getNamespace = (_state: State, props: CollectionIdProps) =>
+  props.namespace;
 
-const getToggleLabel = namespace =>
+const getToggleLabel = (namespace?: string) =>
   namespace === "snippets"
     ? t`Also change sub-folders`
     : t`Also change sub-collections`;
 
+export type CollectionPermissionEditorType = null | {
+  title: string;
+  filterPlaceholder: string;
+  columns: [{ name: string }, { name: string }];
+  entities: {
+    id: number;
+    name: string;
+    permissions: {
+      toggleLabel: string;
+      hasChildren: boolean;
+      isDisabled: boolean;
+      disabledTooltip: string | null;
+      value: string;
+      warning: string | null;
+      confirmations: (newValue: string) => string[];
+      options: string[];
+    }[];
+  }[];
+};
+
 export const getCollectionsPermissionEditor = createSelector(
   getCollectionsPermissions,
   getCollectionEntity,
   Group.selectors.getList,
   getNamespace,
-  (permissions, collection, groups, namespace) => {
+  (
+    permissions,
+    collection,
+    groups,
+    namespace,
+  ): CollectionPermissionEditorType => {
     if (!permissions || collection == null) {
       return null;
     }
@@ -167,7 +231,7 @@ export const getCollectionsPermissionEditor = createSelector(
     const toggleLabel = hasChildren ? getToggleLabel(namespace) : null;
     const defaultGroup = _.find(groups, isDefaultGroup);
 
-    const entities = groups.map(group => {
+    const entities = groups.map((group: GroupType) => {
       const isAdmin = isAdminGroup(group);
 
       const defaultGroupPermission = getCollectionPermission(
@@ -176,7 +240,7 @@ export const getCollectionsPermissionEditor = createSelector(
         collection.id,
       );
 
-      const confirmations = newValue => [
+      const confirmations = (newValue: string) => [
         getPermissionWarningModal(
           newValue,
           defaultGroupPermission,
@@ -223,22 +287,33 @@ export const getCollectionsPermissionEditor = createSelector(
   },
 );
 
-const permissionsCollectionFilter = collection => !collection.is_personal;
+const permissionsCollectionFilter = (collection: ExpandedCollection) =>
+  !collection.is_personal;
 
-function getDecendentCollections(collection) {
+function getDescendentCollections(
+  collection: ExpandedCollection,
+): ExpandedCollection[] {
   const subCollections =
     collection.children?.filter(permissionsCollectionFilter) || [];
-  return subCollections.concat(...subCollections.map(getDecendentCollections));
+  return subCollections.concat(...subCollections.map(getDescendentCollections));
 }
 
-function getPermissionsSet(collections, permissions, groupId) {
+function getPermissionsSet(
+  collections: Collection[],
+  permissions: CollectionPermissions,
+  groupId: number,
+) {
   const perms = collections.map(collection =>
     getCollectionPermission(permissions, groupId, collection.id),
   );
   return new Set(perms);
 }
 
-function getCollectionWarning(groupId, collection, permissions) {
+function getCollectionWarning(
+  groupId: number,
+  collection: ExpandedCollection,
+  permissions: CollectionPermissions,
+) {
   if (!collection) {
     return;
   }
@@ -247,7 +322,7 @@ function getCollectionWarning(groupId, collection, permissions) {
     groupId,
     collection.id,
   );
-  const descendentCollections = getDecendentCollections(collection);
+  const descendentCollections = getDescendentCollections(collection);
   const descendentPerms = getPermissionsSet(
     descendentCollections,
     permissions,
diff --git a/frontend/src/metabase/entities/collections/utils.ts b/frontend/src/metabase/entities/collections/utils.ts
index 95f67095699..f3ad552124a 100644
--- a/frontend/src/metabase/entities/collections/utils.ts
+++ b/frontend/src/metabase/entities/collections/utils.ts
@@ -20,7 +20,7 @@ export function normalizedCollection(collection: Collection) {
 }
 
 export function getCollectionIcon(
-  collection: Collection,
+  collection: Partial<Collection>,
   { tooltip = "default" } = {},
 ): {
   name: IconName;
diff --git a/frontend/test/__support__/server-mocks/permissions.ts b/frontend/test/__support__/server-mocks/permissions.ts
index 77ce6be4539..e46f6cba4ae 100644
--- a/frontend/test/__support__/server-mocks/permissions.ts
+++ b/frontend/test/__support__/server-mocks/permissions.ts
@@ -1,8 +1,25 @@
 import fetchMock from "fetch-mock";
-import type { PermissionsGraph } from "metabase-types/api";
+import type {
+  CollectionPermissionsGraph,
+  PermissionsGraph,
+} from "metabase-types/api";
 
 export const setupPermissionsGraphEndpoint = (
   permissionsGraph: PermissionsGraph,
 ) => {
   fetchMock.get("path:/api/permissions/graph", permissionsGraph);
 };
+
+export const setupCollectionPermissionsGraphEndpoint = (
+  permissionsGraph: CollectionPermissionsGraph,
+) => {
+  fetchMock.get("path:/api/collection/graph", permissionsGraph);
+  fetchMock.put(
+    "path:/api/collection/graph",
+    (url: string, opts: any, req: { body: any }) => {
+      const body = JSON.parse(req.body);
+      body.revision += 1;
+      return body;
+    },
+  );
+};
-- 
GitLab