Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Alex/mvp UI for dbt cloud integration #18095

Merged
merged 31 commits into from
Oct 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
534ed71
Add lightly-styled ui for dbt cloud settings
ambirdsall Oct 3, 2022
a46c42c
Add CollapsablePanel component
ambirdsall Oct 4, 2022
578113b
Add CollapsablePanel around url input, MVP styling
ambirdsall Oct 5, 2022
e3f8fbb
Add new feature flag for dbt cloud integration
ambirdsall Oct 12, 2022
54693cf
Put settings page dbt cloud ui behind feature flag
ambirdsall Oct 12, 2022
39836db
Add feature-flagged CloudTransformationsCard
ambirdsall Oct 12, 2022
e9bb481
Extract (and rename) DbtCloudTransformationsCard
ambirdsall Oct 12, 2022
94598fe
Extract EmptyTransformationList component
ambirdsall Oct 12, 2022
8377b6a
List transformations if any, "no integration" UI
ambirdsall Oct 13, 2022
c4a2f9e
Initial UI for cloud transform jobs
ambirdsall Oct 13, 2022
2286e36
Use formik-backed inputs for job list data fields
ambirdsall Oct 13, 2022
29ca6ab
Improve job list management with FieldArray et al
ambirdsall Oct 14, 2022
7c5fa85
WIP: build payload to save job data as operations
ambirdsall Oct 17, 2022
5f946f6
Start pulling dbt cloud business logic to its own module
ambirdsall Oct 17, 2022
a948409
Renaming pass (s/transformation/job/g)
ambirdsall Oct 17, 2022
fcfc4d4
Move more logic into dbt service module
ambirdsall Oct 17, 2022
7a61a85
Renaming pass (s/project/account/)
ambirdsall Oct 17, 2022
d2b2d9a
Improve useDbtIntegration hook
ambirdsall Oct 17, 2022
8c05d72
Add skeleton of updateWorkspace fn
ambirdsall Oct 17, 2022
10ee765
Connect pages to actual backend (no new jobs tho)
ambirdsall Oct 18, 2022
04cf38c
Add hacky initial add new job implementation
ambirdsall Oct 18, 2022
61156f9
Put the whole dbt cloud card inside FieldArray
ambirdsall Oct 18, 2022
ffc1b2f
Fix button placement, loss of focus on input
ambirdsall Oct 18, 2022
de89a23
re-extract DbtJobsList component
ambirdsall Oct 18, 2022
2cee9f5
Add input labels for dbt cloud job list
ambirdsall Oct 18, 2022
af02acf
Validate dbt cloud jobs so bad data doesn't crash the party
ambirdsall Oct 18, 2022
b0e9079
Fix typo
ambirdsall Oct 18, 2022
923f332
Improve dirty form tracking for dbt jobs list
ambirdsall Oct 18, 2022
ea8d8bc
Remove unused input, add loading state to dbt cloud settings view
ambirdsall Oct 18, 2022
76c69ec
Handle no integration, dirty states in dbt jobs list
ambirdsall Oct 18, 2022
9d56031
Merge branch 'master' into alex/mvp-ui-for-dbt-cloud-integration
ambirdsall Oct 18, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added airbyte-webapp/public/images/octavia/worker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion airbyte-webapp/src/components/LabeledInput/LabeledInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@ import React from "react";
import { ControlLabels, ControlLabelsProps } from "components/LabeledControl";
import { Input, InputProps } from "components/ui/Input";

type LabeledInputProps = Pick<ControlLabelsProps, "success" | "message" | "label" | "labelAdditionLength"> & InputProps;
type LabeledInputProps = Pick<ControlLabelsProps, "success" | "message" | "label" | "labelAdditionLength"> &
InputProps & { className?: string };

