Skip to content

Commit 4633212

Browse files
ambirdsallakashkulk
authored andcommitted
🪟 🎉 Select dbt jobs with dropdown (#19502)
* Add wrapper for cloud dbt endpoint Also includes a cheeky little test usage which probably should have had a name starting with an underscore (I did not commit the test code which added the new variable's contents to `(window as any).availableJobs`, on grounds that it was very ugly, but I did want to have the import and call of the wrapper function hit the git history here). * Add dbt Cloud jobs via dropdown (WIP: breaks if no integration) Selecting job from dropdown adds it to saved jobs, but the new endpoint is unconditionally called even though it will always throw an error for users with no dbt Cloud integration configured. * Refactor to stop errors in non-dbt-integrated workspaces Well, I suppose throwing a runtime error for every connection which could support dbt Cloud jobs but is part of a workspace with no integration set up, but that doesn't exactly seem ideal. Instead, this pulls all logic out of the top-level card except for pulling the dbt Cloud integration information; then it delegates the rest to either of two fairly self-contained components, `NoDbtIntegration` or the new `DbtJobsForm`. The immediate benefit is that I have a nice component boundary in which I can unconditionally run dbt-Cloud-only logic to fetch available jobs. * Filter already-selected jobs out of dropdown * Use dbt's jobNames and read-only {account,job}Id in job list * Remove obsolete yup validations for dbt Cloud jobs Since the values are now supplied by dbt Cloud via API, the user no longer has to manually input anything; and this sudden lack of user input rather obviates the need to validate user input. * Add button loading state when saving dbt Cloud jobs
1 parent 7b4ead8 commit 4633212

File tree

5 files changed

+260
-135
lines changed

5 files changed

+260
-135
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { apiOverride } from "core/request/apiOverride";
2+
3+
/**
4+
* Get the available dbt Cloud jobs associated with the given workspace config.
5+
*/
6+
export interface WorkspaceGetDbtJobsRequest {
7+
workspaceId: WorkspaceId;
8+
/** The config id associated with the dbt Cloud config, references the webhookConfigId in the core API. */
9+
dbtConfigId: string;
10+
}
11+
12+
/**
13+
* The available dbt Cloud jobs for the requested workspace config
14+
*/
15+
export interface WorkspaceGetDbtJobsResponse {
16+
availableDbtJobs: DbtCloudJobInfo[];
17+
}
18+
19+
/**
20+
* A dbt Cloud job
21+
*/
22+
export interface DbtCloudJobInfo {
23+
/** The account id associated with the job */
24+
accountId: number;
25+
/** The the specific job id returned by the dbt Cloud API */
26+
jobId: number;
27+
/** The human-readable name of the job returned by the dbt Cloud API */
28+
jobName: string;
29+
}
30+
31+
/**
32+
* @summary Calls the dbt Cloud `List Accounts` and `List jobs` APIs to get the list of available jobs for the dbt auth token associated with the requested workspace config.
33+
*/
34+
export const webBackendGetAvailableDbtJobsForWorkspace = (
35+
workspaceGetDbtJobsRequest: WorkspaceGetDbtJobsRequest,
36+
options?: SecondParameter<typeof apiOverride>
37+
) => {
38+
return apiOverride<WorkspaceGetDbtJobsResponse>(
39+
{
40+
url: `/v1/web_backend/cloud_workspaces/get_available_dbt_jobs`,
41+
method: "post",
42+
headers: { "Content-Type": "application/json" },
43+
data: workspaceGetDbtJobsRequest,
44+
},
45+
options
46+
);
47+
};
48+
49+
/**
50+
* Workspace Id from OSS Airbyte instance
51+
*/
52+
export type WorkspaceId = string;
53+
54+
// eslint-disable-next-line
55+
type SecondParameter<T extends (...args: any) => any> = T extends (config: any, args: infer P) => any ? P : never;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./api";

airbyte-webapp/src/packages/cloud/services/dbtCloud.ts

Lines changed: 87 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,45 +9,64 @@
99
// - custom domains aren't yet supported
1010

1111
import isEmpty from "lodash/isEmpty";
12-
import { useMutation } from "react-query";
12+
import { useMutation, useQuery } from "react-query";
1313

1414
import { OperatorType, WebBackendConnectionRead, OperationRead, WebhookConfigRead } from "core/request/AirbyteClient";
1515
import { useWebConnectionService } from "hooks/services/useConnectionHook";
1616
import { useCurrentWorkspace } from "hooks/services/useWorkspace";
17+
import {
18+
DbtCloudJobInfo,
19+
webBackendGetAvailableDbtJobsForWorkspace,
20+
WorkspaceGetDbtJobsResponse,
21+
} from "packages/cloud/lib/domain/dbtCloud/api";
22+
import { useDefaultRequestMiddlewares } from "services/useDefaultRequestMiddlewares";
1723
import { useUpdateWorkspace } from "services/workspaces/WorkspacesService";
1824

25+
import { useConfig } from "./config";
26+
1927
export interface DbtCloudJob {
2028
account: string;
2129
job: string;
2230
operationId?: string;
31+
jobName?: string;
2332
}
33+
export type { DbtCloudJobInfo } from "packages/cloud/lib/domain/dbtCloud/api";
2434
const dbtCloudDomain = "https://cloud.getdbt.com";
2535
const webhookConfigName = "dbt cloud";
2636
const executionBody = `{"cause": "airbyte"}`;
2737
const jobName = (t: DbtCloudJob) => `${t.account}/${t.job}`;
2838

2939
const isDbtWebhookConfig = (webhookConfig: WebhookConfigRead) => !!webhookConfig.name?.includes("dbt");
3040

31-
const toDbtCloudJob = (operation: OperationRead): DbtCloudJob => {
32-
const { operationId } = operation;
33-
const { executionUrl } = operation.operatorConfiguration.webhook || {};
41+
export const toDbtCloudJob = (operationOrCloudJob: OperationRead | DbtCloudJobInfo): DbtCloudJob => {
42+
if ("operationId" in operationOrCloudJob) {
43+
const { operationId } = operationOrCloudJob;
44+
const { executionUrl } = operationOrCloudJob.operatorConfiguration.webhook || {};
3445

35-
const matches = (executionUrl || "").match(/\/accounts\/([^/]+)\/jobs\/([^]+)\/run/);
36-
if (!matches) {
37-
throw new Error(`Cannot extract dbt cloud job params from executionUrl ${executionUrl}`);
38-
} else {
39-
const [, account, job] = matches;
46+
const matches = (executionUrl || "").match(/\/accounts\/([^/]+)\/jobs\/([^]+)\/run/);
47+
if (!matches) {
48+
throw new Error(`Cannot extract dbt cloud job params from executionUrl ${executionUrl}`);
49+
} else {
50+
const [, account, job] = matches;
4051

41-
return {
42-
account,
43-
job,
44-
operationId,
45-
};
52+
return {
53+
account,
54+
job,
55+
operationId,
56+
};
57+
}
58+
} else {
59+
const { accountId, jobId, jobName } = operationOrCloudJob;
60+
return { account: `${accountId}`, job: `${jobId}`, jobName };
4661
}
4762
};
63+
4864
const isDbtCloudJob = (operation: OperationRead): boolean =>
4965
operation.operatorConfiguration.operatorType === OperatorType.webhook;
5066

67+
export const isSameJob = (remoteJob: DbtCloudJobInfo, savedJob: DbtCloudJob): boolean =>
68+
savedJob.account === `${remoteJob.accountId}` && savedJob.job === `${remoteJob.jobId}`;
69+
5170
export const useSubmitDbtCloudIntegrationConfig = () => {
5271
const { workspaceId } = useCurrentWorkspace();
5372
const { mutateAsync: updateWorkspace } = useUpdateWorkspace();
@@ -78,35 +97,64 @@ export const useDbtIntegration = (connection: WebBackendConnectionRead) => {
7897
);
7998
const otherOperations = [...(connection.operations?.filter((operation) => !isDbtCloudJob(operation)) || [])];
8099

81-
const saveJobs = (jobs: DbtCloudJob[]) => {
82-
// TODO dynamically use the workspace's configured dbt cloud domain when it gets returned by backend
83-
const urlForJob = (job: DbtCloudJob) => `${dbtCloudDomain}/api/v2/accounts/${job.account}/jobs/${job.job}/run/`;
84-
85-
return connectionService.update({
86-
connectionId: connection.connectionId,
87-
operations: [
88-
...otherOperations,
89-
...jobs.map((job) => ({
90-
workspaceId,
91-
...(job.operationId ? { operationId: job.operationId } : {}),
92-
name: jobName(job),
93-
operatorConfiguration: {
94-
operatorType: OperatorType.webhook,
95-
webhook: {
96-
executionUrl: urlForJob(job),
97-
// if `hasDbtIntegration` is true, webhookConfigId is guaranteed to exist
98-
...(webhookConfigId ? { webhookConfigId } : {}),
99-
executionBody,
100+
const { mutateAsync, isLoading } = useMutation({
101+
mutationFn: (jobs: DbtCloudJob[]) => {
102+
// TODO dynamically use the workspace's configured dbt cloud domain when it gets returned by backend
103+
const urlForJob = (job: DbtCloudJob) => `${dbtCloudDomain}/api/v2/accounts/${job.account}/jobs/${job.job}/run/`;
104+
105+
return connectionService.update({
106+
connectionId: connection.connectionId,
107+
operations: [
108+
...otherOperations,
109+
...jobs.map((job) => ({
110+
workspaceId,
111+
...(job.operationId ? { operationId: job.operationId } : {}),
112+
name: jobName(job),
113+
operatorConfiguration: {
114+
operatorType: OperatorType.webhook,
115+
webhook: {
116+
executionUrl: urlForJob(job),
117+
// if `hasDbtIntegration` is true, webhookConfigId is guaranteed to exist
118+
...(webhookConfigId ? { webhookConfigId } : {}),
119+
executionBody,
120+
},
100121
},
101-
},
102-
})),
103-
],
104-
});
105-
};
122+
})),
123+
],
124+
});
125+
},
126+
});
106127

107128
return {
108129
hasDbtIntegration,
109130
dbtCloudJobs,
110-
saveJobs,
131+
saveJobs: mutateAsync,
132+
isSaving: isLoading,
111133
};
112134
};
135+
136+
export const useAvailableDbtJobs = () => {
137+
const { cloudApiUrl } = useConfig();
138+
const config = { apiUrl: cloudApiUrl };
139+
const middlewares = useDefaultRequestMiddlewares();
140+
const requestOptions = { config, middlewares };
141+
const workspace = useCurrentWorkspace();
142+
const { workspaceId } = workspace;
143+
const dbtConfigId = workspace.webhookConfigs?.find((config) => config.name?.includes("dbt"))?.id;
144+
145+
if (!dbtConfigId) {
146+
throw new Error("cannot request available dbt jobs for a workspace with no dbt cloud integration configured");
147+
}
148+
149+
const results = useQuery(
150+
["dbtCloud", dbtConfigId, "list"],
151+
() => webBackendGetAvailableDbtJobsForWorkspace({ workspaceId, dbtConfigId }, requestOptions),
152+
{
153+
suspense: true,
154+
}
155+
);
156+
157+
// casting type to remove `| undefined`, since `suspense: true` will ensure the value
158+
// is, in fact, available
159+
return (results.data as WorkspaceGetDbtJobsResponse).availableDbtJobs;
160+
};

airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionTransformationTab/DbtCloudTransformationsCard.module.scss

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,20 +63,20 @@
6363
align-items: center;
6464
}
6565

66-
.jobListItemInputGroup {
66+
.jobListItemIdFieldGroup {
6767
display: flex;
6868
justify-content: space-between;
6969
align-items: center;
70+
flex-grow: 2;
7071
}
7172

72-
.jobListItemInput {
73+
.jobListItemIdField {
7374
height: fit-content;
7475
margin-left: 1em;
75-
}
76-
77-
.jobListItemInputLabel {
78-
font-size: 11px;
79-
font-weight: 500;
76+
background-color: colors.$grey-50;
77+
flex-grow: 2;
78+
padding: variables.$spacing-sm;
79+
border-radius: variables.$border-radius-sm;
8080
}
8181

8282
.jobListItemDelete {

0 commit comments

Comments
 (0)