Skip to content

Commit 63061cb

Browse files
teallarsonlmossman
andcommitted
feat: connector resource configuration UI (#15021)
Co-authored-by: Lake Mossman <[email protected]>
1 parent 71f5721 commit 63061cb

File tree

10 files changed

+324
-30
lines changed

10 files changed

+324
-30
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import uniq from "lodash/uniq";
2+
import uniqueId from "lodash/uniqueId";
3+
import { useCallback, useState } from "react";
4+
import { useFormContext } from "react-hook-form";
5+
import { FormattedMessage } from "react-intl";
6+
7+
import { LabelInfo } from "components";
8+
import { ControlLabels } from "components/LabeledControl";
9+
import { Box } from "components/ui/Box";
10+
import { FlexContainer } from "components/ui/Flex";
11+
import { ListBox } from "components/ui/ListBox";
12+
import { Text } from "components/ui/Text";
13+
14+
import { ConnectorDefinition } from "core/domain/connector";
15+
import { isSourceDefinition } from "core/domain/connector/source";
16+
import { FeatureItem, useFeature } from "core/services/features";
17+
import { ConnectorFormValues } from "views/Connector/ConnectorForm";
18+
import { PropertyError } from "views/Connector/ConnectorForm/components/Property/PropertyError";
19+
import { useConnectorForm } from "views/Connector/ConnectorForm/connectorFormContext";
20+
21+
export const API_RESOURCES = {
22+
default: {
23+
memory: 2,
24+
cpu: 1,
25+
},
26+
large: {
27+
memory: 3,
28+
cpu: 2,
29+
},
30+
memoryIntensive: {
31+
memory: 6,
32+
cpu: 2,
33+
},
34+
maximum: {
35+
memory: 8,
36+
cpu: 4,
37+
},
38+
};
39+
40+
export const DB_RESOURCES = {
41+
default: {
42+
memory: 2,
43+
cpu: 2,
44+
},
45+
large: {
46+
memory: 3,
47+
cpu: 3,
48+
},
49+
memoryIntensive: {
50+
memory: 6,
51+
cpu: 3,
52+
},
53+
maximum: {
54+
memory: 8,
55+
cpu: 4,
56+
},
57+
};
58+
59+
const getOptions = (connectorType: "api" | "database") => {
60+
const values = connectorType === "api" ? API_RESOURCES : DB_RESOURCES;
61+
62+
return Object.entries(values).map(([size, value]) => {
63+
return {
64+
value,
65+
label: (
66+
<Text>
67+
<FormattedMessage
68+
id="form.resourceAllocation.label"
69+
values={{
70+
size,
71+
cpu: value.cpu,
72+
memory: value.memory,
73+
details: (...details: React.ReactNode[]) => (
74+
<Text color="grey" as="span">
75+
{details}
76+
</Text>
77+
),
78+
}}
79+
/>
80+
</Text>
81+
),
82+
};
83+
});
84+
};
85+
86+
export const getConnectorType = (selectedConnectorDefinition?: ConnectorDefinition) => {
87+
if (!selectedConnectorDefinition) {
88+
return undefined;
89+
}
90+
91+
return isSourceDefinition(selectedConnectorDefinition)
92+
? selectedConnectorDefinition.sourceType === "file"
93+
? "database"
94+
: selectedConnectorDefinition.sourceType === "custom"
95+
? "api"
96+
: selectedConnectorDefinition.sourceType
97+
: "database";
98+
};
99+
100+
export const useConnectorResourceAllocation = () => {
101+
const supportsResourceAllocation = useFeature(FeatureItem.ConnectorResourceAllocation);
102+
103+
const isHiddenResourceAllocationField = useCallback(
104+
(fieldPath: string) => {
105+
// we want to hide the resourceAllocation sub-fields
106+
return supportsResourceAllocation && fieldPath.startsWith("resourceAllocation.");
107+
},
108+
[supportsResourceAllocation]
109+
);
110+
return { isHiddenResourceAllocationField };
111+
};
112+
113+
/**
114+
* TODO: when we add support for reading the current value from the SourceRead/DestinationRead
115+
* we will want to (a) map from the current value to one of the selectable options AND ALSO
116+
* (b) support values that do not fit the preconfigured options (and just show them in the UI).
117+
* We do not need to support a custom input yet, though.
118+
*/
119+
export const ResourceAllocationMenu: React.FC = () => {
120+
const { selectedConnectorDefinition } = useConnectorForm();
121+
const [controlId] = useState(`resource-listbox-control-${uniqueId()}`);
122+
123+
const { getValues, setValue, formState, getFieldState } = useFormContext<ConnectorFormValues>();
124+
const meta = getFieldState("resourceAllocation", formState);
125+
126+
const connectorType = getConnectorType(selectedConnectorDefinition);
127+
if (!connectorType) {
128+
return null;
129+
}
130+
131+
const options = getOptions(connectorType);
132+
133+
const errorMessage = Array.isArray(meta.error) ? (
134+
<FlexContainer direction="column" gap="none">
135+
{uniq(meta.error.map((error) => error?.message).filter(Boolean)).map((errorMessage, index) => {
136+
return <PropertyError key={index}>{errorMessage}</PropertyError>;
137+
})}
138+
</FlexContainer>
139+
) : !!meta.error?.message ? (
140+
<PropertyError>{meta.error.message}</PropertyError>
141+
) : null;
142+
143+
return (
144+
<Box pb="xl" data-testid="resourceAllocationMenu">
145+
<ControlLabels
146+
htmlFor={controlId}
147+
label={<FormattedMessage id="form.resourceAllocation" />}
148+
optional
149+
infoTooltipContent={
150+
<LabelInfo
151+
label={<FormattedMessage id="form.resourceAllocation" />}
152+
description={
153+
<FlexContainer direction="column" gap="sm">
154+
<Text inverseColor>
155+
<FormattedMessage id="form.resourceAllocation.tooltip" />
156+
</Text>
157+
<Text inverseColor>
158+
<FormattedMessage id="form.resourceAllocation.tooltip.note" />
159+
</Text>
160+
</FlexContainer>
161+
}
162+
/>
163+
}
164+
/>
165+
<ListBox
166+
id={controlId}
167+
options={options}
168+
selectedValue={getValues("resourceAllocation")}
169+
onSelect={(selectedValue) => setValue("resourceAllocation", selectedValue, { shouldDirty: true })}
170+
/>
171+
{errorMessage}
172+
</Box>
173+
);
174+
};

airbyte-webapp/src/core/services/features/types.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export enum FeatureItem {
1414
CloudForTeamsUpsell = "CLOUD_FOR_TEAMS_UPSELLING",
1515
ConnectionHistoryGraphs = "CONNECTION_HISTORY_GRAPHS",
1616
ConnectorBreakingChangeDeadlines = "CONNECTOR_BREAKING_CHANGE_DEADLINES",
17+
ConnectorResourceAllocation = "CONNECTOR_RESOURCE_ALLOCATION",
1718
DiagnosticsExport = "DIAGNOSTICS_EXPORT",
1819
DisplayOrganizationUsers = "DISPLAY_ORGANIZATION_USERS",
1920
EmailNotifications = "EMAIL_NOTIFICATIONS",

airbyte-webapp/src/locales/en.json

+5
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@
101101
"form.connectionName.subtitle": "Name for your connection",
102102
"form.sourceName": "Source name",
103103
"form.destinationName": "Destination name",
104+
"form.resourceAllocation": "Connector Resource Allocation",
105+
"form.resourceAllocation.label": "{size, select, default {Default} large {Large} memoryIntensive {Memory intensive} maximum {Maximum} other {Default}} <details>({cpu}CPU, {memory}GB memory)</details>",
106+
"form.resourceAllocation.tooltip": "Resources allocated to this connector for a sync. ",
107+
"form.resourceAllocation.tooltip.note": "If no nodes with these resources are available during a sync, the sync will enter a pending state and resume when one is free.",
104108
"form.sourceName.message": "Pick a name to help you identify this source in Airbyte",
105109
"form.destinationName.message": "Pick a name to help you identify this destination in Airbyte",
106110
"form.connectionName.message": "Pick a name to help you identify this connection",
@@ -198,6 +202,7 @@
198202
"form.wait": "Please wait a little bit more…",
199203
"form.optional": "Optional",
200204
"form.optionalFields": "Optional fields",
205+
"form.advanced": "Advanced",
201206
"form.min.error": "Must be greater than or equal to {min}",
202207
"form.max.error": "Must be less than or equal to {max}",
203208

airbyte-webapp/src/views/Connector/ConnectorForm/ConnectorForm.test.tsx

+62-9
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useCompleteOAuth } from "core/api";
1111
import { DestinationDefinitionSpecificationRead, OAuthConsentRead } from "core/api/types/AirbyteClient";
1212
import { ConnectorDefinition, ConnectorDefinitionSpecificationRead } from "core/domain/connector";
1313
import { AirbyteJSONSchema } from "core/jsonSchema/types";
14+
import { FeatureItem } from "core/services/features";
1415
import { ConnectorForm } from "views/Connector/ConnectorForm";
1516

1617
import { ConnectorFormValues } from "./types";
@@ -280,10 +281,12 @@ describe("Connector form", () => {
280281
formValuesOverride,
281282
propertiesOverride,
282283
specificationOverride,
284+
features,
283285
}: {
284286
formValuesOverride?: Record<string, unknown>;
285287
specificationOverride?: Partial<ConnectorDefinitionSpecificationRead>;
286288
propertiesOverride?: Record<string, AirbyteJSONSchema>;
289+
features?: FeatureItem[];
287290
} = {}) {
288291
const renderResult = await render(
289292
<ConnectorForm
@@ -311,7 +314,8 @@ describe("Connector form", () => {
311314
} as unknown as DestinationDefinitionSpecificationRead
312315
}
313316
/>,
314-
undefined
317+
undefined,
318+
features ? [...features] : undefined
315319
);
316320
return renderResult.container;
317321
}
@@ -424,19 +428,25 @@ describe("Connector form", () => {
424428
additional_same_group: { type: "string" },
425429
},
426430
});
427-
expect(getInputByName(container, "connectionConfiguration.additional_separate_group")).not.toBeVisible();
428-
expect(getInputByName(container, "connectionConfiguration.additional_same_group")).not.toBeVisible();
429431

