Skip to content

Commit

Permalink
Add advanced mode toggle (#15912)
Browse files Browse the repository at this point in the history
* Add useAdvancedModeSetting hook

* Add advanced mode toggle to workspace settings

As of this commit, it's a bit ugly since the toggle switch has no margin
between it and the text input directly above; going to migrate the page
to use scss modules instead of styling it by the current page
conventions.

* Query the current workspace inside useAdvancedMode

This setting is only relevant to single-workspace views; this simplifies
client code.

* Style workspace settings form with scss module

The switch needed a top-margin, too, so I created an scss module to hold
that style. Also migrated the component-local `Header` and `Buttons`
styled-components.

* Only show connection state if advanced mode is enabled

* Don't call setAdvancedMode until form is submitted

* Add external label, extract text to i18n keys

Keeping the className-passing behavior in LabeledSwitch, though, it
seems to me to be a strictly better API for that component.

* Add tooltip to advanced mode switch

* Add unit test for useAdvancedModeSetting hook

* Add unit test for the SettingsView component

* Address PR comments

- clean up object destructuring/composition
- expand inter-item spacing from 10px to 21px to match related form

* Add advanced mode toggle to OSS account settings

* Remove commented-out button container component

* mock useTrackPage since it was added to SettingsView

Co-authored-by: lmossman <lake@airbyte.io>
  • Loading branch information
ambirdsall and lmossman committed Sep 6, 2022
1 parent e8bc3d7 commit 68ab523
Show file tree
Hide file tree
Showing 12 changed files with 288 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface LabeledSwitchProps extends React.InputHTMLAttributes<HTMLInputElement>
}

