Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🪟🔧 Refactor feature service #14559

Merged
merged 10 commits into from
Jul 15, 2022
4 changes: 2 additions & 2 deletions airbyte-webapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ApiServices } from "core/ApiServices";
import { I18nProvider } from "core/i18n";
import { ServicesProvider } from "core/servicesProvider";
import { ConfirmationModalService } from "hooks/services/ConfirmationModal";
import { FeatureService } from "hooks/services/Feature";
import { defaultFeatures, FeatureService } from "hooks/services/Feature";
import { FormChangeTrackerService } from "hooks/services/FormChangeTracker";
import NotificationService from "hooks/services/Notification";
import { AnalyticsProvider } from "views/common/AnalyticsProvider";
Expand Down Expand Up @@ -41,7 +41,7 @@ const Services: React.FC = ({ children }) => (
<AnalyticsProvider>
<ApiErrorBoundary>
<WorkspaceServiceProvider>
<FeatureService>
<FeatureService features={defaultFeatures}>
<NotificationService>
<ConfirmationModalService>
<FormChangeTrackerService>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Popout } from "components/base/Popout/Popout";
import { ReleaseStageBadge } from "components/ReleaseStageBadge";

import { ReleaseStage } from "core/request/AirbyteClient";
import { FeatureItem, useFeatureService } from "hooks/services/Feature";
import { FeatureItem, useFeature } from "hooks/services/Feature";