430-
await userEvent.click(screen.getAllByTestId("optional-fields").at(0)!);
432+
const additionalSeparateGroupInput = getInputByName(
433+
container,
434+
"connectionConfiguration.additional_separate_group"
435+
);
436+
const additionalSameGroupInput = getInputByName(container, "connectionConfiguration.additional_same_group");
431437

432-
expect(getInputByName(container, "connectionConfiguration.additional_separate_group")).toBeVisible();
438+
const optionalFields = screen.getAllByTestId("optional-fields");
439+
expect(additionalSameGroupInput).not.toBeVisible();
433440

434-
await userEvent.click(screen.getAllByTestId("optional-fields").at(1)!);
441+
await userEvent.click(optionalFields.at(0)!);
442+
expect(additionalSameGroupInput).toBeVisible();
435443

436-
expect(getInputByName(container, "connectionConfiguration.additional_same_group")).toBeVisible();
444+
expect(additionalSeparateGroupInput).not.toBeVisible();
445+
await userEvent.click(optionalFields.at(1)!);
446+
expect(additionalSeparateGroupInput).toBeVisible();
437447

438-
const input1 = getInputByName(container, "connectionConfiguration.additional_same_group");
439-
const input2 = getInputByName(container, "connectionConfiguration.additional_separate_group");
448+
const input1 = additionalSameGroupInput;
449+
const input2 = additionalSeparateGroupInput;
440450
await userEvent.type(input1!, "input1");
441451
await userEvent.type(input2!, "input2");
442452

