Skip to content

Commit

Permalink
🪟 🎉 Add optional invite user hint to connector create pages (airbyteh…
Browse files Browse the repository at this point in the history
…q#15799)

* Add InviteUsersModalService and migrate UserSettingsView to use it

* Add InviteUsersHint component and show in create source/destination pages

* Update InviteUsersHint to use Text component and spacing variables

* Add experiments for showing invite user hint
Move cloud settings paths to its own file

* Update Invite hint to lazy load with suspense

* Fix invite user modal experiment names and add unit test

* Rename CloudInviteUsersHint component file

* Add notification when users are invited

* Fix copy and remove plural form of invite success dialog

* Show invite users hint in connector create form page

* Fix stylelint issue in InviteUsersHint

* Fix access management path in InviteUsersHint

* Fix button text in UserSettingsView

* Fix linkToUsersPage type in experiments iface

* Cleanup code

* Cleanup scss path in InviteUsersHint.module

* update InviteUsersHint layout to be consistent with or without button
  • Loading branch information
edmundito authored and jhammarstedt committed Oct 31, 2022
1 parent f4c1d66 commit 0c0fedb
Show file tree
Hide file tree
Showing 19 changed files with 298 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { lazy, Suspense } from "react";

import { InviteUsersHintProps } from "packages/cloud/views/users/InviteUsersHint/types";
import { isCloudApp } from "utils/app";

const LazyInviteUsersHint = lazy(() =>
import("packages/cloud/views/users/InviteUsersHint").then(({ InviteUsersHint }) => ({ default: InviteUsersHint }))
);

export const CloudInviteUsersHint: React.VFC<InviteUsersHintProps> = (props) =>
isCloudApp() ? (
<Suspense fallback={null}>
<LazyInviteUsersHint {...props} />
</Suspense>
) : null;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./CloudInviteUsersHint";
2 changes: 2 additions & 0 deletions airbyte-webapp/src/hooks/services/Experiment/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

export interface Experiments {
"onboarding.hideOnboarding": boolean;
"connector.inviteUsersHint.visible": boolean;
"connector.inviteUsersHint.linkToUsersPage": boolean;
"connector.orderOverwrite": Record<string, number>;
"connector.frequentlyUsedDestinationIds": string[];
"connector.startWithDestinationId": string;
Expand Down
1 change: 1 addition & 0 deletions airbyte-webapp/src/hooks/services/Notification/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import NotificationService, { useNotificationService } from "./NotificationService";
export * from "./types";

export default NotificationService;
export { NotificationService, useNotificationService };
6 changes: 4 additions & 2 deletions airbyte-webapp/src/hooks/services/Notification/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React from "react";

export interface Notification {
id: string | number;
title: string;
text?: string;
title: React.ReactNode;
text?: React.ReactNode;
isError?: boolean;
nonClosable?: boolean;
onClose?: () => void;
Expand Down
7 changes: 6 additions & 1 deletion airbyte-webapp/src/packages/cloud/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
"settings.accessManagement.roleEditors": "<b>Editors</b> can edit connections",
"settings.accessManagement.roleAdmin": "<b>Admin</b> can also manage users",

"addUsers.success.title": "Invitations sent!",

"credits.date": "Date",
"credits.amount": "Credits",

Expand Down Expand Up @@ -149,5 +151,8 @@

"verifyEmail.notification": "You successfully verified your email. Thank you.",

"webapp.cannotReachServer": "Cannot reach server."
"webapp.cannotReachServer": "Cannot reach server.",

"inviteUsersHint.message": "Need help from a teammate to set up the {connector}?",
"inviteUsersHint.cta": "Invite users"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createContext, useContext, useMemo } from "react";
import { useToggle } from "react-use";

import { InviteUsersModal } from "packages/cloud/views/users/InviteUsersModal";

interface InviteUsersModalServiceContext {
isInviteUsersModalOpen: boolean;
toggleInviteUsersModalOpen: (open?: boolean) => void;
}

const inviteUsersModalServiceContext = createContext<InviteUsersModalServiceContext | null>(null);
const { Provider } = inviteUsersModalServiceContext;

export const useInviteUsersModalService = () => {
const ctx = useContext(inviteUsersModalServiceContext);
if (!ctx) {
throw new Error("useInviteUsersModalService should be use within InviteUsersModalServiceProvider");
}
return ctx;
};

export const InviteUsersModalServiceProvider: React.FC = ({ children }) => {
const [isOpen, toggleIsOpen] = useToggle(false);

const contextValue = useMemo<InviteUsersModalServiceContext>(
() => ({
isInviteUsersModalOpen: isOpen,
toggleInviteUsersModalOpen: toggleIsOpen,
}),
[isOpen, toggleIsOpen]
);

return (
<Provider value={contextValue}>
{children}
{isOpen && <InviteUsersModal onClose={toggleIsOpen} />}
</Provider>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,10 @@ import {
} from "pages/SettingsPage/pages/ConnectorsPage";
// import ConfigurationsPage from "pages/SettingsPage/pages/ConfigurationsPage";
import NotificationPage from "pages/SettingsPage/pages/NotificationPage";
import { PageConfig, SettingsRoute } from "pages/SettingsPage/SettingsPage";
import { PageConfig } from "pages/SettingsPage/SettingsPage";
import { isOsanoActive, showOsanoDrawer } from "utils/dataPrivacy";

const CloudSettingsRoutes = {
Configuration: SettingsRoute.Configuration,
Notifications: SettingsRoute.Notifications,
Account: SettingsRoute.Account,
Source: SettingsRoute.Source,
Destination: SettingsRoute.Destination,

Workspace: "workspaces",
AccessManagement: "access-management",
} as const;
import { CloudSettingsRoutes } from "./routePaths";

export const CloudSettingsPage: React.FC = () => {
// TODO: uncomment when supported in cloud
Expand Down
12 changes: 12 additions & 0 deletions airbyte-webapp/src/packages/cloud/views/settings/routePaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { SettingsRoute } from "pages/SettingsPage/SettingsPage";

export const CloudSettingsRoutes = {
Configuration: SettingsRoute.Configuration,
Notifications: SettingsRoute.Notifications,
Account: SettingsRoute.Account,
Source: SettingsRoute.Source,
Destination: SettingsRoute.Destination,

Workspace: "workspaces",
AccessManagement: "access-management",
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@use "scss/variables";

.container {
text-align: center;
margin-top: variables.$spacing-lg;

&.withLink {
padding: variables.$spacing-md;
}
}

.ctaButton {
margin-left: variables.$spacing-sm;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { fireEvent, render } from "@testing-library/react";
import React from "react";
import { TestWrapper } from "test-utils/testutils";

import { Experiments } from "hooks/services/Experiment/experiments";
import * as ExperimentService from "hooks/services/Experiment/ExperimentService";
import { RoutePaths } from "pages/routePaths";

import { CloudSettingsRoutes } from "../../settings/routePaths";

const mockToggleInviteUsersModalOpen = jest.fn();
jest.doMock("packages/cloud/services/users/InviteUsersModalService", () => ({
InviteUsersModalServiceProvider: ({ children }: { children: React.ReactNode }): JSX.Element => <>{children}</>,
useInviteUsersModalService: () => ({
toggleInviteUsersModalOpen: mockToggleInviteUsersModalOpen,
}),
}));

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { InviteUsersHint } = require("./InviteUsersHint");

const createUseExperimentMock =
(options: { visible?: boolean; linkToUsersPage?: boolean }) => (key: keyof Experiments) => {
switch (key) {
case "connector.inviteUsersHint.visible":
return options.visible ?? false;
case "connector.inviteUsersHint.linkToUsersPage":
return options.linkToUsersPage ?? false;
default:
throw new Error(`${key} is not mocked`);
}
};

describe("InviteUsersHint", () => {
beforeEach(() => {
mockToggleInviteUsersModalOpen.mockReset();
});

it("does not render by default", () => {
const { queryByTestId } = render(<InviteUsersHint connectorType="source" />, { wrapper: TestWrapper });
expect(queryByTestId("inviteUsersHint")).not.toBeInTheDocument();
});

it("renders when `connector.inviteUserHint.visible` is set to `true`", () => {
jest.spyOn(ExperimentService, "useExperiment").mockImplementation(createUseExperimentMock({ visible: true }));

const { getByTestId } = render(<InviteUsersHint connectorType="source" />, { wrapper: TestWrapper });
const element = getByTestId("inviteUsersHint");
expect(element).toBeInTheDocument();
});

it("opens modal when clicking on CTA by default", () => {
jest.spyOn(ExperimentService, "useExperiment").mockImplementation(createUseExperimentMock({ visible: true }));

const { getByTestId } = render(<InviteUsersHint connectorType="source" />, { wrapper: TestWrapper });
const element = getByTestId("inviteUsersHint-cta");

expect(element).not.toHaveAttribute("href");

fireEvent.click(element);
expect(mockToggleInviteUsersModalOpen).toHaveBeenCalledTimes(1);
});

it("opens link to access-management settings when clicking on CTA and `connector.inviteUsersHint.linkToUsersPage` is `true`", () => {
jest
.spyOn(ExperimentService, "useExperiment")
.mockImplementation(createUseExperimentMock({ visible: true, linkToUsersPage: true }));

const { getByTestId } = render(<InviteUsersHint connectorType="source" />, { wrapper: TestWrapper });
const element = getByTestId("inviteUsersHint-cta");

expect(element).toHaveAttribute("href", `../${RoutePaths.Settings}/${CloudSettingsRoutes.AccessManagement}`);

fireEvent.click(element);
expect(mockToggleInviteUsersModalOpen).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import classNames from "classnames";
import { FormattedMessage, useIntl } from "react-intl";

import { Button } from "components/ui/Button";
import { Text } from "components/ui/Text";

import { useExperiment } from "hooks/services/Experiment";
import {
InviteUsersModalServiceProvider,
useInviteUsersModalService,
} from "packages/cloud/services/users/InviteUsersModalService";
import { CloudSettingsRoutes } from "packages/cloud/views/settings/routePaths";
import { RoutePaths } from "pages/routePaths";

import styles from "./InviteUsersHint.module.scss";
import { InviteUsersHintProps } from "./types";

const ACCESS_MANAGEMENT_PATH = `../${RoutePaths.Settings}/${CloudSettingsRoutes.AccessManagement}`;

const InviteUsersHintContent: React.VFC<InviteUsersHintProps> = ({ connectorType }) => {
const { formatMessage } = useIntl();
const { toggleInviteUsersModalOpen } = useInviteUsersModalService();
const linkToUsersPage = useExperiment("connector.inviteUsersHint.linkToUsersPage", false);

const inviteUsersCta = linkToUsersPage ? (
<a href={ACCESS_MANAGEMENT_PATH} target="_blank" rel="noreferrer" data-testid="inviteUsersHint-cta">
<FormattedMessage id="inviteUsersHint.cta" />
</a>
) : (
<Button
className={styles.ctaButton}
variant="secondary"
data-testid="inviteUsersHint-cta"
onClick={() => {
toggleInviteUsersModalOpen();
}}
>
<FormattedMessage id="inviteUsersHint.cta" />
</Button>
);

return (
<Text
size="sm"
className={classNames(styles.container, linkToUsersPage && styles.withLink)}
data-testid="inviteUsersHint"
>
<FormattedMessage
id="inviteUsersHint.message"
values={{
connector: formatMessage({ id: `connector.${connectorType}` }).toLowerCase(),
}}
/>{" "}
{inviteUsersCta}
</Text>
);
};

export const InviteUsersHint: React.VFC<InviteUsersHintProps> = (props) => {
const isVisible = useExperiment("connector.inviteUsersHint.visible", false);

return isVisible ? (
<InviteUsersModalServiceProvider>
<InviteUsersHintContent {...props} />
</InviteUsersModalServiceProvider>
) : null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./InviteUsersHint";
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface InviteUsersHintProps {
connectorType: "source" | "destination";
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { DropDown } from "components/ui/DropDown";
import { Input } from "components/ui/Input";
import { Modal } from "components/ui/Modal";

import { useNotificationService } from "hooks/services/Notification";
import { useCurrentWorkspace } from "hooks/services/useWorkspace";
import { useUserHook } from "packages/cloud/services/users/UseUserHook";

Expand Down Expand Up @@ -59,6 +60,7 @@ export const InviteUsersModal: React.FC<{
const { formatMessage } = useIntl();
const { workspaceId } = useCurrentWorkspace();
const { inviteUserLogic } = useUserHook();
const { registerNotification } = useNotificationService();
const { mutateAsync: invite } = inviteUserLogic;

const isRoleVisible = false; // Temporarily hiding roles because there's only 'Admin' in cloud.
Expand All @@ -81,7 +83,13 @@ export const InviteUsersModal: React.FC<{
await invite(
{ users: values.users, workspaceId },
{
onSuccess: () => props.onClose(),
onSuccess: () => {
registerNotification({
title: formatMessage({ id: "addUsers.success.title" }),
id: "invite-users-success",
});
props.onClose();
},
}
);
}}
Expand Down
Loading

0 comments on commit 0c0fedb

Please sign in to comment.