Skip to content

🪟 🎉 Select dbt jobs with dropdown #19502

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

Merged
merged 8 commits into from
Nov 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 55 additions & 0 deletions airbyte-webapp/src/packages/cloud/lib/domain/dbtCloud/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { apiOverride } from "core/request/apiOverride";

/**
* Get the available dbt Cloud jobs associated with the given workspace config.
*/
export interface WorkspaceGetDbtJobsRequest {
workspaceId: WorkspaceId;
/** The config id associated with the dbt Cloud config, references the webhookConfigId in the core API. */
dbtConfigId: string;
}

/**
* The available dbt Cloud jobs for the requested workspace config
*/
export interface WorkspaceGetDbtJobsResponse {
availableDbtJobs: DbtCloudJobInfo[];
}

/**
* A dbt Cloud job
*/
export interface DbtCloudJobInfo {
/** The account id associated with the job */
accountId: number;
/** The the specific job id returned by the dbt Cloud API */
jobId: number;
/** The human-readable name of the job returned by the dbt Cloud API */
jobName: string;
}

/**
* @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.
*/
export const webBackendGetAvailableDbtJobsForWorkspace = (
workspaceGetDbtJobsRequest: WorkspaceGetDbtJobsRequest,
options?: SecondParameter<typeof apiOverride>
) => {
return apiOverride<WorkspaceGetDbtJobsResponse>(
{
url: `/v1/web_backend/cloud_workspaces/get_available_dbt_jobs`,
method: "post",
headers: { "Content-Type": "application/json" },
data: workspaceGetDbtJobsRequest,
},
options
);
};

/**
* Workspace Id from OSS Airbyte instance
*/
export type WorkspaceId = string;

// eslint-disable-next-line
type SecondParameter<T extends (...args: any) => any> = T extends (config: any, args: infer P) => any ? P : never;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./api";
126 changes: 87 additions & 39 deletions airbyte-webapp/src/packages/cloud/services/dbtCloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,45 +9,64 @@
// - custom domains aren't yet supported

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

import { OperatorType, WebBackendConnectionRead, OperationRead, WebhookConfigRead } from "core/request/AirbyteClient";
import { useWebConnectionService } from "hooks/services/useConnectionHook";
import { useCurrentWorkspace } from "hooks/services/useWorkspace";
import {
DbtCloudJobInfo,
webBackendGetAvailableDbtJobsForWorkspace,
WorkspaceGetDbtJobsResponse,
} from "packages/cloud/lib/domain/dbtCloud/api";
import { useDefaultRequestMiddlewares } from "services/useDefaultRequestMiddlewares";
import { useUpdateWorkspace } from "services/workspaces/WorkspacesService";

import { useConfig } from "./config";

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

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

const toDbtCloudJob = (operation: OperationRead): DbtCloudJob => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

toDbtCloudJob is rewritten to handle either a saved webhook (OperationRead) or dbt-Cloud-supplied JSON job record (DbtCloudJobInfo) as input; existing logic is unchanged except for wrapping it in a type-narrowing conditional.

const { operationId } = operation;
const { executionUrl } = operation.operatorConfiguration.webhook || {};
export const toDbtCloudJob = (operationOrCloudJob: OperationRead | DbtCloudJobInfo): DbtCloudJob => {
if ("operationId" in operationOrCloudJob) {
const { operationId } = operationOrCloudJob;
const { executionUrl } = operationOrCloudJob.operatorConfiguration.webhook || {};

const matches = (executionUrl || "").match(/\/accounts\/([^/]+)\/jobs\/([^]+)\/run/);
if (!matches) {
throw new Error(`Cannot extract dbt cloud job params from executionUrl ${executionUrl}`);
} else {
const [, account, job] = matches;
const matches = (executionUrl || "").match(/\/accounts\/([^/]+)\/jobs\/([^]+)\/run/);
if (!matches) {
throw new Error(`Cannot extract dbt cloud job params from executionUrl ${executionUrl}`);
} else {
const [, account, job] = matches;

return {
account,
job,
operationId,
};
return {
account,
job,
operationId,
};
}
} else {
const { accountId, jobId, jobName } = operationOrCloudJob;
return { account: `${accountId}`, job: `${jobId}`, jobName };
}
};

const isDbtCloudJob = (operation: OperationRead): boolean =>
operation.operatorConfiguration.operatorType === OperatorType.webhook;

export const isSameJob = (remoteJob: DbtCloudJobInfo, savedJob: DbtCloudJob): boolean =>
savedJob.account === `${remoteJob.accountId}` && savedJob.job === `${remoteJob.jobId}`;

export const useSubmitDbtCloudIntegrationConfig = () => {
const { workspaceId } = useCurrentWorkspace();
const { mutateAsync: updateWorkspace } = useUpdateWorkspace();
Expand Down Expand Up @@ -78,35 +97,64 @@ export const useDbtIntegration = (connection: WebBackendConnectionRead) => {
);
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,
const { mutateAsync, isLoading } = useMutation({
mutationFn: (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,
saveJobs: mutateAsync,
isSaving: isLoading,
};
};

export const useAvailableDbtJobs = () => {
const { cloudApiUrl } = useConfig();
const config = { apiUrl: cloudApiUrl };
const middlewares = useDefaultRequestMiddlewares();
const requestOptions = { config, middlewares };
const workspace = useCurrentWorkspace();
const { workspaceId } = workspace;
const dbtConfigId = workspace.webhookConfigs?.find((config) => config.name?.includes("dbt"))?.id;

if (!dbtConfigId) {
throw new Error("cannot request available dbt jobs for a workspace with no dbt cloud integration configured");
}

const results = useQuery(
["dbtCloud", dbtConfigId, "list"],
() => webBackendGetAvailableDbtJobsForWorkspace({ workspaceId, dbtConfigId }, requestOptions),
{
suspense: true,
}
);

// casting type to remove `| undefined`, since `suspense: true` will ensure the value
// is, in fact, available
return (results.data as WorkspaceGetDbtJobsResponse).availableDbtJobs;
Comment on lines +149 to +159
Copy link
Contributor

Choose a reason for hiding this comment

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

You can use useSuspenseQuery() wrapper to avoid the | undefined and { suspense: true } option. I don't know why useSuspenseQuery() currently lives in services/connector/useSuspenseQuery, but that's where you can find it!

};
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,20 @@
align-items: center;
}

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

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

.jobListItemInputLabel {
font-size: 11px;
font-weight: 500;
background-color: colors.$grey-50;
flex-grow: 2;
padding: variables.$spacing-sm;
border-radius: variables.$border-radius-sm;
Copy link
Contributor

Choose a reason for hiding this comment

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

image

Styling of the icon / border radius looks a bit off? (icon too big, text too close to icon, border radius). Maybe I'm looking at an old Figma design.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed, it could use a few small tweaks. The icon size at least is as designed, but the text is definitely a bit too close and now that it holds the job's display name instead of a generic "hey this is a dbt cloud job" title, it's probably a bit too small also. I'm going to merge this as-is for now, though, so Sophia can test it out while rewriting the documentation to reflect the updated UI.

}

.jobListItemDelete {
Expand Down
Loading