@@ -1110,4 +1120,47 @@ describe("Connector form", () => {
11101120
expect(getOAuthButton(container)).toBeInTheDocument();
11111121
});
11121122
});
1123+
1124+
describe("Advanced section", () => {
1125+
it("does not render the advanced section if Resource Allocation is off + spec does not define it", async () => {
1126+
await renderForm({});
1127+
1128+
expect(screen.queryAllByTestId("optional-fields")).toHaveLength(0);
1129+
});
1130+
1131+
it("renders the Advanced section if the flag is on and value is present in spec without groups", async () => {
1132+
await renderForm({ features: [FeatureItem.ConnectorResourceAllocation] });
1133+
1134+
const optionalFields = screen.getAllByTestId("optional-fields");
1135+
expect(optionalFields[0]).toHaveTextContent("Advanced");
1136+
expect(optionalFields).toHaveLength(1);
1137+
});
1138+
1139+
it("renders the Advanced section if the flag is on and value is present in spec with groups", async () => {
1140+
await renderForm({
1141+
propertiesOverride: {
1142+
additional_separate_group: { type: "string", group: "abc" },
1143+
},
1144+
features: [FeatureItem.ConnectorResourceAllocation],
1145+
});
1146+
1147+
const optionalFields = screen.getAllByTestId("optional-fields");
1148+
expect(optionalFields[0]).toHaveTextContent("Optional fields");
1149+
expect(optionalFields[1]).toHaveTextContent("Advanced");
1150+
expect(optionalFields).toHaveLength(2);
1151+
});
1152+
1153+
it("combines resource allocation Advanced section with one described in the spec", async () => {
1154+
await renderForm({
1155+
propertiesOverride: {
1156+
additional_separate_group: { type: "string", group: "advanced" },
1157+
},
1158+
features: [FeatureItem.ConnectorResourceAllocation],
1159+
});
1160+
1161+
const optionalFields = screen.getAllByTestId("optional-fields");
1162+
expect(optionalFields[0]).toHaveTextContent("Advanced");
1163+
expect(optionalFields).toHaveLength(1);
1164+
});
1165+
});
11131166
});

