diff --git a/airbyte-webapp/src/App.tsx b/airbyte-webapp/src/App.tsx index 6536704c38a2b..f444e697ed8fc 100644 --- a/airbyte-webapp/src/App.tsx +++ b/airbyte-webapp/src/App.tsx @@ -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"; @@ -41,7 +41,7 @@ const Services: React.FC = ({ children }) => ( - + diff --git a/airbyte-webapp/src/components/ConnectorBlocks/TableItemTitle.tsx b/airbyte-webapp/src/components/ConnectorBlocks/TableItemTitle.tsx index a8ac818ece2e8..012e373a8fa0b 100644 --- a/airbyte-webapp/src/components/ConnectorBlocks/TableItemTitle.tsx +++ b/airbyte-webapp/src/components/ConnectorBlocks/TableItemTitle.tsx @@ -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"; @@ -55,8 +55,7 @@ const TableItemTitle: React.FC = ({ entityIcon, releaseStage, }) => { - const { hasFeature } = useFeatureService(); - const allowCreateConnection = hasFeature(FeatureItem.AllowCreateConnection); + const allowCreateConnection = useFeature(FeatureItem.AllowCreateConnection); const { formatMessage } = useIntl(); const options = [ { diff --git a/airbyte-webapp/src/components/EntityTable/ConnectionTable.tsx b/airbyte-webapp/src/components/EntityTable/ConnectionTable.tsx index a567fbfcb3790..28fc328a6bd4c 100644 --- a/airbyte-webapp/src/components/EntityTable/ConnectionTable.tsx +++ b/airbyte-webapp/src/components/EntityTable/ConnectionTable.tsx @@ -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"; @@ -32,8 +32,7 @@ interface IProps { const ConnectionTable: React.FC = ({ 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; diff --git a/airbyte-webapp/src/config/defaultConfig.ts b/airbyte-webapp/src/config/defaultConfig.ts index b43dd4ea7ef97..4788f09f68870 100644 --- a/airbyte-webapp/src/config/defaultConfig.ts +++ b/airbyte-webapp/src/config/defaultConfig.ts @@ -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: "" }, @@ -31,7 +10,6 @@ const defaultConfig: Config = { integrationUrl: "/docs", oauthRedirectUrl: `${window.location.protocol}//${window.location.host}`, isDemo: false, - features, }; export { defaultConfig }; diff --git a/airbyte-webapp/src/config/types.ts b/airbyte-webapp/src/config/types.ts index 29054ef080463..bb126cf2c1f8a 100644 --- a/airbyte-webapp/src/config/types.ts +++ b/airbyte-webapp/src/config/types.ts @@ -1,5 +1,4 @@ import { SegmentAnalytics } from "core/analytics/types"; -import { Feature } from "hooks/services/Feature"; import { OutboundLinks } from "./links"; @@ -22,7 +21,6 @@ declare global { export interface Config { links: OutboundLinks; - features: Feature[]; segment: { token: string; enabled: boolean }; apiUrl: string; oauthRedirectUrl: string; diff --git a/airbyte-webapp/src/hooks/services/Feature/FeatureService.test.tsx b/airbyte-webapp/src/hooks/services/Feature/FeatureService.test.tsx index 87aa7a6fba199..4d4708b9c9edd 100644 --- a/airbyte-webapp/src/hooks/services/Feature/FeatureService.test.tsx +++ b/airbyte-webapp/src/hooks/services/Feature/FeatureService.test.tsx @@ -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 }) => ( - - - {children} - - + {children} ); -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( + + + , + { 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( + + + , + { wrapper } + ); + expect(queryByTestId("content")).toBeFalsy(); + }); + + it("allows changing features and rerenders correctly", () => { + const { queryByTestId, rerender } = render( + + + + + + ); + expect(queryByTestId("content")).toBeFalsy(); + rerender( + + + + + + ); + expect(queryByTestId("content")).toBeTruthy(); + }); }); }); diff --git a/airbyte-webapp/src/hooks/services/Feature/FeatureService.tsx b/airbyte-webapp/src/hooks/services/Feature/FeatureService.tsx index cbf53962af248..7ede4dc9cce23 100644 --- a/airbyte-webapp/src/hooks/services/Feature/FeatureService.tsx +++ b/airbyte-webapp/src/hooks/services/Feature/FeatureService.tsx @@ -1,46 +1,81 @@ -import React, { useContext, useMemo, useState } from "react"; -import { useDeepCompareEffect } from "react-use"; +import React, { useCallback, useContext, useMemo, useState } from "react"; -import { useConfig } from "config"; +import { FeatureItem, FeatureSet } from "./types"; -import { Feature, FeatureItem, FeatureServiceApi } from "./types"; +interface FeatureServiceContext { + features: FeatureItem[]; + setWorkspaceFeatures: (features: FeatureItem[] | FeatureSet | undefined) => void; + setUserFeatures: (features: FeatureItem[] | FeatureSet | undefined) => void; + setFeatureOverwrites: (features: FeatureItem[] | FeatureSet | undefined) => void; +} -const featureServiceContext = React.createContext(null); +const featureServiceContext = React.createContext(null); -export const FeatureService = ({ children }: { children: React.ReactNode }) => { - const [additionFeatures, setAdditionFeatures] = useState([]); - const { features: instanceWideFeatures } = useConfig(); +const featureSetFromList = (featureList: FeatureItem[]): FeatureSet => { + return featureList.reduce((set, val) => ({ ...set, [val]: true }), {} as FeatureSet); +}; + +interface FeatureServiceProps { + features: FeatureItem[]; +} - const featureMethods = useMemo(() => { - return { - registerFeature: (newFeatures: Feature[]): void => - setAdditionFeatures((oldFeatures) => [...oldFeatures, ...newFeatures]), - unregisterFeature: (unregisteredFeatures: FeatureItem[]): void => { - setAdditionFeatures((oldFeatures) => - oldFeatures.filter((feature) => !unregisteredFeatures.includes(feature.id)) - ); - }, +/** + * The FeatureService allows tracking support for whether a specific feature should be + * enabled or disabled. The feature can be enabled/disabled on either of the following level: + * + * - globally (the values passed into this service) + * - workspace (can be configured via setWorkspaceFeatures) + * - user (can be configured via setUserFeatures) + * + * In addition via setFeatureOverwrites allow overwriting any features. The priority for configuring + * features is: overwrite > user > workspace > globally, i.e. if a feature is disabled for a user + * it will take precedence over the feature being enabled globally or for that workspace. + */ +export const FeatureService: React.FC = ({ features: defaultFeatures, children }) => { + const [workspaceFeatures, setWorkspaceFeaturesState] = useState(); + const [userFeatures, setUserFeaturesState] = useState(); + const [overwrittenFeatures, setOverwrittenFeaturesState] = useState(); + + const combinedFeatures = useMemo(() => { + const combined: FeatureSet = { + ...featureSetFromList(defaultFeatures), + ...workspaceFeatures, + ...userFeatures, + ...overwrittenFeatures, }; + + return Object.entries(combined) + .filter(([, enabled]) => enabled) + .map(([id]) => id) as FeatureItem[]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceFeatures, userFeatures, overwrittenFeatures, ...defaultFeatures]); + + const setWorkspaceFeatures = useCallback((features: FeatureItem[] | FeatureSet | undefined) => { + setWorkspaceFeaturesState(Array.isArray(features) ? featureSetFromList(features) : features); }, []); - const features = useMemo( - () => [...instanceWideFeatures, ...additionFeatures], - [instanceWideFeatures, additionFeatures] - ); + const setUserFeatures = useCallback((features: FeatureItem[] | FeatureSet | undefined) => { + setUserFeaturesState(Array.isArray(features) ? featureSetFromList(features) : features); + }, []); - const featureService = useMemo( - () => ({ - features, - hasFeature: (featureId: FeatureItem): boolean => !!features.find((feature) => feature.id === featureId), - ...featureMethods, + const setFeatureOverwrites = useCallback((features: FeatureItem[] | FeatureSet | undefined) => { + setOverwrittenFeaturesState(Array.isArray(features) ? featureSetFromList(features) : features); + }, []); + + const serviceContext = useMemo( + (): FeatureServiceContext => ({ + features: combinedFeatures, + setWorkspaceFeatures, + setUserFeatures, + setFeatureOverwrites, }), - [features, featureMethods] + [combinedFeatures, setFeatureOverwrites, setUserFeatures, setWorkspaceFeatures] ); - return {children}; + return {children}; }; -export const useFeatureService: () => FeatureServiceApi = () => { +export const useFeatureService: () => FeatureServiceContext = () => { const featureService = useContext(featureServiceContext); if (!featureService) { throw new Error("useFeatureService must be used within a FeatureService."); @@ -48,23 +83,16 @@ export const useFeatureService: () => FeatureServiceApi = () => { return featureService; }; -export const WithFeature: React.FC<{ featureId: FeatureItem }> = ({ featureId, children }) => { - const { hasFeature } = useFeatureService(); - return hasFeature(featureId) ? <>{children} : null; +/** + * Returns whether a specific feature is enabled currently. + * Will cause the component to rerender if the state of the feature changes. + */ +export const useFeature = (feature: FeatureItem): boolean => { + const { features } = useFeatureService(); + return features.includes(feature); }; -export const useFeatureRegisterValues = (props?: Feature[] | null): void => { - const { registerFeature, unregisterFeature } = useFeatureService(); - - useDeepCompareEffect(() => { - if (!props) { - return; - } - - registerFeature(props); - - return () => unregisterFeature(props.map((feature: Feature) => feature.id)); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props]); +export const IfFeatureEnabled: React.FC<{ feature: FeatureItem }> = ({ feature, children }) => { + const hasFeature = useFeature(feature); + return hasFeature ? <>{children} : null; }; diff --git a/airbyte-webapp/src/hooks/services/Feature/constants.ts b/airbyte-webapp/src/hooks/services/Feature/constants.ts new file mode 100644 index 0000000000000..2a8f6179bd10a --- /dev/null +++ b/airbyte-webapp/src/hooks/services/Feature/constants.ts @@ -0,0 +1,10 @@ +import { FeatureItem } from "./types"; + +/** The default feature set that OSS releases should use. */ +export const defaultFeatures = [ + FeatureItem.AllowCreateConnection, + FeatureItem.AllowCustomDBT, + FeatureItem.AllowSync, + FeatureItem.AllowUpdateConnectors, + FeatureItem.AllowUploadCustomImage, +]; diff --git a/airbyte-webapp/src/hooks/services/Feature/index.tsx b/airbyte-webapp/src/hooks/services/Feature/index.tsx index 04eed13b4053a..db18dc2b3456f 100644 --- a/airbyte-webapp/src/hooks/services/Feature/index.tsx +++ b/airbyte-webapp/src/hooks/services/Feature/index.tsx @@ -1,2 +1,3 @@ export * from "./FeatureService"; export * from "./types"; +export { defaultFeatures } from "./constants"; diff --git a/airbyte-webapp/src/hooks/services/Feature/types.tsx b/airbyte-webapp/src/hooks/services/Feature/types.tsx index 00043aec62638..f847aa37d2e26 100644 --- a/airbyte-webapp/src/hooks/services/Feature/types.tsx +++ b/airbyte-webapp/src/hooks/services/Feature/types.tsx @@ -7,15 +7,4 @@ export enum FeatureItem { AllowSync = "ALLOW_SYNC", } -interface Feature { - id: FeatureItem; -} - -interface FeatureServiceApi { - features: Feature[]; - registerFeature: (props: Feature[]) => void; - unregisterFeature: (props: FeatureItem[]) => void; - hasFeature: (featureId: FeatureItem) => boolean; -} - -export type { Feature, FeatureServiceApi }; +export type FeatureSet = Record; diff --git a/airbyte-webapp/src/packages/cloud/App.tsx b/airbyte-webapp/src/packages/cloud/App.tsx index efcd8a3d263ba..353db8ebbe26b 100644 --- a/airbyte-webapp/src/packages/cloud/App.tsx +++ b/airbyte-webapp/src/packages/cloud/App.tsx @@ -8,7 +8,7 @@ import LoadingPage from "components/LoadingPage"; import { I18nProvider } from "core/i18n"; import { ConfirmationModalService } from "hooks/services/ConfirmationModal"; -import { FeatureService } from "hooks/services/Feature"; +import { FeatureItem, FeatureService } from "hooks/services/Feature"; import { FormChangeTrackerService } from "hooks/services/FormChangeTracker"; import NotificationServiceProvider from "hooks/services/Notification"; import en from "locales/en.json"; @@ -38,7 +38,9 @@ const Services: React.FC = ({ children }) => ( - + {children} diff --git a/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx b/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx index 0e85c23624911..a7d92c53da779 100644 --- a/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx +++ b/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useMemo } from "react"; +import React, { Suspense, useEffect, useMemo } from "react"; import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { useEffectOnce } from "react-use"; @@ -7,7 +7,7 @@ import LoadingPage from "components/LoadingPage"; import { useAnalyticsIdentifyUser, useAnalyticsRegisterValues } from "hooks/services/Analytics/useAnalyticsService"; import { useTrackPageAnalytics } from "hooks/services/Analytics/useTrackPageAnalytics"; -import { FeatureItem, useFeatureRegisterValues } from "hooks/services/Feature"; +import { FeatureItem, FeatureSet, useFeatureService } from "hooks/services/Feature"; import { useApiHealthPoll } from "hooks/services/Health"; import { OnboardingServiceProvider } from "hooks/services/Onboarding"; import useRouter from "hooks/useRouter"; @@ -56,9 +56,24 @@ export const CloudRoutes = { } as const; const MainRoutes: React.FC = () => { + const { setWorkspaceFeatures } = useFeatureService(); const workspace = useCurrentWorkspace(); const cloudWorkspace = useGetCloudWorkspace(workspace.workspaceId); + useEffect(() => { + const outOfCredits = + cloudWorkspace.creditStatus === CreditStatus.NEGATIVE_BEYOND_GRACE_PERIOD || + cloudWorkspace.creditStatus === CreditStatus.NEGATIVE_MAX_THRESHOLD; + // If the workspace is out of credits it doesn't allow creation of new connections + // or syncing existing connections. + setWorkspaceFeatures( + outOfCredits ? ({ [FeatureItem.AllowCreateConnection]: false, [FeatureItem.AllowSync]: false } as FeatureSet) : [] + ); + return () => { + setWorkspaceFeatures(undefined); + }; + }, [cloudWorkspace.creditStatus, setWorkspaceFeatures]); + const analyticsContext = useMemo( () => ({ workspace_id: workspace.workspaceId, @@ -70,17 +85,6 @@ const MainRoutes: React.FC = () => { const mainNavigate = workspace.displaySetupWizard ? RoutePaths.Onboarding : RoutePaths.Connections; - const features = useMemo( - () => - cloudWorkspace.creditStatus !== CreditStatus.NEGATIVE_BEYOND_GRACE_PERIOD && - cloudWorkspace.creditStatus !== CreditStatus.NEGATIVE_MAX_THRESHOLD - ? [{ id: FeatureItem.AllowCreateConnection }, { id: FeatureItem.AllowSync }] - : null, - [cloudWorkspace] - ); - - useFeatureRegisterValues(features); - return ( diff --git a/airbyte-webapp/src/packages/cloud/services/config/index.ts b/airbyte-webapp/src/packages/cloud/services/config/index.ts index ff2df46ad56f2..877d7351ba024 100644 --- a/airbyte-webapp/src/packages/cloud/services/config/index.ts +++ b/airbyte-webapp/src/packages/cloud/services/config/index.ts @@ -1,5 +1,4 @@ -import { defaultConfig as coreDefaultConfig, useConfig as useCoreConfig, Config } from "config"; -import { FeatureItem } from "hooks/services/Feature"; +import { defaultConfig as coreDefaultConfig, useConfig as useCoreConfig } from "config"; import { CloudConfig, CloudConfigExtension } from "./types"; @@ -7,16 +6,6 @@ export function useConfig(): CloudConfig { return useCoreConfig(); } -const features = [ - { - id: FeatureItem.AllowOAuthConnector, - }, -]; - -const coreDefaultConfigOverrites: Partial = { - features, -}; - const cloudConfigExtensionDefault: CloudConfigExtension = { cloudApiUrl: "", firebase: { @@ -35,7 +24,6 @@ const cloudConfigExtensionDefault: CloudConfigExtension = { export const defaultConfig: CloudConfig = { ...coreDefaultConfig, - ...coreDefaultConfigOverrites, ...cloudConfigExtensionDefault, }; diff --git a/airbyte-webapp/src/packages/cloud/services/thirdParty/launchdarkly/LDExperimentService.tsx b/airbyte-webapp/src/packages/cloud/services/thirdParty/launchdarkly/LDExperimentService.tsx index 5667b89dfff26..d8c37f51b996f 100644 --- a/airbyte-webapp/src/packages/cloud/services/thirdParty/launchdarkly/LDExperimentService.tsx +++ b/airbyte-webapp/src/packages/cloud/services/thirdParty/launchdarkly/LDExperimentService.tsx @@ -11,6 +11,7 @@ import { useI18nContext } from "core/i18n"; import { useAnalytics } from "hooks/services/Analytics"; import { ExperimentProvider, ExperimentService } from "hooks/services/Experiment"; import type { Experiments } from "hooks/services/Experiment/experiments"; +import { FeatureSet, useFeatureService } from "hooks/services/Feature"; import { User } from "packages/cloud/lib/domain/users"; import { useAuthService } from "packages/cloud/services/auth/AuthService"; import { rejectAfter } from "utils/promises"; @@ -21,6 +22,8 @@ import { rejectAfter } from "utils/promises"; */ const INITIALIZATION_TIMEOUT = 1500; +const FEATURE_FLAG_EXPERIMENT = "featureService.overwrites"; + type LDInitState = "initializing" | "failed" | "initialized"; function mapUserToLDUser(user: User | null, locale: string): LDClient.LDUser { @@ -39,6 +42,7 @@ function mapUserToLDUser(user: User | null, locale: string): LDClient.LDUser { } const LDInitializationWrapper: React.FC<{ apiKey: string }> = ({ children, apiKey }) => { + const { setFeatureOverwrites } = useFeatureService(); const ldClient = useRef(); const [state, setState] = useState("initializing"); const { user } = useAuthService(); @@ -61,6 +65,23 @@ const LDInitializationWrapper: React.FC<{ apiKey: string }> = ({ children, apiKe setMessageOverwrite(Object.fromEntries(messageOverwrites)); }; + /** + * Update the feature overwrites based on the LaunchDarkly value. + * It's expected to be a comma separated list of features (the values + * of the enum) that should be enabled. Each can be prefixed with "-" + * to disable the feature instead. + */ + const updateFeatureOverwrites = (featureOverwriteString: string) => { + const featureSet = featureOverwriteString.split(",").reduce((featureSet, featureString) => { + const [key, enabled] = featureString.startsWith("-") ? [featureString.slice(1), false] : [featureString, true]; + return { + ...featureSet, + [key]: enabled, + }; + }, {} as FeatureSet); + setFeatureOverwrites(featureSet); + }; + if (!ldClient.current) { ldClient.current = LDClient.initialize(apiKey, mapUserToLDUser(user, locale)); // Wait for either LaunchDarkly to initialize or a specific timeout to pass first @@ -75,6 +96,7 @@ const LDInitializationWrapper: React.FC<{ apiKey: string }> = ({ children, apiKe addAnalyticsContext({ experiments: ldClient.current?.allFlags() }); // Check for overwritten i18n messages updateI18nMessages(); + updateFeatureOverwrites(ldClient.current?.variation(FEATURE_FLAG_EXPERIMENT, "")); }) .catch((reason) => { // If the promise fails, either because LaunchDarkly service fails to initialize, or @@ -85,6 +107,14 @@ const LDInitializationWrapper: React.FC<{ apiKey: string }> = ({ children, apiKe }); } + useEffectOnce(() => { + const onFeatureServiceCange = (newOverwrites: string) => { + updateFeatureOverwrites(newOverwrites); + }; + ldClient.current?.on(`change:${FEATURE_FLAG_EXPERIMENT}`, onFeatureServiceCange); + return () => ldClient.current?.off(`change:${FEATURE_FLAG_EXPERIMENT}`, onFeatureServiceCange); + }); + useEffectOnce(() => { const onFeatureFlagsChanged = () => { // Update analytics context whenever a flag changes diff --git a/airbyte-webapp/src/packages/cloud/services/workspaces/WorkspacesService.tsx b/airbyte-webapp/src/packages/cloud/services/workspaces/WorkspacesService.tsx index 516ecb03afd68..77f11904a525d 100644 --- a/airbyte-webapp/src/packages/cloud/services/workspaces/WorkspacesService.tsx +++ b/airbyte-webapp/src/packages/cloud/services/workspaces/WorkspacesService.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { QueryObserverResult, useMutation, useQuery, useQueryClient } from "react-query"; import { CloudWorkspacesService } from "packages/cloud/lib/domain/cloudWorkspaces/CloudWorkspacesService"; -import type { CloudWorkspace, CloudWorkspaceUsage } from "packages/cloud/lib/domain/cloudWorkspaces/types"; +import { CloudWorkspace, CloudWorkspaceUsage } from "packages/cloud/lib/domain/cloudWorkspaces/types"; import { useCurrentUser } from "packages/cloud/services/auth/AuthService"; import { useConfig } from "packages/cloud/services/config"; import { SCOPE_USER } from "services/Scope"; diff --git a/airbyte-webapp/src/packages/cloud/views/layout/SideBar/SideBar.tsx b/airbyte-webapp/src/packages/cloud/views/layout/SideBar/SideBar.tsx index 0a2e2bc61d306..6847d9e8fc147 100644 --- a/airbyte-webapp/src/packages/cloud/views/layout/SideBar/SideBar.tsx +++ b/airbyte-webapp/src/packages/cloud/views/layout/SideBar/SideBar.tsx @@ -8,7 +8,7 @@ import styled from "styled-components"; import { Link } from "components"; -import { FeatureItem, WithFeature } from "hooks/services/Feature"; +import { FeatureItem, IfFeatureEnabled } from "hooks/services/Feature"; import { useCurrentWorkspace } from "hooks/services/useWorkspace"; import { CloudRoutes } from "packages/cloud/cloudRoutes"; import { useIntercom } from "packages/cloud/services/thirdParty/intercom"; @@ -198,11 +198,11 @@ const SideBar: React.FC = () => {
  • - + - + diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/AllConnectionsPage/AllConnectionsPage.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/AllConnectionsPage/AllConnectionsPage.tsx index 3a402f14589bd..624eccd483c62 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/AllConnectionsPage/AllConnectionsPage.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/AllConnectionsPage/AllConnectionsPage.tsx @@ -5,7 +5,7 @@ import { Button, LoadingPage, MainPageWithScroll, PageTitle } from "components"; import { EmptyResourceListView } from "components/EmptyResourceListView"; import HeadTitle from "components/HeadTitle"; -import { FeatureItem, useFeatureService } from "hooks/services/Feature"; +import { FeatureItem, useFeature } from "hooks/services/Feature"; import { useConnectionList } from "hooks/services/useConnectionHook"; import useRouter from "hooks/useRouter"; @@ -16,8 +16,7 @@ const AllConnectionsPage: React.FC = () => { const { push } = useRouter(); const { connections } = useConnectionList(); - const { hasFeature } = useFeatureService(); - const allowCreateConnection = hasFeature(FeatureItem.AllowCreateConnection); + const allowCreateConnection = useFeature(FeatureItem.AllowCreateConnection); const onCreateClick = () => push(`${RoutePaths.ConnectionNew}`); diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusMainInfo.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusMainInfo.tsx index b0e588d405772..d46fe42e998a0 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusMainInfo.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusMainInfo.tsx @@ -8,7 +8,7 @@ import ConnectorCard from "components/ConnectorCard"; import { getFrequencyConfig } from "config/utils"; import { ConnectionStatus, SourceRead, DestinationRead, WebBackendConnectionRead } from "core/request/AirbyteClient"; -import { FeatureItem, useFeatureService } from "hooks/services/Feature"; +import { FeatureItem, useFeature } from "hooks/services/Feature"; import { RoutePaths } from "pages/routePaths"; import { useDestinationDefinition } from "services/connector/DestinationDefinitionService"; import { useSourceDefinition } from "services/connector/SourceDefinitionService"; @@ -48,12 +48,10 @@ export const StatusMainInfo: React.FC = ({ source, destination, }) => { - const { hasFeature } = useFeatureService(); - const sourceDefinition = useSourceDefinition(source.sourceDefinitionId); const destinationDefinition = useDestinationDefinition(destination.destinationDefinitionId); - const allowSync = hasFeature(FeatureItem.AllowSync); + const allowSync = useFeature(FeatureItem.AllowSync); const frequency = getFrequencyConfig(connection.schedule); const sourceConnectionPath = `../../${RoutePaths.Source}/${source.sourceId}`; diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusView.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusView.tsx index 7b0fdf638d479..f30153b47e57d 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusView.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusView.tsx @@ -11,7 +11,7 @@ import ToolTip from "components/ToolTip"; import { ConnectionStatus, WebBackendConnectionRead } from "core/request/AirbyteClient"; import Status from "core/statuses"; import { useConfirmationModalService } from "hooks/services/ConfirmationModal"; -import { FeatureItem, useFeatureService } from "hooks/services/Feature"; +import { FeatureItem, useFeature } from "hooks/services/Feature"; import { useResetConnection, useSyncConnection } from "hooks/services/useConnectionHook"; import useLoadingState from "hooks/useLoadingState"; import { useListJobs } from "services/job/JobService"; @@ -53,8 +53,7 @@ const SyncButton = styled(LoadingButton)` const StatusView: React.FC = ({ connection, isStatusUpdating }) => { const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService(); const { isLoading, showFeedback, startAction } = useLoadingState(); - const { hasFeature } = useFeatureService(); - const allowSync = hasFeature(FeatureItem.AllowSync); + const allowSync = useFeature(FeatureItem.AllowSync); const jobs = useListJobs({ configId: connection.connectionId, diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/TransformationView.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/TransformationView.tsx index a1854da52a14b..f3f211b8bae6b 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/TransformationView.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/TransformationView.tsx @@ -7,7 +7,7 @@ import styled from "styled-components"; import { ContentCard, H4 } from "components"; import { buildConnectionUpdate, NormalizationType } from "core/domain/connection"; -import { FeatureItem, useFeatureService } from "hooks/services/Feature"; +import { FeatureItem, useFeature } from "hooks/services/Feature"; import { useUpdateConnection } from "hooks/services/useConnectionHook"; import { useCurrentWorkspace } from "hooks/services/useWorkspace"; import { useGetDestinationDefinitionSpecification } from "services/connector/DestinationDefinitionSpecificationService"; @@ -124,10 +124,9 @@ const TransformationView: React.FC = ({ connection }) = const definition = useGetDestinationDefinitionSpecification(connection.destination.destinationDefinitionId); const { mutateAsync: updateConnection } = useUpdateConnection(); const workspace = useCurrentWorkspace(); - const { hasFeature } = useFeatureService(); const { supportsNormalization } = definition; - const supportsDbt = hasFeature(FeatureItem.AllowCustomDBT) && definition.supportsDbt; + const supportsDbt = useFeature(FeatureItem.AllowCustomDBT) && definition.supportsDbt; const mode = connection.status === ConnectionStatus.deprecated ? "readonly" : "edit"; diff --git a/airbyte-webapp/src/pages/SettingsPage/pages/ConnectorsPage/components/ConnectorsView.tsx b/airbyte-webapp/src/pages/SettingsPage/pages/ConnectorsPage/components/ConnectorsView.tsx index 055082a788e6c..351b13f4375e3 100644 --- a/airbyte-webapp/src/pages/SettingsPage/pages/ConnectorsPage/components/ConnectorsView.tsx +++ b/airbyte-webapp/src/pages/SettingsPage/pages/ConnectorsPage/components/ConnectorsView.tsx @@ -8,7 +8,7 @@ import Table from "components/Table"; import { Connector, ConnectorDefinition } from "core/domain/connector"; import { DestinationDefinitionRead, SourceDefinitionRead } from "core/request/AirbyteClient"; import { useAvailableConnectorDefinitions } from "hooks/domain/connector/useAvailableConnectorDefinitions"; -import { FeatureItem, useFeatureService, WithFeature } from "hooks/services/Feature"; +import { FeatureItem, IfFeatureEnabled, useFeature } from "hooks/services/Feature"; import { useCurrentWorkspace } from "hooks/services/useWorkspace"; import ConnectorCell from "./ConnectorCell"; @@ -45,8 +45,7 @@ const ConnectorsView: React.FC = ({ onUpdate, connectorsDefinitions, }) => { - const { hasFeature } = useFeatureService(); - const allowUpdateConnectors = hasFeature(FeatureItem.AllowUpdateConnectors); + const allowUpdateConnectors = useFeature(FeatureItem.AllowUpdateConnectors); const workspace = useCurrentWorkspace(); const availableConnectorDefinitions = useAvailableConnectorDefinitions(connectorsDefinitions, workspace); @@ -109,9 +108,9 @@ const ConnectorsView: React.FC = ({ ((section === "used" && usedConnectorsDefinitions.length > 0) || (section === "available" && usedConnectorsDefinitions.length === 0)) && (
    - + - + {(hasNewConnectorVersion || isUpdateSuccess) && allowUpdateConnectors && ( - + diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/OperationsSection.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/components/OperationsSection.tsx index b4ca5ea89100e..1f5090b43abc6 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/components/OperationsSection.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/OperationsSection.tsx @@ -3,7 +3,7 @@ import React from "react"; import { useIntl } from "react-intl"; import styled from "styled-components"; -import { FeatureItem, useFeatureService } from "hooks/services/Feature"; +import { FeatureItem, useFeature } from "hooks/services/Feature"; import { DestinationDefinitionSpecificationRead } from "../../../../core/request/AirbyteClient"; import { useDefaultTransformation } from "../formConfig"; @@ -28,10 +28,9 @@ export const OperationsSection: React.FC = ({ onEndEditTransformation, }) => { const { formatMessage } = useIntl(); - const { hasFeature } = useFeatureService(); const { supportsNormalization } = destDefinition; - const supportsTransformations = destDefinition.supportsDbt && hasFeature(FeatureItem.AllowCustomDBT); + const supportsTransformations = useFeature(FeatureItem.AllowCustomDBT) && destDefinition.supportsDbt; const defaultTransformation = useDefaultTransformation(); diff --git a/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/auth/AuthSection.tsx b/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/auth/AuthSection.tsx index 0e5c55ced9931..b362e642750e3 100644 --- a/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/auth/AuthSection.tsx +++ b/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/auth/AuthSection.tsx @@ -1,16 +1,16 @@ import React from "react"; -import { FeatureItem, WithFeature } from "hooks/services/Feature"; +import { FeatureItem, IfFeatureEnabled } from "hooks/services/Feature"; import { SectionContainer } from "../common"; import { AuthButton } from "./AuthButton"; export const AuthSection: React.FC = () => { return ( - + - + ); }; diff --git a/airbyte-webapp/src/views/Connector/ServiceForm/serviceFormContext.tsx b/airbyte-webapp/src/views/Connector/ServiceForm/serviceFormContext.tsx index 3937e364c6a3e..85e4a17b57b18 100644 --- a/airbyte-webapp/src/views/Connector/ServiceForm/serviceFormContext.tsx +++ b/airbyte-webapp/src/views/Connector/ServiceForm/serviceFormContext.tsx @@ -3,7 +3,7 @@ import React, { useContext, useMemo } from "react"; import { Connector, ConnectorDefinition, ConnectorDefinitionSpecification } from "core/domain/connector"; import { WidgetConfigMap } from "core/form/types"; -import { FeatureItem, useFeatureService } from "hooks/services/Feature"; +import { FeatureItem, useFeature } from "hooks/services/Feature"; import { ServiceFormValues } from "./types"; import { makeConnectionConfigurationPath, serverProvidedOauthPaths } from "./utils"; @@ -56,7 +56,7 @@ const ServiceFormContextProvider: React.FC<{ isEditMode, }) => { const { values } = useFormikContext(); - const { hasFeature } = useFeatureService(); + const allowOAuthConnector = useFeature(FeatureItem.AllowOAuthConnector); const { serviceType } = values; const selectedService = useMemo( @@ -66,11 +66,11 @@ const ServiceFormContextProvider: React.FC<{ const isAuthFlowSelected = useMemo( () => - hasFeature(FeatureItem.AllowOAuthConnector) && + allowOAuthConnector && selectedConnector?.advancedAuth && selectedConnector?.advancedAuth.predicateValue === getIn(getValues(values), makeConnectionConfigurationPath(selectedConnector?.advancedAuth.predicateKey ?? [])), - [selectedConnector, hasFeature, values, getValues] + [selectedConnector, allowOAuthConnector, values, getValues] ); const authFieldsToHide = useMemo( diff --git a/airbyte-webapp/src/views/Connector/ServiceForm/useBuildForm.tsx b/airbyte-webapp/src/views/Connector/ServiceForm/useBuildForm.tsx index fe5e9808af4a3..d33214db2c30c 100644 --- a/airbyte-webapp/src/views/Connector/ServiceForm/useBuildForm.tsx +++ b/airbyte-webapp/src/views/Connector/ServiceForm/useBuildForm.tsx @@ -11,7 +11,7 @@ import { buildPathInitialState } from "core/form/uiWidget"; import { applyFuncAt, removeNestedPaths } from "core/jsonSchema"; import { jsonSchemaToUiWidget } from "core/jsonSchema/schemaToUiWidget"; import { buildYupFormForJsonSchema } from "core/jsonSchema/schemaToYup"; -import { FeatureItem, useFeatureService } from "hooks/services/Feature"; +import { FeatureItem, useFeature } from "hooks/services/Feature"; import { DestinationDefinitionSpecificationRead } from "../../../core/request/AirbyteClient"; import { ServiceFormValues } from "./types"; @@ -45,10 +45,10 @@ function upgradeSchemaLegacyAuth( function useBuildInitialSchema( connectorSpecification?: ConnectorDefinitionSpecification ): JSONSchema7Definition | undefined { - const { hasFeature } = useFeatureService(); + const allowOAuthConnector = useFeature(FeatureItem.AllowOAuthConnector); return useMemo(() => { - if (hasFeature(FeatureItem.AllowOAuthConnector)) { + if (allowOAuthConnector) { if (connectorSpecification?.authSpecification && !connectorSpecification?.advancedAuth) { return upgradeSchemaLegacyAuth({ connectionSpecification: connectorSpecification?.connectionSpecification, @@ -58,7 +58,7 @@ function useBuildInitialSchema( } return connectorSpecification?.connectionSpecification as JSONSchema7Definition | undefined; - }, [hasFeature, connectorSpecification]); + }, [allowOAuthConnector, connectorSpecification]); } function useBuildForm(