diff --git a/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.tsx b/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.tsx index 6851a2ef20d4d..30358beb24538 100644 --- a/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.tsx +++ b/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.tsx @@ -25,6 +25,7 @@ import { import styles from "./CreateConnectionForm.module.scss"; import { CreateConnectionNameField } from "./CreateConnectionNameField"; +import { DataResidency } from "./DataResidency"; import { SchemaError } from "./SchemaError"; interface CreateConnectionProps { @@ -39,7 +40,7 @@ interface CreateConnectionPropsInner extends Pick = ({ schemaError, afterSubmitConnection }) => { const navigate = useNavigate(); - + const canEditDataGeographies = useFeature(FeatureItem.AllowChangeDataGeographies); const { mutateAsync: createConnection } = useCreateConnection(); const { clearFormChange } = useFormChangeTrackerService(); @@ -113,6 +114,7 @@ const CreateConnectionFormInner: React.FC = ({ schem {({ values, isSubmitting, isValid, dirty }) => (
+ {canEditDataGeographies && } setEditingTransformation(true)} diff --git a/airbyte-webapp/src/components/CreateConnection/DataResidency.module.scss b/airbyte-webapp/src/components/CreateConnection/DataResidency.module.scss new file mode 100644 index 0000000000000..89f3ded0617c4 --- /dev/null +++ b/airbyte-webapp/src/components/CreateConnection/DataResidency.module.scss @@ -0,0 +1,20 @@ +@use "scss/variables"; + +.flexRow { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + gap: variables.$spacing-md; +} + +.leftFieldCol { + flex: 1; + max-width: 640px; + padding-right: 30px; +} + +.rightFieldCol { + flex: 1; + max-width: 300px; +} diff --git a/airbyte-webapp/src/components/CreateConnection/DataResidency.tsx b/airbyte-webapp/src/components/CreateConnection/DataResidency.tsx new file mode 100644 index 0000000000000..6943c82e3bf09 --- /dev/null +++ b/airbyte-webapp/src/components/CreateConnection/DataResidency.tsx @@ -0,0 +1,64 @@ +import { Field, FieldProps, useFormikContext } from "formik"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { ControlLabels } from "components/LabeledControl"; +import { DropDown } from "components/ui/DropDown"; + +import { useAvailableGeographies } from "packages/cloud/services/geographies/GeographiesService"; +import { links } from "utils/links"; +import { Section } from "views/Connection/ConnectionForm/components/Section"; + +import styles from "./DataResidency.module.scss"; + +interface DataResidencyProps { + name?: string; +} + +export const DataResidency: React.FC = ({ name = "geography" }) => { + const { formatMessage } = useIntl(); + const { setFieldValue } = useFormikContext(); + const { geographies } = useAvailableGeographies(); + + return ( +
+ + {({ field, form }: FieldProps) => ( +
+
+ } + message={ + ( + + {node} + + ), + }} + /> + } + /> +
+
+ ({ + label: formatMessage({ + id: `connection.geography.${geography}`, + defaultMessage: geography.toUpperCase(), + }), + value: geography, + }))} + value={field.value} + onChange={(geography) => setFieldValue(name, geography.value)} + /> +
+
+ )} +
+
+ ); +}; diff --git a/airbyte-webapp/src/components/Label/Label.tsx b/airbyte-webapp/src/components/Label/Label.tsx index fc0a78190cea9..999cedeb9ef30 100644 --- a/airbyte-webapp/src/components/Label/Label.tsx +++ b/airbyte-webapp/src/components/Label/Label.tsx @@ -6,20 +6,18 @@ interface IProps { nextLine?: boolean; success?: boolean; message?: string | React.ReactNode; - additionLength?: number; className?: string; onClick?: (data: unknown) => void; htmlFor?: string; } -const Content = styled.label<{ additionLength?: number | string }>` +const Content = styled.label` display: block; font-weight: 500; font-size: 14px; line-height: 17px; color: ${({ theme }) => theme.textColor}; padding-bottom: 5px; - width: calc(100% + ${({ additionLength }) => (additionLength === 0 || additionLength ? additionLength : 30)}px); & a { text-decoration: underline; @@ -31,16 +29,18 @@ const MessageText = styled.span>` white-space: break-spaces; color: ${(props) => props.error ? props.theme.dangerColor : props.success ? props.theme.successColor : props.theme.greyColor40}; - font-size: 13px; + font-size: 12px; + font-weight: 400; + + a:link, + a:hover, + a:visited { + color: ${(props) => props.theme.greyColor40}; + } `; const Label: React.FC> = (props) => ( - + {props.children} {props.message && ( diff --git a/airbyte-webapp/src/components/LabeledControl/ControlLabels.tsx b/airbyte-webapp/src/components/LabeledControl/ControlLabels.tsx index cab7607fdf120..6689a885c1d24 100644 --- a/airbyte-webapp/src/components/LabeledControl/ControlLabels.tsx +++ b/airbyte-webapp/src/components/LabeledControl/ControlLabels.tsx @@ -14,7 +14,6 @@ export interface ControlLabelsProps { success?: boolean; nextLine?: boolean; message?: React.ReactNode; - labelAdditionLength?: number; label?: React.ReactNode; infoTooltipContent?: React.ReactNode; optional?: boolean; @@ -27,7 +26,6 @@ const ControlLabels: React.FC> = (pr error={props.error} success={props.success} message={props.message} - additionLength={props.labelAdditionLength} nextLine={props.nextLine} htmlFor={props.htmlFor} > diff --git a/airbyte-webapp/src/components/LabeledInput/LabeledInput.tsx b/airbyte-webapp/src/components/LabeledInput/LabeledInput.tsx index 8f072745946b6..bbbde8e5733b2 100644 --- a/airbyte-webapp/src/components/LabeledInput/LabeledInput.tsx +++ b/airbyte-webapp/src/components/LabeledInput/LabeledInput.tsx @@ -3,26 +3,11 @@ import React from "react"; import { ControlLabels, ControlLabelsProps } from "components/LabeledControl"; import { Input, InputProps } from "components/ui/Input"; -type LabeledInputProps = Pick & +type LabeledInputProps = Pick & InputProps & { className?: string }; -const LabeledInput: React.FC = ({ - error, - success, - message, - label, - labelAdditionLength, - className, - ...inputProps -}) => ( - +const LabeledInput: React.FC = ({ error, success, message, label, className, ...inputProps }) => ( + ); diff --git a/airbyte-webapp/src/components/connection/UpdateConnectionDataResidency/UpdateConnectionDataResidency.module.scss b/airbyte-webapp/src/components/connection/UpdateConnectionDataResidency/UpdateConnectionDataResidency.module.scss new file mode 100644 index 0000000000000..7b68578ce9f95 --- /dev/null +++ b/airbyte-webapp/src/components/connection/UpdateConnectionDataResidency/UpdateConnectionDataResidency.module.scss @@ -0,0 +1,21 @@ +.wrapper { + display: flex; + align-items: center; + justify-content: space-between; +} + +.dropdownWrapper { + display: flex; + flex: 1 0 310px; +} + +.spinner { + width: 50px; + display: flex; + justify-content: center; + align-items: center; +} + +.dropdown { + flex-grow: 1; +} diff --git a/airbyte-webapp/src/components/connection/UpdateConnectionDataResidency/UpdateConnectionDataResidency.tsx b/airbyte-webapp/src/components/connection/UpdateConnectionDataResidency/UpdateConnectionDataResidency.tsx new file mode 100644 index 0000000000000..85e7e56daff9f --- /dev/null +++ b/airbyte-webapp/src/components/connection/UpdateConnectionDataResidency/UpdateConnectionDataResidency.tsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { ControlLabels } from "components/LabeledControl"; +import { Card } from "components/ui/Card"; +import { DropDown } from "components/ui/DropDown"; +import { Spinner } from "components/ui/Spinner"; + +import { Geography } from "core/request/AirbyteClient"; +import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService"; +import { useNotificationService } from "hooks/services/Notification"; +import { useAvailableGeographies } from "packages/cloud/services/geographies/GeographiesService"; +import { links } from "utils/links"; + +import styles from "./UpdateConnectionDataResidency.module.scss"; + +export const UpdateConnectionDataResidency: React.FC = () => { + const { connection, updateConnection, connectionUpdating } = useConnectionEditService(); + const { registerNotification } = useNotificationService(); + const { formatMessage } = useIntl(); + const [selectedValue, setSelectedValue] = useState(); + + const { geographies } = useAvailableGeographies(); + + const handleSubmit = async ({ value }: { value: Geography }) => { + try { + setSelectedValue(value); + await updateConnection({ + connectionId: connection.connectionId, + geography: value, + }); + } catch (e) { + registerNotification({ + id: "connection.geographyUpdateError", + title: formatMessage({ id: "connection.geographyUpdateError" }), + isError: true, + }); + } + setSelectedValue(undefined); + }; + + return ( + +
+
+ } + message={ + ( + + {node} + + ), + }} + /> + } + /> +
+
+
{connectionUpdating && }
+
+ ({ + label: formatMessage({ + id: `connection.geography.${geography}`, + defaultMessage: geography.toUpperCase(), + }), + value: geography, + }))} + value={selectedValue || connection.geography} + onChange={handleSubmit} + /> +
+
+
+
+ ); +}; diff --git a/airbyte-webapp/src/components/connection/UpdateConnectionDataResidency/index.ts b/airbyte-webapp/src/components/connection/UpdateConnectionDataResidency/index.ts new file mode 100644 index 0000000000000..e6f591b82ff00 --- /dev/null +++ b/airbyte-webapp/src/components/connection/UpdateConnectionDataResidency/index.ts @@ -0,0 +1 @@ +export { UpdateConnectionDataResidency } from "./UpdateConnectionDataResidency"; diff --git a/airbyte-webapp/src/components/ui/Card/Card.module.scss b/airbyte-webapp/src/components/ui/Card/Card.module.scss index 319e7b6a39bd2..fb339d67d6b2c 100644 --- a/airbyte-webapp/src/components/ui/Card/Card.module.scss +++ b/airbyte-webapp/src/components/ui/Card/Card.module.scss @@ -1,5 +1,5 @@ -@use "../../../scss/colors"; -@use "../../../scss/variables" as vars; +@use "scss/colors"; +@use "scss/variables"; .container { width: auto; @@ -17,9 +17,9 @@ } .title { - padding: 25px 25px 22px; + padding: variables.$spacing-xl; color: colors.$dark-blue; - border-bottom: colors.$grey-100 vars.$border-thin solid; + border-bottom: colors.$grey-100 variables.$border-thin solid; font-weight: 600; letter-spacing: 0.008em; border-top-left-radius: 10px; diff --git a/airbyte-webapp/src/components/ui/SideMenu/SideMenu.tsx b/airbyte-webapp/src/components/ui/SideMenu/SideMenu.tsx index 94788e4b575ff..d31fc0511a1aa 100644 --- a/airbyte-webapp/src/components/ui/SideMenu/SideMenu.tsx +++ b/airbyte-webapp/src/components/ui/SideMenu/SideMenu.tsx @@ -27,7 +27,7 @@ interface SideMenuProps { } const Content = styled.nav` - min-width: 147px; + min-width: 155px; `; const Category = styled.div` diff --git a/airbyte-webapp/src/hooks/services/Analytics/pageTrackingCodes.tsx b/airbyte-webapp/src/hooks/services/Analytics/pageTrackingCodes.tsx index 0099472cfd1b5..117e51a573ba2 100644 --- a/airbyte-webapp/src/hooks/services/Analytics/pageTrackingCodes.tsx +++ b/airbyte-webapp/src/hooks/services/Analytics/pageTrackingCodes.tsx @@ -27,6 +27,7 @@ export enum PageTrackingCodes { SETTINGS_NOTIFICATION = "Settings.Notifications", SETTINGS_ACCESS_MANAGEMENT = "Settings.AccessManagement", SETTINGS_METRICS = "Settings.Metrics", + SETTINGS_DATA_RESIDENCY = "Settings.DataResidency", CREDITS = "Credits", WORKSPACES = "Workspaces", PREFERENCES = "Preferences", diff --git a/airbyte-webapp/src/hooks/services/ConnectionEdit/ConnectionEditService.test.tsx b/airbyte-webapp/src/hooks/services/ConnectionEdit/ConnectionEditService.test.tsx index b12e4e2ac8fa5..2610a828122d0 100644 --- a/airbyte-webapp/src/hooks/services/ConnectionEdit/ConnectionEditService.test.tsx +++ b/airbyte-webapp/src/hooks/services/ConnectionEdit/ConnectionEditService.test.tsx @@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react-hooks"; import React from "react"; import mockConnection from "test-utils/mock-data/mockConnection.json"; import mockDest from "test-utils/mock-data/mockDestinationDefinition.json"; +import mockWorkspace from "test-utils/mock-data/mockWorkspace.json"; import { TestWrapper } from "test-utils/testutils"; import { WebBackendConnectionUpdate } from "core/request/AirbyteClient"; @@ -13,6 +14,10 @@ jest.mock("services/connector/DestinationDefinitionSpecificationService", () => useGetDestinationDefinitionSpecification: () => mockDest, })); +jest.mock("services/workspaces/WorkspacesService", () => ({ + useCurrentWorkspace: () => mockWorkspace, +})); + jest.mock("../useConnectionHook", () => ({ useGetConnection: () => mockConnection, useWebConnectionService: () => ({ diff --git a/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.test.tsx b/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.test.tsx index b869fdbc0777d..4098958ca9584 100644 --- a/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.test.tsx +++ b/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.test.tsx @@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react-hooks"; import React from "react"; import mockConnection from "test-utils/mock-data/mockConnection.json"; import mockDest from "test-utils/mock-data/mockDestinationDefinition.json"; +import mockWorkspace from "test-utils/mock-data/mockWorkspace.json"; import { TestWrapper } from "test-utils/testutils"; import { AirbyteCatalog, WebBackendConnectionRead } from "core/request/AirbyteClient"; @@ -17,6 +18,10 @@ jest.mock("services/connector/DestinationDefinitionSpecificationService", () => useGetDestinationDefinitionSpecification: () => mockDest, })); +jest.mock("services/workspaces/WorkspacesService", () => ({ + useCurrentWorkspace: () => mockWorkspace, +})); + describe("ConnectionFormService", () => { const Wrapper: React.FC[0]> = ({ children, ...props }) => ( diff --git a/airbyte-webapp/src/hooks/services/Feature/types.tsx b/airbyte-webapp/src/hooks/services/Feature/types.tsx index 6b16253cb6ec0..a7a252416f696 100644 --- a/airbyte-webapp/src/hooks/services/Feature/types.tsx +++ b/airbyte-webapp/src/hooks/services/Feature/types.tsx @@ -5,6 +5,7 @@ export enum FeatureItem { AllowUpdateConnectors = "ALLOW_UPDATE_CONNECTORS", AllowOAuthConnector = "ALLOW_OAUTH_CONNECTOR", AllowSync = "ALLOW_SYNC", + AllowChangeDataGeographies = "ALLOW_CHANGE_DATA_GEOGRAPHIES", AllowSyncSubOneHourCronExpressions = "ALLOW_SYNC_SUB_ONE_HOUR_CRON_EXPRESSIONS", } diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index d3b783c79e522..4c695d656c379 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -367,6 +367,13 @@ "connection.catalogTree.sourceSchema": "'", "connection.catalogTree.destinationSchema": "'", + "connection.geographyTitle": "Data residency", + "connection.geographyDescription": "Depending on your network configuration, you may need to add IP addresses to your allowlist.", + "connection.geography.auto": "Airbyte Default", + "connection.geography.us": "United States", + "connection.geography.eu": "European Union", + "connection.geographyUpdateError": "There was an error updating the data residency for this connection.", + "connection.newConnection": "New connection", "connection.createFirst": "Create your first connection", "connection.newConnectionTitle": "New connection", @@ -510,6 +517,10 @@ "settings.account": "Account", "settings.accountSettings.updateEmailSuccess": "Email updated", "settings.cookiePreferences": "Cookie Preferences", + "settings.dataResidency": "Data Residency", + "settings.defaultDataResidency": "Default Data Residency", + "settings.defaultGeography": "Geography", + "settings.defaultDataResidencyDescription": "Choose the default preferred data processing location for all of your connections. The default data residency setting only affects new connections. Existing connections will retain their data residency setting. Learn more.", "connector.requestConnectorBlock": "Request a new connector", "connector.requestConnector": "Request a new connector", diff --git a/airbyte-webapp/src/packages/cloud/lib/domain/geographies/GeographiesService.ts b/airbyte-webapp/src/packages/cloud/lib/domain/geographies/GeographiesService.ts new file mode 100644 index 0000000000000..b512e1a5631b8 --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/lib/domain/geographies/GeographiesService.ts @@ -0,0 +1,8 @@ +import { WebBackendGeographiesListResult, webBackendListGeographies } from "core/request/AirbyteClient"; +import { AirbyteRequestService } from "core/request/AirbyteRequestService"; + +export class GeographiesService extends AirbyteRequestService { + public async list(): Promise { + return webBackendListGeographies(this.requestOptions); + } +} diff --git a/airbyte-webapp/src/packages/cloud/services/geographies/GeographiesService.ts b/airbyte-webapp/src/packages/cloud/services/geographies/GeographiesService.ts new file mode 100644 index 0000000000000..1ea3010085a25 --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/services/geographies/GeographiesService.ts @@ -0,0 +1,29 @@ +import { WebBackendGeographiesListResult } from "core/request/AirbyteClient"; +import { GeographiesService } from "packages/cloud/lib/domain/geographies/GeographiesService"; +import { useConfig } from "packages/cloud/services/config"; +import { useSuspenseQuery } from "services/connector/useSuspenseQuery"; +import { SCOPE_USER } from "services/Scope"; +import { useDefaultRequestMiddlewares } from "services/useDefaultRequestMiddlewares"; +import { useInitService } from "services/useInitService"; + +export const workspaceKeys = { + all: [SCOPE_USER, "geographies"] as const, + list: () => [...workspaceKeys.all, "list"] as const, +}; + +export function useGeographiesService() { + const { apiUrl } = useConfig(); + + const requestAuthMiddleware = useDefaultRequestMiddlewares(); + + return useInitService(() => new GeographiesService(apiUrl, requestAuthMiddleware), [apiUrl, requestAuthMiddleware]); +} + +/** + * Returns a list of data geographies that can be associated with a connection or workspace + **/ +export function useAvailableGeographies() { + const geographiesService = useGeographiesService(); + + return useSuspenseQuery(workspaceKeys.list(), () => geographiesService.list()); +} diff --git a/airbyte-webapp/src/packages/cloud/views/settings/CloudSettingsPage.tsx b/airbyte-webapp/src/packages/cloud/views/settings/CloudSettingsPage.tsx index 8dd379a615c7a..8737e29351ec8 100644 --- a/airbyte-webapp/src/packages/cloud/views/settings/CloudSettingsPage.tsx +++ b/airbyte-webapp/src/packages/cloud/views/settings/CloudSettingsPage.tsx @@ -6,6 +6,7 @@ import { FeatureItem, useFeature } from "hooks/services/Feature"; import { DbtCloudSettingsView } from "packages/cloud/views/settings/integrations/DbtCloudSettingsView"; import { AccountSettingsView } from "packages/cloud/views/users/AccountSettingsView"; import { UsersSettingsView } from "packages/cloud/views/users/UsersSettingsView"; +import { DataResidencyView } from "packages/cloud/views/workspaces/DataResidencyView"; import { WorkspaceSettingsView } from "packages/cloud/views/workspaces/WorkspaceSettingsView"; import SettingsPage from "pages/SettingsPage"; import { @@ -23,6 +24,7 @@ export const CloudSettingsPage: React.FC = () => { // TODO: uncomment when supported in cloud // const { countNewSourceVersion, countNewDestinationVersion } = useConnector(); const supportsCloudDbtIntegration = useFeature(FeatureItem.AllowDBTCloudIntegration); + const supportsDataResidency = useFeature(FeatureItem.AllowChangeDataGeographies); const pageConfig = useMemo( () => ({ @@ -55,6 +57,15 @@ export const CloudSettingsPage: React.FC = () => { component: WorkspaceSettingsView, id: "workspaceSettings.generalSettings", }, + ...(supportsDataResidency + ? [ + { + path: CloudSettingsRoutes.DataResidency, + name: , + component: DataResidencyView, + }, + ] + : []), { path: CloudSettingsRoutes.Source, name: , @@ -102,7 +113,7 @@ export const CloudSettingsPage: React.FC = () => { : []), ], }), - [supportsCloudDbtIntegration] + [supportsCloudDbtIntegration, supportsDataResidency] ); return ; diff --git a/airbyte-webapp/src/packages/cloud/views/settings/routePaths.ts b/airbyte-webapp/src/packages/cloud/views/settings/routePaths.ts index 5926f6aeaf7e0..80c54edfc71ae 100644 --- a/airbyte-webapp/src/packages/cloud/views/settings/routePaths.ts +++ b/airbyte-webapp/src/packages/cloud/views/settings/routePaths.ts @@ -6,6 +6,7 @@ export const CloudSettingsRoutes = { Account: SettingsRoute.Account, Source: SettingsRoute.Source, Destination: SettingsRoute.Destination, + DataResidency: SettingsRoute.DataResidency, Workspace: "workspaces", AccessManagement: "access-management", diff --git a/airbyte-webapp/src/packages/cloud/views/workspaces/DataResidencyView/DataResidencyView.module.scss b/airbyte-webapp/src/packages/cloud/views/workspaces/DataResidencyView/DataResidencyView.module.scss new file mode 100644 index 0000000000000..8bac0c8d1417a --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/views/workspaces/DataResidencyView/DataResidencyView.module.scss @@ -0,0 +1,39 @@ +@use "scss/colors"; +@use "scss/variables"; + +.cardContent { + padding: variables.$spacing-xl; +} + +.description { + color: colors.$grey-300; + margin-bottom: variables.$spacing-md; + font-size: 12px; + + a:link, + a:visited { + color: colors.$grey-300; + } +} + +.geographyRow { + margin-top: variables.$spacing-xl; + display: flex; + justify-content: space-between; +} + +.defaultGeographyDropdown { + flex: 0 0 300px; +} + +.buttonGroup { + margin-top: variables.$spacing-xl; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + + & > button { + margin-left: variables.$spacing-md; + } +} diff --git a/airbyte-webapp/src/packages/cloud/views/workspaces/DataResidencyView/DataResidencyView.tsx b/airbyte-webapp/src/packages/cloud/views/workspaces/DataResidencyView/DataResidencyView.tsx new file mode 100644 index 0000000000000..a24475b5e8345 --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/views/workspaces/DataResidencyView/DataResidencyView.tsx @@ -0,0 +1,129 @@ +import { Field, FieldProps, Form, Formik, FormikHelpers } from "formik"; +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { ControlLabels } from "components"; +import { Button } from "components/ui/Button"; +import { DropDown } from "components/ui/DropDown"; +import { Text } from "components/ui/Text"; + +import { Geography } from "core/request/AirbyteClient"; +import { PageTrackingCodes, useTrackPage } from "hooks/services/Analytics"; +import { useNotificationService } from "hooks/services/Notification"; +import { useCurrentWorkspace } from "hooks/services/useWorkspace"; +import { useAvailableGeographies } from "packages/cloud/services/geographies/GeographiesService"; +import { SettingsCard } from "pages/SettingsPage/pages/SettingsComponents"; +import { useUpdateWorkspace } from "services/workspaces/WorkspacesService"; +import { links } from "utils/links"; + +import styles from "./DataResidencyView.module.scss"; + +interface SelectGeographyOption { + label: Geography; + value: Geography; +} + +interface DefaultDataResidencyFormValues { + defaultGeography: Geography | undefined; +} + +export const DataResidencyView: React.FC = () => { + const workspace = useCurrentWorkspace(); + const { geographies } = useAvailableGeographies(); + const { mutateAsync: updateWorkspace } = useUpdateWorkspace(); + const { registerNotification } = useNotificationService(); + + const { formatMessage } = useIntl(); + useTrackPage(PageTrackingCodes.SETTINGS_DATA_RESIDENCY); + + const handleSubmit = async ( + values: DefaultDataResidencyFormValues, + { resetForm }: FormikHelpers + ) => { + try { + await updateWorkspace({ + workspaceId: workspace.workspaceId, + defaultGeography: values.defaultGeography, + }); + resetForm({ values }); + } catch (e) { + registerNotification({ + id: "workspaceSettings.defaultGeographyError", + title: formatMessage({ id: "connection.geographyUpdateError" }), + isError: true, + }); + } + }; + + const initialValues: DefaultDataResidencyFormValues = { + defaultGeography: workspace.defaultGeography, + }; + + return ( + }> +
+ + ( + + {node} + + ), + }} + /> + + + {({ isSubmitting, dirty, isValid, resetForm }) => ( + + + {({ field, form }: FieldProps) => ( +
+ } + message={ + ( + + {node} + + ), + }} + /> + } + /> +
+ ({ + label: formatMessage({ + id: `connection.geography.${geography}`, + defaultMessage: geography.toUpperCase(), + }), + value: geography, + }))} + value={field.value} + onChange={(option) => form.setFieldValue("defaultGeography", option.value)} + /> +
+
+ )} +
+
+ + +
+ + )} +
+
+
+ ); +}; diff --git a/airbyte-webapp/src/packages/cloud/views/workspaces/DataResidencyView/index.ts b/airbyte-webapp/src/packages/cloud/views/workspaces/DataResidencyView/index.ts new file mode 100644 index 0000000000000..463f131fdb1c7 --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/views/workspaces/DataResidencyView/index.ts @@ -0,0 +1 @@ +export { DataResidencyView } from "./DataResidencyView"; diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.test.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.test.tsx index a63ad3d78c186..816cf1b023209 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.test.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.test.tsx @@ -7,6 +7,8 @@ import React, { Suspense } from "react"; import selectEvent from "react-select-event"; import mockConnection from "test-utils/mock-data/mockConnection.json"; import mockDest from "test-utils/mock-data/mockDestinationDefinition.json"; +import mockWorkspace from "test-utils/mock-data/mockWorkspace.json"; +import { mockWorkspaceId } from "test-utils/mock-data/mockWorkspaceId"; import { TestWrapper } from "test-utils/testutils"; import { WebBackendConnectionUpdate } from "core/request/AirbyteClient"; @@ -21,6 +23,11 @@ jest.mock("services/connector/DestinationDefinitionSpecificationService", () => })); jest.setTimeout(10000); +jest.mock("services/workspaces/WorkspacesService", () => ({ + useCurrentWorkspace: () => mockWorkspace, + useCurrentWorkspaceId: () => mockWorkspaceId, +})); + describe("ConnectionReplicationTab", () => { const Wrapper: React.FC> = ({ children }) => ( I should not show up in a snapshot}> diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionSettingsTab.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionSettingsTab.tsx index 782d63d43c673..1470872500d7c 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionSettingsTab.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionSettingsTab.tsx @@ -1,9 +1,11 @@ import React from "react"; import { DeleteBlock } from "components/common/DeleteBlock"; +import { UpdateConnectionDataResidency } from "components/connection/UpdateConnectionDataResidency"; import { PageTrackingCodes, useTrackPage } from "hooks/services/Analytics"; import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService"; +import { FeatureItem, useFeature } from "hooks/services/Feature"; import { useAdvancedModeSetting } from "hooks/services/useAdvancedModeSetting"; import { useDeleteConnection } from "hooks/services/useConnectionHook"; @@ -13,6 +15,7 @@ import { StateBlock } from "./StateBlock"; export const ConnectionSettingsTab: React.FC = () => { const { connection } = useConnectionEditService(); const { mutateAsync: deleteConnection } = useDeleteConnection(); + const canUpdateDataResidency = useFeature(FeatureItem.AllowChangeDataGeographies); const [isAdvancedMode] = useAdvancedModeSetting(); useTrackPage(PageTrackingCodes.CONNECTIONS_ITEM_SETTINGS); @@ -20,6 +23,7 @@ export const ConnectionSettingsTab: React.FC = () => { return (
+ {canUpdateDataResidency && } {isAdvancedMode && }
diff --git a/airbyte-webapp/src/pages/SettingsPage/SettingsPage.module.scss b/airbyte-webapp/src/pages/SettingsPage/SettingsPage.module.scss index 003f7af39281e..a756f2ddb7790 100644 --- a/airbyte-webapp/src/pages/SettingsPage/SettingsPage.module.scss +++ b/airbyte-webapp/src/pages/SettingsPage/SettingsPage.module.scss @@ -1,4 +1,4 @@ -@use "../../scss/variables"; +@use "scss/variables"; .content { display: flex; @@ -7,6 +7,6 @@ } .mainView { - width: 100%; + flex-grow: 1; margin-left: variables.$spacing-2xl; } diff --git a/airbyte-webapp/src/pages/SettingsPage/SettingsPage.tsx b/airbyte-webapp/src/pages/SettingsPage/SettingsPage.tsx index 733ee63444758..88c85f375f164 100644 --- a/airbyte-webapp/src/pages/SettingsPage/SettingsPage.tsx +++ b/airbyte-webapp/src/pages/SettingsPage/SettingsPage.tsx @@ -32,6 +32,7 @@ export const SettingsRoute = { Configuration: "configuration", Notifications: "notifications", Metrics: "metrics", + DataResidency: "data-residency", } as const; const SettingsPage: React.FC = ({ pageConfig }) => { diff --git a/airbyte-webapp/src/test-utils/mock-data/mockConnection.json b/airbyte-webapp/src/test-utils/mock-data/mockConnection.json index a4cd6916599f3..d3ece6b96a8a8 100644 --- a/airbyte-webapp/src/test-utils/mock-data/mockConnection.json +++ b/airbyte-webapp/src/test-utils/mock-data/mockConnection.json @@ -6,6 +6,7 @@ "prefix": "", "sourceId": "a3295ed7-4acf-4c0b-b16b-07a00e624a52", "destinationId": "083a53bc-8bc2-4dc0-b05a-4273a96f3b93", + "geography": "auto", "syncCatalog": { "streams": [ { diff --git a/airbyte-webapp/src/test-utils/mock-data/mockWorkspace.json b/airbyte-webapp/src/test-utils/mock-data/mockWorkspace.json index 07ea47bdadb85..a87976333a3cc 100644 --- a/airbyte-webapp/src/test-utils/mock-data/mockWorkspace.json +++ b/airbyte-webapp/src/test-utils/mock-data/mockWorkspace.json @@ -5,6 +5,7 @@ "name": "47c74b9b-9b89-4af1-8331-4865af6c4e4d", "slug": "47c74b9b-9b89-4af1-8331-4865af6c4e4d", "initialSetupComplete": true, + "defaultGeography": "auto", "displaySetupWizard": false, "anonymousDataCollection": false, "news": false, diff --git a/airbyte-webapp/src/test-utils/mock-data/mockWorkspaceId.ts b/airbyte-webapp/src/test-utils/mock-data/mockWorkspaceId.ts new file mode 100644 index 0000000000000..cce229aafe582 --- /dev/null +++ b/airbyte-webapp/src/test-utils/mock-data/mockWorkspaceId.ts @@ -0,0 +1 @@ +export const mockWorkspaceId = "a8f75674-44e0-4219-b9ed-99ad0c080d03"; diff --git a/airbyte-webapp/src/utils/links.ts b/airbyte-webapp/src/utils/links.ts index 6465ea64e3ece..957f87b08f803 100644 --- a/airbyte-webapp/src/utils/links.ts +++ b/airbyte-webapp/src/utils/links.ts @@ -29,6 +29,7 @@ export const links = { webhookVideoGuideLink: "https://www.youtube.com/watch?v=NjYm8F-KiFc", webhookGuideLink: `${BASE_DOCS_LINK}/operator-guides/configuring-sync-notifications/`, cronReferenceLink: "http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html", + cloudAllowlistIPsLink: `${BASE_DOCS_LINK}/cloud/getting-started-with-airbyte-cloud/#allowlist-ip-address`, } as const; export type OutboundLinks = typeof links; diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/__snapshots__/formConfig.test.ts.snap b/airbyte-webapp/src/views/Connection/ConnectionForm/__snapshots__/formConfig.test.ts.snap index 171ce4d48e027..e25ac64ede60b 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/__snapshots__/formConfig.test.ts.snap +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/__snapshots__/formConfig.test.ts.snap @@ -2,6 +2,7 @@ exports[`#useInitialValues should generate initial values w/ 'not create' mode: false 1`] = ` Object { + "geography": "auto", "name": "Scrafty <> Heroku Postgres", "namespaceDefinition": "source", "namespaceFormat": "\${SOURCE_NAMESPACE}", @@ -534,6 +535,7 @@ Object { exports[`#useInitialValues should generate initial values w/ 'not create' mode: true 1`] = ` Object { + "geography": "auto", "namespaceDefinition": "source", "namespaceFormat": "\${SOURCE_NAMESPACE}", "normalization": "basic", @@ -1065,6 +1067,7 @@ Object { exports[`#useInitialValues should generate initial values w/ no 'not create' mode 1`] = ` Object { + "geography": "auto", "name": "Scrafty <> Heroku Postgres", "namespaceDefinition": "source", "namespaceFormat": "\${SOURCE_NAMESPACE}", diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.tsx index 6b206c42351b6..df1e653f9db1c 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.tsx @@ -35,7 +35,6 @@ export const NamespaceDefinitionField: React.FC> = ({ field, } message={} /> diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.test.ts b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.test.ts index a119935a6553b..968ff286f5b73 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.test.ts +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.test.ts @@ -1,6 +1,7 @@ import { renderHook } from "@testing-library/react-hooks"; -import mockDestinationDefinition from "test-utils/mock-data//mockDestinationDefinition.json"; import mockConnection from "test-utils/mock-data/mockConnection.json"; +import mockDestinationDefinition from "test-utils/mock-data/mockDestinationDefinition.json"; +import mockWorkspace from "test-utils/mock-data/mockWorkspace.json"; import { TestWrapper as wrapper } from "test-utils/testutils"; import { frequencyConfig } from "config/frequencyConfig"; @@ -14,6 +15,10 @@ import { import { mapFormPropsToOperation, useFrequencyDropdownData, useInitialValues } from "./formConfig"; +jest.mock("services/workspaces/WorkspacesService", () => ({ + useCurrentWorkspace: () => mockWorkspace, +})); + describe("#useFrequencyDropdownData", () => { it("should return only default frequencies when no additional frequency is provided", () => { const { result } = renderHook(() => useFrequencyDropdownData(undefined), { wrapper }); diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx index a8d99c9e41012..6aab438566335 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx @@ -17,6 +17,7 @@ import { ConnectionScheduleType, DestinationDefinitionSpecificationRead, DestinationSyncMode, + Geography, NamespaceDefinitionType, OperationCreate, OperationRead, @@ -41,6 +42,7 @@ export interface FormikConnectionFormValues { namespaceFormat: string; transformations?: OperationRead[]; normalization?: NormalizationType; + geography: Geography; } export type ConnectionFormValues = ValuesProps; @@ -88,6 +90,7 @@ export const createConnectionValidationSchema = ({ .object({ // The connection name during Editing is handled separately from the form name: mode === "create" ? yup.string().required("form.empty.error") : yup.string().notRequired(), + geography: yup.mixed().oneOf(Object.values(Geography)), scheduleType: yup .string() .oneOf([ConnectionScheduleType.manual, ConnectionScheduleType.basic, ConnectionScheduleType.cron]), @@ -266,6 +269,7 @@ export const useInitialValues = ( destDefinition: DestinationDefinitionSpecificationRead, isNotCreateMode?: boolean ): FormikConnectionFormValues => { + const workspace = useCurrentWorkspace(); const { catalogDiff } = connection; const newStreamDescriptors = catalogDiff?.transforms @@ -291,6 +295,7 @@ export const useInitialValues = ( prefix: connection.prefix || "", namespaceDefinition: connection.namespaceDefinition || NamespaceDefinitionType.source, namespaceFormat: connection.namespaceFormat ?? SOURCE_NAMESPACE_TAG, + geography: connection.geography || workspace.defaultGeography || "auto", }; // Is Create Mode @@ -312,6 +317,7 @@ export const useInitialValues = ( }, [ connection.connectionId, connection.destination.name, + connection.geography, connection.name, connection.namespaceDefinition, connection.namespaceFormat, @@ -324,6 +330,7 @@ export const useInitialValues = ( destDefinition.supportsNormalization, initialSchema, isNotCreateMode, + workspace, ]); }; diff --git a/airbyte-webapp/src/views/Connector/ServiceForm/components/Property/PropertyLabel.tsx b/airbyte-webapp/src/views/Connector/ServiceForm/components/Property/PropertyLabel.tsx index a447ea89785c0..9584d37b3399f 100644 --- a/airbyte-webapp/src/views/Connector/ServiceForm/components/Property/PropertyLabel.tsx +++ b/airbyte-webapp/src/views/Connector/ServiceForm/components/Property/PropertyLabel.tsx @@ -30,7 +30,6 @@ export const PropertyLabel: React.FC return (