airbyte-webapp/src/views/Connector/ConnectorForm/ConnectorForm.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ConnectorDefinitionSpecificationRead,
99
SourceDefinitionSpecificationDraft,
1010
} from "core/domain/connector";
11+
import { FeatureItem, useFeature } from "core/services/features";
1112
import { removeEmptyProperties } from "core/utils/form";
1213
import { useFormChangeTrackerService, useUniqueFormId } from "hooks/services/FormChangeTracker";
1314

@@ -35,6 +36,7 @@ export interface ConnectorFormProps extends Omit<FormRootProps, "formFields" | "
3536
export const ConnectorForm: React.FC<ConnectorFormProps> = (props) => {
3637
const formId = useUniqueFormId(props.formId);
3738
const { clearFormChange } = useFormChangeTrackerService();
39+
const isResourceAllocationEnabled = useFeature(FeatureItem.ConnectorResourceAllocation);
3840

3941
const {
4042
formType,
@@ -51,6 +53,7 @@ export const ConnectorForm: React.FC<ConnectorFormProps> = (props) => {
5153
Boolean(isEditMode),
5254
formType,
5355
selectedConnectorDefinitionSpecification,
56+
isResourceAllocationEnabled,
5457
formValues
5558
);
5659

airbyte-webapp/src/views/Connector/ConnectorForm/components/Sections/FormSection.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FlexContainer, FlexItem } from "components/ui/Flex";
77
import { Heading } from "components/ui/Heading";
88

99
import { ArrayOfObjectsSection } from "area/connector/components/ArrayOfObjectsSection";
10+
import { ResourceAllocationMenu } from "area/connector/components/ResourceAllocationMenu";
1011
import { FormBlock, GroupDetails } from "core/form/types";
1112

1213
import { AuthSection } from "./auth/AuthSection";
@@ -94,6 +95,7 @@ export const FormSection: React.FC<FormSectionProps> = ({
9495
where the formField._type is formCondition, which will be the "outside rendering".
9596
*/}
9697
{shouldShowAuthButton(sectionPath) && formField._type !== "formCondition" && <AuthSection />}
98+
{sectionPath === "resourceAllocation" && <ResourceAllocationMenu />}
9799
<FormNode sectionPath={sectionPath} formField={formField} disabled={disabled} />
98100
</React.Fragment>
99101
);

airbyte-webapp/src/views/Connector/ConnectorForm/components/Sections/useGroupsAndSections.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,10 @@ function section(blocks: FormBlock[], displayType: DisplayType = "expanded"): Se
6363
};
6464
}
6565

66-
const isHiddenAuthField = jest.fn(() => false);
66+
const isHiddenField = jest.fn(() => false);
6767

6868
function generate(blocks: FormBlock | FormBlock[], groups: GroupDetails[] = []) {
69-
return generateGroupsAndSections(blocks, groups, true, isHiddenAuthField);
69+
return generateGroupsAndSections(blocks, groups, true, isHiddenField);
7070
}
7171

7272
describe("useGroupsAndSections", () => {

0 commit comments

Comments
 (0)