diff --git a/airbyte-webapp/src/components/ConnectorBlocks/TableItemTitle.tsx b/airbyte-webapp/src/components/ConnectorBlocks/TableItemTitle.tsx index 811bc686546f..6883c93cea0d 100644 --- a/airbyte-webapp/src/components/ConnectorBlocks/TableItemTitle.tsx +++ b/airbyte-webapp/src/components/ConnectorBlocks/TableItemTitle.tsx @@ -6,6 +6,7 @@ import { Button, DropDownRow, H3, H5 } from "components"; import { Popout } from "components/base/Popout/Popout"; import { ReleaseStageBadge } from "components/ReleaseStageBadge"; import { ReleaseStage } from "core/domain/connector"; +import { FeatureItem, useFeatureService } from "hooks/services/Feature"; type IProps = { type: "source" | "destination"; @@ -53,6 +54,8 @@ const TableItemTitle: React.FC = ({ entityIcon, releaseStage, }) => { + const { hasFeature } = useFeatureService(); + const allowCreateConnection = hasFeature(FeatureItem.AllowCreateConnection); const formatMessage = useIntl().formatMessage; const options = [ { @@ -94,7 +97,7 @@ const TableItemTitle: React.FC = ({ }} onChange={onSelect} targetComponent={({ onOpen }) => ( - )} diff --git a/airbyte-webapp/src/components/EntityTable/ConnectionTable.tsx b/airbyte-webapp/src/components/EntityTable/ConnectionTable.tsx index 804d166eb80e..2018b29f31a4 100644 --- a/airbyte-webapp/src/components/EntityTable/ConnectionTable.tsx +++ b/airbyte-webapp/src/components/EntityTable/ConnectionTable.tsx @@ -15,6 +15,7 @@ import StatusCell from "./components/StatusCell"; import ConnectionSettingsCell from "./components/ConnectionSettingsCell"; import { ITableDataItem, SortOrderEnum } from "./types"; import useRouter from "hooks/useRouter"; +import { FeatureItem, useFeatureService } from "hooks/services/Feature"; const Content = styled.div` margin: 0 32px 0 27px; @@ -36,6 +37,8 @@ const ConnectionTable: React.FC = ({ onSync, }) => { const { query, push } = useRouter(); + const { hasFeature } = useFeatureService(); + const allowSync = hasFeature(FeatureItem.AllowSync); const sortBy = query.sortBy || "entity"; const sortOrder = query.order || SortOrderEnum.ASC; @@ -165,6 +168,7 @@ const ConnectionTable: React.FC = ({ isManual={!row.original.schedule} onChangeStatus={onChangeStatus} onSync={onSync} + allowSync={allowSync} /> ), }, @@ -177,7 +181,7 @@ const ConnectionTable: React.FC = ({ ), }, ], - [entity, onChangeStatus, onSync, onSortClick, sortBy, sortOrder] + [allowSync, entity, onChangeStatus, onSync, onSortClick, sortBy, sortOrder] ); return ( diff --git a/airbyte-webapp/src/components/EntityTable/components/StatusCell.tsx b/airbyte-webapp/src/components/EntityTable/components/StatusCell.tsx index 2b28d01e131a..471047437dc0 100644 --- a/airbyte-webapp/src/components/EntityTable/components/StatusCell.tsx +++ b/airbyte-webapp/src/components/EntityTable/components/StatusCell.tsx @@ -6,6 +6,7 @@ import { useAsyncFn } from "react-use"; import { LoadingButton, Toggle } from "components"; type IProps = { + allowSync?: boolean; enabled?: boolean; isSyncing?: boolean; isManual?: boolean; @@ -29,6 +30,7 @@ const StatusCell: React.FC = ({ onChangeStatus, isSyncing, onSync, + allowSync, }) => { const [{ loading }, OnLaunch] = useAsyncFn( async (event: React.SyntheticEvent) => { @@ -44,7 +46,13 @@ const StatusCell: React.FC = ({ }; if (!isManual) { - return ; + return ( + + ); } if (isSyncing) { @@ -56,7 +64,7 @@ const StatusCell: React.FC = ({ } return ( - + ); diff --git a/airbyte-webapp/src/config/ConfigServiceProvider.tsx b/airbyte-webapp/src/config/ConfigServiceProvider.tsx index fed03a91ad71..7b1a6f8b1ae9 100644 --- a/airbyte-webapp/src/config/ConfigServiceProvider.tsx +++ b/airbyte-webapp/src/config/ConfigServiceProvider.tsx @@ -6,7 +6,7 @@ import { LoadingPage } from "components"; import { Config, ValueProvider } from "./types"; import { applyProviders } from "./configProviders"; -type ConfigContext = { +export type ConfigContext = { config: T; }; diff --git a/airbyte-webapp/src/config/defaultConfig.ts b/airbyte-webapp/src/config/defaultConfig.ts index b187c6856c40..d630258ceb3f 100644 --- a/airbyte-webapp/src/config/defaultConfig.ts +++ b/airbyte-webapp/src/config/defaultConfig.ts @@ -13,6 +13,12 @@ const features: Feature[] = [ { id: FeatureItem.AllowUpdateConnectors, }, + { + id: FeatureItem.AllowCreateConnection, + }, + { + id: FeatureItem.AllowSync, + }, ]; const defaultConfig: Config = { diff --git a/airbyte-webapp/src/hooks/services/Feature/FeatureService.test.tsx b/airbyte-webapp/src/hooks/services/Feature/FeatureService.test.tsx new file mode 100644 index 000000000000..4adba2e3dc12 --- /dev/null +++ b/airbyte-webapp/src/hooks/services/Feature/FeatureService.test.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { act, renderHook } from "@testing-library/react-hooks"; +import { + FeatureService, + useFeatureRegisterValues, + useFeatureService, +} from "./FeatureService"; +import { FeatureItem } from "./types"; +import { TestWrapper } from "utils/testutils"; +import { ConfigContext, configContext } from "config"; + +const predefinedFeatures = [ + { + id: FeatureItem.AllowCustomDBT, + }, +]; + +const wrapper: React.FC = ({ children }) => ( + + + {children} + + +); + +describe("FeatureService", () => { + test("should register and unregister features", async () => { + const { result } = renderHook(() => useFeatureService(), { + wrapper, + }); + + expect(result.current.features).toEqual(predefinedFeatures); + + act(() => { + result.current.registerFeature([ + { + id: FeatureItem.AllowCreateConnection, + }, + ]); + }); + + expect(result.current.features).toEqual([ + ...predefinedFeatures, + { + id: FeatureItem.AllowCreateConnection, + }, + ]); + + act(() => { + result.current.unregisterFeature([FeatureItem.AllowCreateConnection]); + }); + + expect(result.current.features).toEqual(predefinedFeatures); + }); +}); + +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]); + }); + + expect(result.current.features).toEqual([ + ...predefinedFeatures, + { id: FeatureItem.AllowSync }, + ]); + }); +}); diff --git a/airbyte-webapp/src/hooks/services/Feature/FeatureService.tsx b/airbyte-webapp/src/hooks/services/Feature/FeatureService.tsx index 517a7079ac12..716284993f7b 100644 --- a/airbyte-webapp/src/hooks/services/Feature/FeatureService.tsx +++ b/airbyte-webapp/src/hooks/services/Feature/FeatureService.tsx @@ -1,25 +1,43 @@ -import React, { useContext, useMemo } from "react"; +import React, { useContext, useMemo, useState } from "react"; import { Feature, FeatureItem, FeatureServiceApi } from "./types"; import { useConfig } from "config"; +import { useDeepCompareEffect } from "react-use"; const featureServiceContext = React.createContext( null ); -export function FeatureService({ - children, -}: { - children: React.ReactNode; - features?: Feature[]; -}) { - const { features } = useConfig(); +export function FeatureService({ children }: { children: React.ReactNode }) { + const [additionFeatures, setAdditionFeatures] = useState([]); + const { features: instanceWideFeatures } = useConfig(); + + 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) + ) + ); + }, + }; + }, []); + + const features = useMemo( + () => [...instanceWideFeatures, ...additionFeatures], + [instanceWideFeatures, additionFeatures] + ); + const featureService = useMemo( () => ({ features, hasFeature: (featureId: FeatureItem): boolean => !!features.find((feature) => feature.id === featureId), + ...featureMethods, }), - [features] + [features, featureMethods] ); return ( @@ -44,3 +62,19 @@ export const WithFeature: React.FC<{ featureId: FeatureItem }> = ({ const { hasFeature } = useFeatureService(); return hasFeature(featureId) ? <>{children} : null; }; + +export const useFeatureRegisterValues = (props?: Feature[] | null): void => { + const { registerFeature, unregisterFeature } = useFeatureService(); + + useDeepCompareEffect(() => { + if (props) { + registerFeature(props); + + return () => + unregisterFeature(props.map((feature: Feature) => feature.id)); + } + + return; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props]); +}; diff --git a/airbyte-webapp/src/hooks/services/Feature/types.tsx b/airbyte-webapp/src/hooks/services/Feature/types.tsx index 70e58db98a34..26be96a7be85 100644 --- a/airbyte-webapp/src/hooks/services/Feature/types.tsx +++ b/airbyte-webapp/src/hooks/services/Feature/types.tsx @@ -3,6 +3,8 @@ export enum FeatureItem { AllowCustomDBT = "ALLOW_CUSTOM_DBT", AllowUpdateConnectors = "ALLOW_UPDATE_CONNECTORS", AllowOAuthConnector = "ALLOW_OAUTH_CONNECTOR", + AllowCreateConnection = "ALLOW_CREATE_CONNECTION", + AllowSync = "ALLOW_SYNC", } type Feature = { @@ -11,6 +13,8 @@ type Feature = { type FeatureServiceApi = { features: Feature[]; + registerFeature: (props: Feature[]) => void; + unregisterFeature: (props: FeatureItem[]) => void; hasFeature: (featureId: FeatureItem) => boolean; }; diff --git a/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx b/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx index e22dab7341ab..b82433241e68 100644 --- a/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx +++ b/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx @@ -36,6 +36,9 @@ import { storeUtmFromQuery } from "utils/utmStorage"; import { DefaultView } from "./views/DefaultView"; import { hasFromState } from "utils/stateUtils"; import { RoutePaths } from "../../pages/routePaths"; +import { FeatureItem, useFeatureRegisterValues } from "hooks/services/Feature"; +import { useGetCloudWorkspace } from "./services/workspaces/WorkspacesService"; +import { CreditStatus } from "./lib/domain/cloudWorkspaces/types"; export const CloudRoutes = { Root: "/", @@ -60,6 +63,7 @@ export const CloudRoutes = { const MainRoutes: React.FC = () => { const workspace = useCurrentWorkspace(); + const cloudWorkspace = useGetCloudWorkspace(workspace.workspaceId); const analyticsContext = useMemo( () => ({ @@ -74,6 +78,21 @@ const MainRoutes: React.FC = () => { ? 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 ( { @@ -18,6 +19,8 @@ const AllConnectionsPage: React.FC = () => { const { connections } = useResource(ConnectionResource.listShape(), { workspaceId: workspace.workspaceId, }); + const { hasFeature } = useFeatureService(); + const allowCreateConnection = hasFeature(FeatureItem.AllowCreateConnection); const onClick = () => push(`${RoutePaths.ConnectionNew}`); @@ -28,7 +31,7 @@ const AllConnectionsPage: React.FC = () => { } endComponent={ - } 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 2df6e12d82d0..c85579d971ca 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusMainInfo.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusMainInfo.tsx @@ -36,6 +36,7 @@ type IProps = { frequencyText?: string; destinationDefinition?: DestinationDefinition; sourceDefinition?: SourceDefinition; + allowSync?: boolean; }; const StatusMainInfo: React.FC = ({ @@ -43,6 +44,7 @@ const StatusMainInfo: React.FC = ({ frequencyText, destinationDefinition, sourceDefinition, + allowSync, }) => { return ( @@ -72,6 +74,7 @@ const StatusMainInfo: React.FC = ({ {frequencyText} 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 428b8e60da23..a3850b75ff8b 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusView.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/StatusView.tsx @@ -17,6 +17,7 @@ import useConnection from "hooks/services/useConnectionHook"; import useLoadingState from "hooks/useLoadingState"; import SourceDefinitionResource from "core/resources/SourceDefinition"; import DestinationDefinitionResource from "core/resources/DestinationDefinition"; +import { FeatureItem, useFeatureService } from "hooks/services/Feature"; type IProps = { connection: Connection; @@ -53,6 +54,8 @@ const SyncButton = styled(LoadingButton)` const StatusView: React.FC = ({ connection, frequencyText }) => { const [isModalOpen, setIsModalOpen] = useState(false); const { isLoading, showFeedback, startAction } = useLoadingState(); + const { hasFeature } = useFeatureService(); + const allowSync = hasFeature(FeatureItem.AllowSync); const sourceDefinition = useResource( SourceDefinitionResource.detailShape(), @@ -90,6 +93,7 @@ const StatusView: React.FC = ({ connection, frequencyText }) => { frequencyText={frequencyText} sourceDefinition={sourceDefinition} destinationDefinition={destinationDefinition} + allowSync={allowSync} /> = ({ connection, frequencyText }) => { startAction({ action: onSync })} diff --git a/airbyte-webapp/src/utils/testutils.tsx b/airbyte-webapp/src/utils/testutils.tsx index 31598661834e..38f9f40f6482 100644 --- a/airbyte-webapp/src/utils/testutils.tsx +++ b/airbyte-webapp/src/utils/testutils.tsx @@ -25,17 +25,23 @@ export function render( ): RenderResult { function Wrapper({ children }: WrapperProps) { return ( - - - - - {children} - - - - + + + + {children} + + + ); } return rtlRender(
{ui}
, { wrapper: Wrapper, ...renderOptions }); } + +export const TestWrapper: React.FC = ({ children }) => ( + + + {children} + + +);