export const LabeledSwitch: React.FC<LabeledSwitchProps> = (props) => (
<div className={styles.labeledSwitch}>
<div className={classNames(styles.labeledSwitch, props.className)}>
<span>
{props.checkbox ? (
<CheckBox {...props} id={`toggle-${props.name}`} />
Expand Down
53 changes: 53 additions & 0 deletions airbyte-webapp/src/hooks/services/useAdvancedModeSetting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { act, renderHook } from "@testing-library/react-hooks";

import { useAdvancedModeSetting } from "./useAdvancedModeSetting";

// mock `useCurrentWorkspace` with a closure so we can simulate changing
// workspaces by mutating the top-level variable it references
let mockWorkspaceId = "fakeWorkspaceId";
const changeToWorkspace = (newWorkspaceId: string) => {
mockWorkspaceId = newWorkspaceId;
};

jest.mock("hooks/services/useWorkspace", () => ({
useCurrentWorkspace() {
return { workspaceId: mockWorkspaceId };
},
}));

test("it defaults to false before advanced mode is explicitly set", () => {
const { result } = renderHook(() => useAdvancedModeSetting());
// eslint-disable-next-line prefer-const
let [isAdvancedMode, setAdvancedMode] = result.current;

expect(isAdvancedMode).toBe(false);

act(() => setAdvancedMode(true));
[isAdvancedMode] = result.current;

expect(isAdvancedMode).toBe(true);
});

test("it stores workspace-specific advanced mode settings", () => {
changeToWorkspace("workspaceA");

const { result, rerender } = renderHook(() => useAdvancedModeSetting());
// Avoiding destructuring in this spec to avoid capturing stale values when
// rerendering in different workspaces
const setAdvancedModeA = result.current[1];

expect(result.current[0]).toBe(false);
act(() => setAdvancedModeA(true));

expect(result.current[0]).toBe(true);

// in workspaceB, it returns the default setting of `false`
changeToWorkspace("workspaceB");
rerender();
expect(result.current[0]).toBe(false);

// ...but workspaceA's manual setting is persisted
changeToWorkspace("workspaceA");
rerender();
expect(result.current[0]).toBe(true);
});
19 changes: 19 additions & 0 deletions airbyte-webapp/src/hooks/services/useAdvancedModeSetting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useLocalStorage } from "react-use";

import { useCurrentWorkspace } from "hooks/services/useWorkspace";

type SettingsByWorkspace = Record<string, boolean>;

export const useAdvancedModeSetting = (): [boolean, (newSetting: boolean) => void] => {
const { workspaceId } = useCurrentWorkspace();
const [advancedModeSettingsByWorkspace, setAdvancedModeSettingsByWorkspace] = useLocalStorage<SettingsByWorkspace>(
"advancedMode",
{}
);

const isAdvancedMode = (advancedModeSettingsByWorkspace || {})[workspaceId] ?? false;
const setAdvancedMode = (newSetting: boolean) =>
setAdvancedModeSettingsByWorkspace({ ...advancedModeSettingsByWorkspace, [workspaceId]: newSetting });

return [isAdvancedMode, setAdvancedMode];
};
3 changes: 3 additions & 0 deletions airbyte-webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@
"form.url.error": "field must be a valid URL",
"form.setupGuide": "Setup Guide",
"form.wait": "Please wait a little bit more…",
"form.advancedMode.label": "Advanced mode",
"form.advancedMode.switchLabel": "Enable advanced mode",
"form.advancedMode.tooltip": "When Advanced Mode is enabled, certain views will display additional technical information.",

"connectionForm.validation.error": "The form is invalid. Please make sure that all fields are correct.",
"connectionForm.normalization.title": "Normalization",
Expand Down
3 changes: 3 additions & 0 deletions airbyte-webapp/src/packages/cloud/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@
"settings.generalSettings.changeWorkspace": "Change Workspace",
"settings.generalSettings.form.name.label": "Workspace name",
"settings.generalSettings.form.name.placeholder": "Workspace name",
"settings.generalSettings.form.advancedMode.label": "Advanced mode",
"settings.generalSettings.form.advancedMode.switchLabel": "Enable advanced mode",
"settings.generalSettings.form.advancedMode.tooltip": "When Advanced Mode is enabled, certain views will display additional technical information.",
"settings.generalSettings.deleteLabel": "Delete your workspace",
"settings.generalSettings.deleteText": "Delete",
"settings.accessManagementSettings": "Access Management",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.formItem {
margin-top: 21px;
width: 100%;
}

.buttonGroup {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;

& > button {
margin-left: 5px;
}
}

.header {
display: flex;
justify-content: space-between;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import classNames from "classnames";
import { Field, FieldProps, Form, Formik } from "formik";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import styled from "styled-components";

import { Button, LabeledInput, LoadingButton } from "components";
import { Button, Label, LabeledInput, LabeledSwitch, LoadingButton } from "components";
import { InfoTooltip } from "components/base/Tooltip";

import { useTrackPage, PageTrackingCodes } from "hooks/services/Analytics";
import { useAdvancedModeSetting } from "hooks/services/useAdvancedModeSetting";
import { useCurrentWorkspace } from "hooks/services/useWorkspace";
import {
useRemoveWorkspace,
Expand All @@ -14,23 +16,16 @@ import {
} from "packages/cloud/services/workspaces/WorkspacesService";
import { Content, SettingsCard } from "pages/SettingsPage/pages/SettingsComponents";

const Header = styled.div`
display: flex;
justify-content: space-between;
`;
import styles from "./WorkspaceSettingsView.module.scss";

const Buttons = styled.div`
margin-top: 10px;
width: 100%;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
& > button {
margin-left: 5px;
}
`;
const AdvancedModeSwitchLabel = () => (
<>
<FormattedMessage id="settings.generalSettings.form.advancedMode.switchLabel" />
<InfoTooltip>
<FormattedMessage id="settings.generalSettings.form.advancedMode.tooltip" />
</InfoTooltip>
</>
);

export const WorkspaceSettingsView: React.FC = () => {
const { formatMessage } = useIntl();
Expand All @@ -39,29 +34,34 @@ export const WorkspaceSettingsView: React.FC = () => {
const workspace = useCurrentWorkspace();
const removeWorkspace = useRemoveWorkspace();
const updateWorkspace = useUpdateWorkspace();
const [isAdvancedMode, setAdvancedMode] = useAdvancedModeSetting();

return (
<>
<SettingsCard
title={
<Header>
<div className={styles.header}>
<FormattedMessage id="settings.generalSettings" />
<Button type="button" onClick={exitWorkspace} data-testid="button.changeWorkspace">
<FormattedMessage id="settings.generalSettings.changeWorkspace" />
</Button>
</Header>
</div>
}
>
<Formik
initialValues={{ name: workspace.name }}
onSubmit={async (payload) =>
updateWorkspace.mutateAsync({
initialValues={{
name: workspace.name,
advancedMode: isAdvancedMode,
}}
onSubmit={async (payload) => {
setAdvancedMode(payload.advancedMode);
return updateWorkspace.mutateAsync({
workspaceId: workspace.workspaceId,
name: payload.name,
})
}
});
}}
>
{({ dirty, isSubmitting, resetForm, isValid }) => (
{({ dirty, isSubmitting, resetForm, isValid, setFieldValue }) => (
<Form>
<Content>
<Field name="name">
Expand All @@ -78,22 +78,35 @@ export const WorkspaceSettingsView: React.FC = () => {
/>
)}
</Field>
<Buttons>
<Label className={styles.formItem}>
<FormattedMessage id="settings.generalSettings.form.advancedMode.label" />
</Label>
<Field name="advancedMode">
{({ field }: FieldProps<boolean>) => (
<LabeledSwitch
label={<AdvancedModeSwitchLabel />}
checked={field.value}
onChange={() => setFieldValue(field.name, !field.value)}
/>
)}
</Field>

<div className={classNames(styles.formItem, styles.buttonGroup)}>
<Button type="button" secondary disabled={!dirty} onClick={() => resetForm()}>
cancel
</Button>
<LoadingButton type="submit" disabled={!isValid} isLoading={isSubmitting}>
save changes
</LoadingButton>
</Buttons>
</div>
</Content>
</Form>
)}
</Formik>
</SettingsCard>
<SettingsCard
title={
<Header>
<div className={styles.header}>
<FormattedMessage id="settings.generalSettings.deleteLabel" />
<LoadingButton
isLoading={removeWorkspace.isLoading}
Expand All @@ -102,7 +115,7 @@ export const WorkspaceSettingsView: React.FC = () => {
>
<FormattedMessage id="settings.generalSettings.deleteText" />
</LoadingButton>
</Header>
</div>
}
/>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { render, mockConnection } from "utils/testutils";

import SettingsView from "./SettingsView";

let mockIsAdvancedMode = false;
const setMockIsAdvancedMode = (newSetting: boolean) => {
mockIsAdvancedMode = newSetting;
};
jest.mock("hooks/services/useAdvancedModeSetting", () => ({
useAdvancedModeSetting() {
return [mockIsAdvancedMode, setMockIsAdvancedMode];
},
}));

jest.mock("hooks/services/useConnectionHook", () => ({
useDeleteConnection: () => ({ mutateAsync: () => null }),
useGetConnectionState: () => ({ state: null, globalState: null, streamState: null }),
}));

jest.mock("hooks/services/Analytics/useAnalyticsService", () => ({
useTrackPage: () => null,
}));

// Mocking the DeleteBlock component is a bit ugly, but it's simpler and less
// brittle than mocking the providers it depends on; at least it's a direct,
// visible dependency of the component under test here.
//
// This mock is intentionally trivial; if anything to do with this component is
// to be tested, we'll have to bite the bullet and render it properly, within
// the necessary providers.
jest.mock("components/DeleteBlock", () => () => {
const MockDeleteBlock = () => <div>Does not actually delete anything</div>;
return <MockDeleteBlock />;
});

describe("<SettingsView />", () => {
test("it only renders connection state when advanced mode is enabled", async () => {
let container: HTMLElement;

setMockIsAdvancedMode(false);
({ container } = await render(<SettingsView connection={mockConnection} />));
expect(container.textContent).not.toContain("Connection State");

setMockIsAdvancedMode(true);
({ container } = await render(<SettingsView connection={mockConnection} />));
expect(container.textContent).toContain("Connection State");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from "react";
import DeleteBlock from "components/DeleteBlock";

import { PageTrackingCodes, useTrackPage } from "hooks/services/Analytics";
import { useAdvancedModeSetting } from "hooks/services/useAdvancedModeSetting";
import { useDeleteConnection } from "hooks/services/useConnectionHook";

import { WebBackendConnectionRead } from "../../../../../core/request/AirbyteClient";
Expand All @@ -16,12 +17,13 @@ interface SettingsViewProps {
const SettingsView: React.FC<SettingsViewProps> = ({ connection }) => {
const { mutateAsync: deleteConnection } = useDeleteConnection();

const [isAdvancedMode] = useAdvancedModeSetting();
useTrackPage(PageTrackingCodes.CONNECTIONS_ITEM_SETTINGS);
const onDelete = () => deleteConnection(connection);

return (
<div className={styles.container}>
<StateBlock connectionId={connection.connectionId} />
{isAdvancedMode && <StateBlock connectionId={connection.connectionId} />}
<DeleteBlock type="connection" onDelete={onDelete} />
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.formItem {
margin-bottom: 21px;
}

.submit {
margin-bottom: 10px;
}

0 comments on commit 68ab523

Please sign in to comment.