const LabeledInput: React.FC<LabeledInputProps> = ({
error,
success,
message,
label,
labelAdditionLength,
className,
...inputProps
}) => (
<ControlLabels
error={error}
success={success}
message={message}
label={label}
className={className}
labelAdditionLength={labelAdditionLength}
>
<Input {...inputProps} error={error} />
Expand Down
1 change: 1 addition & 0 deletions airbyte-webapp/src/hooks/services/Feature/types.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum FeatureItem {
AllowUploadCustomImage = "ALLOW_UPLOAD_CUSTOM_IMAGE",
AllowCustomDBT = "ALLOW_CUSTOM_DBT",
AllowDBTCloudIntegration = "ALLOW_DBT_CLOUD_INTEGRATION",
AllowUpdateConnectors = "ALLOW_UPDATE_CONNECTORS",
AllowOAuthConnector = "ALLOW_OAUTH_CONNECTOR",
AllowCreateConnection = "ALLOW_CREATE_CONNECTION",
Expand Down
7 changes: 7 additions & 0 deletions airbyte-webapp/src/packages/cloud/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@
"settings.accountSettings.updateNameSuccess": "Your name has been updated!",
"settings.userSettings": "User settings",
"settings.workspaceSettings": "Workspace settings",
"settings.integrationSettings": "Integration settings",
"settings.integrationSettings.dbtCloudSettings": "dbt Cloud Integration",
"settings.integrationSettings.dbtCloudSettings.form.serviceToken": "Service Token",
"settings.integrationSettings.dbtCloudSettings.form.advancedOptions": "Advanced options",
"settings.integrationSettings.dbtCloudSettings.form.singleTenantUrl": "Single-tenant URL",
"settings.integrationSettings.dbtCloudSettings.form.testConnection": "Test connection",
"settings.integrationSettings.dbtCloudSettings.form.submit": "Save changes",
"settings.generalSettings": "General Settings",
"settings.generalSettings.changeWorkspace": "Change Workspace",
"settings.generalSettings.form.name.label": "Workspace name",
Expand Down
113 changes: 113 additions & 0 deletions airbyte-webapp/src/packages/cloud/services/dbtCloud.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// This module is for the business logic of working with dbt Cloud webhooks.
// Static config data, urls, functions which wrangle the APIs to manipulate
// records in ways suited to the UI user workflows--all the implementation
// details of working with dbtCloud jobs as webhook operations, all goes here.
// The presentation logic and orchestration in the UI all goes elsewhere.
//
// About that business logic:
// - for now, the code treats "webhook operations" and "dbt Cloud job" as synonymous.
// - custom domains aren't yet supported

import isEmpty from "lodash/isEmpty";
import { useMutation } from "react-query";

import { OperatorType, WebBackendConnectionRead, OperationRead } from "core/request/AirbyteClient";
import { useWebConnectionService } from "hooks/services/useConnectionHook";
import { useCurrentWorkspace } from "hooks/services/useWorkspace";
import { useUpdateWorkspace } from "services/workspaces/WorkspacesService";

export interface DbtCloudJob {
account: string;
job: string;
operationId?: string;
}
const dbtCloudDomain = "https://cloud.getdbt.com";
const webhookConfigName = "dbt cloud";
const executionBody = `{"cause": "airbyte"}`;
const jobName = (t: DbtCloudJob) => `${t.account}/${t.job}`;

const toDbtCloudJob = (operation: OperationRead): DbtCloudJob => {
const { operationId } = operation;
const { executionUrl } = operation.operatorConfiguration.webhook || {};

const matches = (executionUrl || "").match(/\/accounts\/([^/]+)\/jobs\/([^]+)\//);
Copy link
Collaborator

Choose a reason for hiding this comment

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

For my understanding, does it mean the job actually only stores the full execution URL, while we're entering in the UI individual separate fields?

I think the backend should store the fields we want the user to actually enter, and just build the executionUrl when doing the request. Parsing this client side sounds like a fragile method, and if we don't store this data now, we might have a problem addressing this in a backwards compatible way easily, since we already lost the individual field information.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good callout! We did discuss this a bit.

The backend is actually unaware of dbt accounts/jobs, or dbt cloud at all - it's a "webhook operation", and it just holds the plain URL. dbt Cloud is a totally frontend concept at the moment.

With that said, I do think a better approach would be what you're describing, and the way I'd do it would be to accept the URL "template" that includes named params; plus a map of the params. e.g., "https://cloud.getdbt.com/api/v2/accounts/{accountId}/jobs/{jobId}/run/", {"accountId": "x", "jobId": "y"}.

I think for existing configs we would represent this like "https://cloud.getdbt.com/api/v2/accounts/x/jobs/y/run/", {}. Since dbt Cloud is going to be the only "webhook operation" integration in the UI for the foreseeable future, once we have the template, params representation we can run a targeted migration since we know what these look like, and then we'd be able to kill this bit of messy FE code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@davinchia any thoughts here? I do agree we should make the change in the backend ~asap.

Copy link
Contributor

@ambirdsall ambirdsall Oct 18, 2022

Choose a reason for hiding this comment

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

I 100% agree; I think there should be cloud endpoints which are specific to dbt Cloud, return the easy-to-use DbtCloudJob objects, and, down the line, support UI niceties like selecting dbt jobs from a dropdown (which has to go through the backend for CORS reasons) and the fact that these are backed by a generic webhook record remaining an implementation detail hidden from the frontend code. (In fact, it's one of the primary reasons I tried to keep all the API interactions contained to this single file.) It came up a bit in earlier discussions and it's something I'm going to advocate for in the immediate clean-up planning/spec work. Thoughts, @mfsiega-airbyte?

For what it's worth, this seems like a reasonable PR sequencing of that change if we do choose to ship the MVP with these API contracts still in place:

  1. first an "append-only" PR to airbyte-cloud adding new dbt-Cloud-specific APIs (which wouldn't yet be used);
  2. then full-stack PR to airbyte to define and generate those new APIs and replace all the webhook-oriented code in this file with them.

That said, in terms of this PR, I didn't have a working instance of this more minimal backend implementation to develop and experiment with until this weekend, so I had little choice but to run with what was available to get something working for the demo.


if (!matches) {
throw new Error(`Cannot extract dbt cloud job params from executionUrl ${executionUrl}`);
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_fullUrl, account, job] = matches;
Comment on lines +38 to +39
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_fullUrl, account, job] = matches;
const [, account, job] = matches;

Copy link
Collaborator

Choose a reason for hiding this comment

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

^ We don't assign unused variables any name, but instead skip them. This is happening in a couple of places around this PR, so we should try to remove all of those.

Copy link
Contributor

Choose a reason for hiding this comment

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

Somehow I didn't realize you can do that when destructuring; I like that much better than using throwaway variables and manually shushing the linter. Will do for all instances.

Copy link
Collaborator

Choose a reason for hiding this comment

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

🎨 Other unused variable, that should be skipped in destructuring.


return {
account,
job,
operationId,
};
}
};
const isDbtCloudJob = (operation: OperationRead): boolean =>
operation.operatorConfiguration.operatorType === OperatorType.webhook;

export const useSubmitDbtCloudIntegrationConfig = () => {
const { workspaceId } = useCurrentWorkspace();
const { mutateAsync: updateWorkspace } = useUpdateWorkspace();

return useMutation(async (authToken: string) => {
await updateWorkspace({
workspaceId,
webhookConfigs: [
{
name: webhookConfigName,
authToken,
},
],
});
});
};

export const useDbtIntegration = (connection: WebBackendConnectionRead) => {
const workspace = useCurrentWorkspace();
const { workspaceId } = workspace;
const connectionService = useWebConnectionService();

// TODO extract shared isDbtWebhookConfig predicate
const hasDbtIntegration = !isEmpty(workspace.webhookConfigs?.filter((config) => /dbt/.test(config.name || "")));
Copy link
Collaborator

Choose a reason for hiding this comment

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

🎨 This looks like it could just be an .includes instead of a regexp (same the line below).

Copy link
Contributor

Choose a reason for hiding this comment

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

I got paranoid about locking in our ability to edit the webhook name if we decide to tweak the naming convention, but I think you're right that it would be cleaner just checking for the actual value, since it's not user-supplied

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure whether we meant the same thing, so just for clarification, I suggested just replacing the regexp by an includes not the whole filter statement, i.e.:

Suggested change
const hasDbtIntegration = !isEmpty(workspace.webhookConfigs?.filter((config) => /dbt/.test(config.name || "")));
const hasDbtIntegration = !isEmpty(workspace.webhookConfigs?.filter((config) => config.name?.includes("dbt")));

const webhookConfigId = workspace.webhookConfigs?.find((config) => /dbt/.test(config.name || ""))?.id;

const dbtCloudJobs = [...(connection.operations?.filter((operation) => isDbtCloudJob(operation)) || [])].map(
toDbtCloudJob
);
const otherOperations = [...(connection.operations?.filter((operation) => !isDbtCloudJob(operation)) || [])];

const saveJobs = (jobs: DbtCloudJob[]) => {
// TODO dynamically use the workspace's configured dbt cloud domain when it gets returned by backend
const urlForJob = (job: DbtCloudJob) => `${dbtCloudDomain}/api/v2/accounts/${job.account}/jobs/${job.job}/run`;

return connectionService.update({
connectionId: connection.connectionId,
operations: [
...otherOperations,
...jobs.map((job) => ({
workspaceId,
...(job.operationId ? { operationId: job.operationId } : {}),
name: jobName(job),
operatorConfiguration: {
operatorType: OperatorType.webhook,
webhook: {
executionUrl: urlForJob(job),
// if `hasDbtIntegration` is true, webhookConfigId is guaranteed to exist
...(webhookConfigId ? { webhookConfigId } : {}),
executionBody,
},
},
})),
],
});
};

return {
hasDbtIntegration,
dbtCloudJobs,
saveJobs,
};
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, { useMemo } from "react";
import { FormattedMessage } from "react-intl";

import { FeatureItem, useFeature } from "hooks/services/Feature";
// import useConnector from "hooks/services/useConnector";
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 { WorkspaceSettingsView } from "packages/cloud/views/workspaces/WorkspaceSettingsView";
Expand All @@ -20,6 +22,7 @@ import { CloudSettingsRoutes } from "./routePaths";
export const CloudSettingsPage: React.FC = () => {
// TODO: uncomment when supported in cloud
// const { countNewSourceVersion, countNewDestinationVersion } = useConnector();
const supportsCloudDbtIntegration = useFeature(FeatureItem.AllowDBTCloudIntegration);

const pageConfig = useMemo<PageConfig>(
() => ({
Expand Down Expand Up @@ -82,9 +85,24 @@ export const CloudSettingsPage: React.FC = () => {
},
],
},
...(supportsCloudDbtIntegration
? [
{
category: <FormattedMessage id="settings.integrationSettings" />,
routes: [
{
path: CloudSettingsRoutes.DbtCloud,
name: <FormattedMessage id="settings.integrationSettings.dbtCloudSettings" />,
component: DbtCloudSettingsView,
id: "integrationSettings.dbtCloudSettings",
},
],
},
]
: []),
],
}),
[]
[supportsCloudDbtIntegration]
);

return <SettingsPage pageConfig={pageConfig} />;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@use "scss/colors";
@use "scss/variables" as vars;

$item-spacing: 25px;
Copy link
Contributor

Choose a reason for hiding this comment

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

Would be great if we can use the spacing variables in scss/variables


.controlGroup {
display: flex;
justify-content: flex-end;
margin-top: $item-spacing;

.button {
margin-left: 1em;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Field, FieldProps, Form, Formik } from "formik";
import React from "react";
import { FormattedMessage } from "react-intl";

import { LabeledInput } from "components/LabeledInput";
import { Button } from "components/ui/Button";

import { useSubmitDbtCloudIntegrationConfig } from "packages/cloud/services/dbtCloud";
import { Content, SettingsCard } from "pages/SettingsPage/pages/SettingsComponents";

import styles from "./DbtCloudSettingsView.module.scss";

export const DbtCloudSettingsView: React.FC = () => {
const { mutate: submitDbtCloudIntegrationConfig, isLoading } = useSubmitDbtCloudIntegrationConfig();
return (
<SettingsCard title={<FormattedMessage id="settings.integrationSettings.dbtCloudSettings" />}>
<Content>
<Formik
initialValues={{
serviceToken: "",
}}
onSubmit={({ serviceToken }) => submitDbtCloudIntegrationConfig(serviceToken)}
>
<Form>
<Field name="serviceToken">
{({ field }: FieldProps<string>) => (
<LabeledInput
{...field}
label={<FormattedMessage id="settings.integrationSettings.dbtCloudSettings.form.serviceToken" />}
type="text"
/>
)}
</Field>
<div className={styles.controlGroup}>
<Button variant="primary" type="submit" className={styles.button} isLoading={isLoading}>
<FormattedMessage id="settings.integrationSettings.dbtCloudSettings.form.submit" />
</Button>
</div>
</Form>
</Formik>
</Content>
</SettingsCard>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export const CloudSettingsRoutes = {

Workspace: "workspaces",
AccessManagement: "access-management",
DbtCloud: "dbt-cloud",
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { FormCard } from "views/Connection/FormCard";

import styles from "./ConnectionTransformationTab.module.scss";
import { DbtCloudTransformationsCard } from "./ConnectionTransformationTab/DbtCloudTransformationsCard";

const CustomTransformationsCard: React.FC<{
operations?: OperationCreate[];
Expand Down Expand Up @@ -100,6 +101,8 @@ export const ConnectionTransformationTab: React.FC = () => {
useTrackPage(PageTrackingCodes.CONNECTIONS_ITEM_TRANSFORMATION);
const { supportsNormalization } = definition;
const supportsDbt = useFeature(FeatureItem.AllowCustomDBT) && definition.supportsDbt;
const supportsCloudDbtIntegration = useFeature(FeatureItem.AllowDBTCloudIntegration) && definition.supportsDbt;
const noSupportedTransformations = !supportsNormalization && !supportsDbt && !supportsCloudDbtIntegration;

const onSubmit: FormikOnSubmit<{ transformations?: OperationRead[]; normalization?: NormalizationType }> = async (
values,
Expand Down Expand Up @@ -134,7 +137,8 @@ export const ConnectionTransformationTab: React.FC = () => {
>
{supportsNormalization && <NormalizationCard operations={connection.operations} onSubmit={onSubmit} />}
{supportsDbt && <CustomTransformationsCard operations={connection.operations} onSubmit={onSubmit} />}
{!supportsNormalization && !supportsDbt && (
{supportsCloudDbtIntegration && <DbtCloudTransformationsCard connection={connection} />}
{noSupportedTransformations && (
<Card className={styles.customCard}>
<Text as="p" size="lg" centered>
<FormattedMessage id="connectionForm.operations.notSupported" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
@use "scss/colors";

.jobListContainer {
padding: 25px 25px 22px;
background-color: colors.$grey-50;
}

.jobListTitle {
display: flex;
justify-content: space-between;
}

.jobListForm {
width: 100%;
}

.emptyListContent {
display: flex;
flex-direction: column;
align-items: center;

> img {
Copy link
Collaborator

Choose a reason for hiding this comment

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

🎨 We have that img element under our control as well (i.e. render it in JSX), so it would be preferable to give the img itself just a classname instead of selecting > img here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed, this was 100% a time-saving hack; I'm not a fan of coupling style and html structure at all.

width: 111px;
height: 111px;
margin: 20px 0;
Copy link
Collaborator

Choose a reason for hiding this comment

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

variables.$spacing-xl

}
}

.contextExplanation {
color: colors.$grey-300;
width: 100%;

& a {
color: colors.$grey-300;
}
}

.jobListButtonGroup {
display: flex;
justify-content: flex-end;
margin-top: 20px;
Copy link
Collaborator

Choose a reason for hiding this comment

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

variables.$spacing-xl

width: 100%;
}

.jobListButton {
margin-left: 10px;
}

.jobListItem {
margin-top: 10px;
padding: 18px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;

& img {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same as above, we should give this img a className instead.

height: 32px;
width: 32px;
}
}

.jobListItemIntegrationName {
display: flex;
align-items: center;
}

.jobListItemInputGroup {
display: flex;
justify-content: space-between;
align-items: center;
}

.jobListItemInput {
height: fit-content;
margin-left: 1em;
}

.jobListItemInputLabel {
font-size: 11px;
font-weight: 500;
}

.jobListItemDelete {
color: colors.$grey-200;
font-size: large;
margin: 0 1em;
cursor: pointer;
border: none;
background-color: inherit;
}
Loading