interface TableItemTitleProps {
type: "source" | "destination";
Expand Down Expand Up @@ -55,8 +55,7 @@ const TableItemTitle: React.FC<TableItemTitleProps> = ({
entityIcon,
releaseStage,
}) => {
const { hasFeature } = useFeatureService();
const allowCreateConnection = hasFeature(FeatureItem.AllowCreateConnection);
const allowCreateConnection = useFeature(FeatureItem.AllowCreateConnection);
const { formatMessage } = useIntl();
const options = [
{
Expand Down
5 changes: 2 additions & 3 deletions airbyte-webapp/src/components/EntityTable/ConnectionTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import styled from "styled-components";

import Table from "components/Table";

import { FeatureItem, useFeatureService } from "hooks/services/Feature";
import { FeatureItem, useFeature } from "hooks/services/Feature";
import useRouter from "hooks/useRouter";

import ConnectionSettingsCell from "./components/ConnectionSettingsCell";
Expand All @@ -32,8 +32,7 @@ interface IProps {

const ConnectionTable: React.FC<IProps> = ({ data, entity, onClickRow, onChangeStatus, onSync }) => {
const { query, push } = useRouter();
const { hasFeature } = useFeatureService();
const allowSync = hasFeature(FeatureItem.AllowSync);
const allowSync = useFeature(FeatureItem.AllowSync);

const sortBy = query.sortBy || "entityName";
const sortOrder = query.order || SortOrderEnum.ASC;
Expand Down
22 changes: 0 additions & 22 deletions airbyte-webapp/src/config/defaultConfig.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,6 @@
import { Feature } from "hooks/services/Feature";
import { FeatureItem } from "hooks/services/Feature/types";

import { links } from "./links";
import { Config } from "./types";

const features: Feature[] = [
{
id: FeatureItem.AllowUploadCustomImage,
},
{
id: FeatureItem.AllowCustomDBT,
},
{
id: FeatureItem.AllowUpdateConnectors,
},
{
id: FeatureItem.AllowCreateConnection,
},
{
id: FeatureItem.AllowSync,
},
];

const defaultConfig: Config = {
links,
segment: { enabled: true, token: "" },
Expand All @@ -31,7 +10,6 @@ const defaultConfig: Config = {
integrationUrl: "/docs",
oauthRedirectUrl: `${window.location.protocol}//${window.location.host}`,
isDemo: false,
features,
};

export { defaultConfig };
2 changes: 0 additions & 2 deletions airbyte-webapp/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { SegmentAnalytics } from "core/analytics/types";
import { Feature } from "hooks/services/Feature";

import { OutboundLinks } from "./links";

Expand All @@ -22,7 +21,6 @@ declare global {

export interface Config {
links: OutboundLinks;
features: Feature[];
segment: { token: string; enabled: boolean };
apiUrl: string;
oauthRedirectUrl: string;
Expand Down
252 changes: 184 additions & 68 deletions airbyte-webapp/src/hooks/services/Feature/FeatureService.test.tsx
Original file line number Diff line number Diff line change
@@ -1,88 +1,204 @@
import { act, renderHook } from "@testing-library/react-hooks";
import React from "react";
import { render } from "@testing-library/react";
import { renderHook } from "@testing-library/react-hooks";
import { useEffect } from "react";

import { ConfigContext, ConfigContextData } from "config";
import { TestWrapper } from "utils/testutils";

import { FeatureService, useFeatureRegisterValues, useFeatureService } from "./FeatureService";
import { FeatureItem } from "./types";

const predefinedFeatures = [
{
id: FeatureItem.AllowCustomDBT,
},
];
import { FeatureService, IfFeatureEnabled, useFeature, useFeatureService } from "./FeatureService";
import { FeatureItem, FeatureSet } from "./types";

const wrapper: React.FC = ({ children }) => (
<TestWrapper>
<ConfigContext.Provider
value={
{
config: { features: predefinedFeatures },
} as unknown as ConfigContextData
}
>
<FeatureService>{children}</FeatureService>
</ConfigContext.Provider>
</TestWrapper>
<FeatureService features={[FeatureItem.AllowCreateConnection, FeatureItem.AllowSync]}>{children}</FeatureService>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be better to have the features in a constant such as defaultFeatures so that it can be referenced later in the tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally would prefer keeping it that way. Most of the tests tests different combinations of enabling/disabling, why we can't spread that anyway simply into it. Also given that those tests all test whether overwriting etc work, I'd prefer if someone ever adjust this line, manually also needs to adjust the expect cases below, to sanity check that the overwrite logic is really working as expected.

);

describe("FeatureService", () => {
test("should register and unregister features", async () => {
const { result } = renderHook(() => useFeatureService(), {
wrapper,
type FeatureOverwrite = FeatureItem[] | FeatureSet | undefined;

interface FeatureOverwrites {
workspace?: FeatureOverwrite;
user?: FeatureOverwrite;
overwrite?: FeatureOverwrite;
}

/**
* Test utility method to wrap setting all the different level of features, rerender
* with a different set of features and getting the merged feature set.
*/
const getFeatures = (initialProps: FeatureOverwrites) => {
return renderHook(
({ overwrite, user, workspace }: FeatureOverwrites) => {
const { features, setWorkspaceFeatures, setUserFeatures, setFeatureOverwrites } = useFeatureService();
useEffect(() => {
setWorkspaceFeatures(workspace);
}, [setWorkspaceFeatures, workspace]);
useEffect(() => {
setUserFeatures(user);
}, [setUserFeatures, user]);
useEffect(() => {
setFeatureOverwrites(overwrite);
}, [overwrite, setFeatureOverwrites]);
return features;
},
{ wrapper, initialProps }
);
};

describe("Feature Service", () => {
describe("FeatureService", () => {
it("should allow setting default features", () => {
const getFeature = (feature: FeatureItem) => renderHook(() => useFeature(feature), { wrapper }).result.current;
expect(getFeature(FeatureItem.AllowCreateConnection)).toBe(true);
expect(getFeature(FeatureItem.AllowCustomDBT)).toBe(false);
expect(getFeature(FeatureItem.AllowSync)).toBe(true);
expect(getFeature(FeatureItem.AllowUpdateConnectors)).toBe(false);
});

it("workspace features should merge correctly with default features", () => {
expect(
getFeatures({
workspace: [FeatureItem.AllowCustomDBT, FeatureItem.AllowUploadCustomImage],
}).result.current.sort()
).toEqual([
FeatureItem.AllowCreateConnection,
FeatureItem.AllowCustomDBT,
FeatureItem.AllowSync,
FeatureItem.AllowUploadCustomImage,
]);
});

expect(result.current.features).toEqual(predefinedFeatures);
it("workspace features can disable default features", () => {
expect(
getFeatures({
workspace: { [FeatureItem.AllowCustomDBT]: true, [FeatureItem.AllowCreateConnection]: false } as FeatureSet,
}).result.current.sort()
).toEqual([FeatureItem.AllowCustomDBT, FeatureItem.AllowSync]);
});

act(() => {
result.current.registerFeature([
{
id: FeatureItem.AllowCreateConnection,
},
it("user features should merge correctly with workspace and default features", () => {
expect(
getFeatures({
workspace: [FeatureItem.AllowCustomDBT, FeatureItem.AllowUploadCustomImage],
user: [FeatureItem.AllowOAuthConnector],
}).result.current.sort()
).toEqual([
FeatureItem.AllowCreateConnection,
FeatureItem.AllowCustomDBT,
FeatureItem.AllowOAuthConnector,
FeatureItem.AllowSync,
FeatureItem.AllowUploadCustomImage,
]);
});

expect(result.current.features).toEqual([
...predefinedFeatures,
{
id: FeatureItem.AllowCreateConnection,
},
]);
it("user features can disable workspace and default features", () => {
expect(
getFeatures({
workspace: [FeatureItem.AllowCustomDBT, FeatureItem.AllowUploadCustomImage],
user: {
[FeatureItem.AllowOAuthConnector]: true,
[FeatureItem.AllowUploadCustomImage]: false,
[FeatureItem.AllowCreateConnection]: false,
} as FeatureSet,
}).result.current.sort()
).toEqual([FeatureItem.AllowCustomDBT, FeatureItem.AllowOAuthConnector, FeatureItem.AllowSync]);
});

act(() => {
result.current.unregisterFeature([FeatureItem.AllowCreateConnection]);
it("user features can re-enable feature that are disabled per workspace", () => {
expect(
getFeatures({
workspace: { [FeatureItem.AllowCustomDBT]: true, [FeatureItem.AllowSync]: false } as FeatureSet,
user: [FeatureItem.AllowOAuthConnector, FeatureItem.AllowSync],
}).result.current.sort()
).toEqual([
FeatureItem.AllowCreateConnection,
FeatureItem.AllowCustomDBT,
FeatureItem.AllowOAuthConnector,
FeatureItem.AllowSync,
]);
});

expect(result.current.features).toEqual(predefinedFeatures);
it("overwritte features can overwrite workspace and user features", () => {
expect(
getFeatures({
workspace: { [FeatureItem.AllowCustomDBT]: true, [FeatureItem.AllowSync]: false } as FeatureSet,
user: {
[FeatureItem.AllowOAuthConnector]: true,
[FeatureItem.AllowSync]: true,
[FeatureItem.AllowCreateConnection]: false,
} as FeatureSet,
overwrite: [FeatureItem.AllowUploadCustomImage, FeatureItem.AllowCreateConnection],
}).result.current.sort()
).toEqual([
FeatureItem.AllowCreateConnection,
FeatureItem.AllowCustomDBT,
FeatureItem.AllowOAuthConnector,
FeatureItem.AllowSync,
FeatureItem.AllowUploadCustomImage,
]);
});

it("workspace features can be cleared again", () => {
const { result, rerender } = getFeatures({
workspace: { [FeatureItem.AllowCustomDBT]: true, [FeatureItem.AllowSync]: false } as FeatureSet,
});
expect(result.current.sort()).toEqual([FeatureItem.AllowCreateConnection, FeatureItem.AllowCustomDBT]);
rerender({ workspace: undefined });
expect(result.current.sort()).toEqual([FeatureItem.AllowCreateConnection, FeatureItem.AllowSync]);
});

it("user features can be cleared again", () => {
const { result, rerender } = getFeatures({
user: { [FeatureItem.AllowCustomDBT]: true, [FeatureItem.AllowSync]: false } as FeatureSet,
});
expect(result.current.sort()).toEqual([FeatureItem.AllowCreateConnection, FeatureItem.AllowCustomDBT]);
rerender({ user: undefined });
expect(result.current.sort()).toEqual([FeatureItem.AllowCreateConnection, FeatureItem.AllowSync]);
});

it("overwritten features can be cleared again", () => {
const { result, rerender } = getFeatures({
overwrite: { [FeatureItem.AllowCustomDBT]: true, [FeatureItem.AllowSync]: false } as FeatureSet,
});
expect(result.current.sort()).toEqual([FeatureItem.AllowCreateConnection, FeatureItem.AllowCustomDBT]);
rerender({ overwrite: undefined });
expect(result.current.sort()).toEqual([FeatureItem.AllowCreateConnection, FeatureItem.AllowSync]);
});
});
});

describe("useFeatureRegisterValues", () => {
test("should register more than 1 feature", async () => {
const { result } = renderHook(
() => {
useFeatureRegisterValues([{ id: FeatureItem.AllowCreateConnection }]);
useFeatureRegisterValues([{ id: FeatureItem.AllowSync }]);

return useFeatureService();
},
{
initialProps: { initialValue: 0 },
wrapper,
}
);

expect(result.current.features).toEqual([
...predefinedFeatures,
{ id: FeatureItem.AllowCreateConnection },
{ id: FeatureItem.AllowSync },
]);

act(() => {
result.current.unregisterFeature([FeatureItem.AllowCreateConnection]);
describe("IfFeatureEnabled", () => {
it("renders its children if the given feature is enabled", () => {
const { getByTestId } = render(
<IfFeatureEnabled feature={FeatureItem.AllowCreateConnection}>
<span data-testid="content" />
</IfFeatureEnabled>,
{ wrapper }
);
expect(getByTestId("content")).toBeTruthy();
});

expect(result.current.features).toEqual([...predefinedFeatures, { id: FeatureItem.AllowSync }]);
it("does not render its children if the given feature is disabled", () => {
const { queryByTestId } = render(
<IfFeatureEnabled feature={FeatureItem.AllowOAuthConnector}>
<span data-testid="content" />
</IfFeatureEnabled>,
{ wrapper }
);
expect(queryByTestId("content")).toBeFalsy();
});

it("allows changing features and rerenders correctly", () => {
const { queryByTestId, rerender } = render(
<FeatureService features={[FeatureItem.AllowCreateConnection]}>
<IfFeatureEnabled feature={FeatureItem.AllowOAuthConnector}>
<span data-testid="content" />
</IfFeatureEnabled>
</FeatureService>
);
expect(queryByTestId("content")).toBeFalsy();
rerender(
<FeatureService features={[FeatureItem.AllowOAuthConnector]}>
<IfFeatureEnabled feature={FeatureItem.AllowOAuthConnector}>
<span data-testid="content" />
</IfFeatureEnabled>
</FeatureService>
);
expect(queryByTestId("content")).toBeTruthy();
});
});
});
Loading