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

🪟 🐛 Add DbtCloudErrorBoundary #20616

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
2 changes: 2 additions & 0 deletions airbyte-webapp/src/packages/cloud/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@

"connection.dbtCloudJobs.cardTitle": "Transformations",
"connection.dbtCloudJobs.addJob": "Add transformation",
"connection.dbtCloudJobs.dbtError": "There was an error communicating with dbt Cloud: {displayMessage}",
"connection.dbtCloudJobs.genericError": "There was an error communicating with dbt Cloud.",
"connection.dbtCloudJobs.explanation": "After an Airbyte sync job has completed, the following jobs will run",
"connection.dbtCloudJobs.noJobs": "No transformations",
"connection.dbtCloudJobs.job.title": "dbt Cloud transform",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,82 @@
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import classNames from "classnames";
import { Form, Formik, FieldArray, FormikHelpers } from "formik";
import { ReactNode } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Link } from "react-router-dom";
import React from "react";
import { FormattedMessage } from "react-intl";

import { FormChangeTracker } from "components/common/FormChangeTracker";
import { Button } from "components/ui/Button";
import { Card } from "components/ui/Card";
import { DropdownMenu } from "components/ui/DropdownMenu";
import { Text } from "components/ui/Text";

import { WebBackendConnectionRead } from "core/request/AirbyteClient";
import { useCurrentWorkspace } from "hooks/services/useWorkspace";
import { DbtCloudJob, isSameJob, useDbtIntegration, useAvailableDbtJobs } from "packages/cloud/services/dbtCloud";
import { RoutePaths } from "pages/routePaths";
import { TrackErrorFn, useAppMonitoringService } from "hooks/services/AppMonitoringService";
import { useDbtIntegration, useAvailableDbtJobs } from "packages/cloud/services/dbtCloud";
import { useCurrentWorkspaceId } from "services/workspaces/WorkspacesService";

import dbtLogo from "./dbt-bit_tm.svg";
import styles from "./DbtCloudTransformationsCard.module.scss";
import octaviaWorker from "./octavia-worker.png";
import styles from "./DbtCloudTransformationsCard/DbtCloudTransformationsCard.module.scss";
import { DbtJobsForm } from "./DbtCloudTransformationsCard/DbtJobsForm";
import { NoDbtIntegration } from "./DbtCloudTransformationsCard/NoDbtIntegration";

interface DbtJobListValues {
jobs: DbtCloudJob[];
interface DbtCloudErrorBoundaryProps {
trackError: TrackErrorFn;
workspaceId: string;
}

class DbtCloudErrorBoundary extends React.Component<React.PropsWithChildren<DbtCloudErrorBoundaryProps>> {
state = { error: null, displayMessage: null };

// TODO parse the error to determine if the source was the upstream network call to
// the dbt Cloud API. If it is, extract the `user_message` field from dbt's error
// response for display to user; if not, provide a more generic error message. If the
// error was *definitely* not related to the dbt Cloud API, consider reraising it.
static getDerivedStateFromError(error: Error) {
// TODO I'm pretty sure I did not correctly mock the exact error response format.
// eslint-disable-next-line
const displayMessage = (error?.message as any)?.status?.user_message;
return { error, displayMessage };
}

componentDidCatch(error: Error) {
const { trackError, workspaceId } = this.props;
trackError(error, { workspaceId });
}

render() {
const { error, displayMessage } = this.state;
if (error) {
return (
<Card
title={
<span className={styles.cardTitle}>
<FormattedMessage id="connection.dbtCloudJobs.cardTitle" />
</span>
}
>
<Text centered className={styles.cardBodyContainer}>
{displayMessage ? (
<FormattedMessage id="connection.dbtCloudJobs.dbtError" values={{ displayMessage }} />
) : (
<FormattedMessage id="connection.dbtCloudJobs.genericError" />
)}
</Text>
</Card>
);
}

return this.props.children;
}
}

type DbtIntegrationCardContentProps = Omit<ReturnType<typeof useDbtIntegration>, "hasDbtIntegration">;

const DbtIntegrationCardContent = ({ saveJobs, isSaving, dbtCloudJobs }: DbtIntegrationCardContentProps) => {
const availableDbtJobs = useAvailableDbtJobs();
return (
<DbtJobsForm
saveJobs={saveJobs}
isSaving={isSaving}
dbtCloudJobs={dbtCloudJobs}
availableDbtCloudJobs={availableDbtJobs}
/>
);
};

export const DbtCloudTransformationsCard = ({ connection }: { connection: WebBackendConnectionRead }) => {
// Possible render paths:
// 1) IF the workspace has no dbt cloud account linked
Expand All @@ -37,194 +89,14 @@ export const DbtCloudTransformationsCard = ({ connection }: { connection: WebBac
// THEN show the jobs list and the "+ Add transformation" button

const { hasDbtIntegration, isSaving, saveJobs, dbtCloudJobs } = useDbtIntegration(connection);
const { trackError } = useAppMonitoringService();
const workspaceId = useCurrentWorkspaceId();

return hasDbtIntegration ? (
<DbtJobsForm saveJobs={saveJobs} isSaving={isSaving} dbtCloudJobs={dbtCloudJobs} />
<DbtCloudErrorBoundary trackError={trackError} workspaceId={workspaceId}>
<DbtIntegrationCardContent saveJobs={saveJobs} isSaving={isSaving} dbtCloudJobs={dbtCloudJobs} />
</DbtCloudErrorBoundary>
) : (
<NoDbtIntegration />
);
};

const NoDbtIntegration = () => {
const { workspaceId } = useCurrentWorkspace();
const dbtSettingsPath = `/${RoutePaths.Workspaces}/${workspaceId}/${RoutePaths.Settings}/dbt-cloud`;
return (
<Card
title={
<span className={styles.jobListTitle}>
<FormattedMessage id="connection.dbtCloudJobs.cardTitle" />
</span>
}
>
<div className={classNames(styles.jobListContainer)}>
<Text className={styles.contextExplanation}>
<FormattedMessage
id="connection.dbtCloudJobs.noIntegration"
values={{
settingsLink: (linkText: ReactNode) => <Link to={dbtSettingsPath}>{linkText}</Link>,
}}
/>
</Text>
</div>
</Card>
);
};

interface DbtJobsFormProps {
saveJobs: (jobs: DbtCloudJob[]) => Promise<unknown>;
isSaving: boolean;
dbtCloudJobs: DbtCloudJob[];
}
const DbtJobsForm: React.FC<DbtJobsFormProps> = ({ saveJobs, isSaving, dbtCloudJobs }) => {
const onSubmit = (values: DbtJobListValues, { resetForm }: FormikHelpers<DbtJobListValues>) => {
saveJobs(values.jobs).then(() => resetForm({ values }));
};

const availableDbtJobs = useAvailableDbtJobs();
// because we don't store names for saved jobs, just the account and job IDs needed for
// webhook operation, we have to find the display names for saved jobs by comparing IDs
// with the list of available jobs as provided by dbt Cloud.
const jobs = dbtCloudJobs.map((savedJob) => {
const { jobName } = availableDbtJobs.find((remoteJob) => isSameJob(remoteJob, savedJob)) || {};
const { accountId, jobId } = savedJob;

return { accountId, jobId, jobName };
});

return (
<Formik
onSubmit={onSubmit}
initialValues={{ jobs }}
render={({ values, dirty }) => {
return (
<Form className={styles.jobListForm}>
<FormChangeTracker changed={dirty} />
<FieldArray
name="jobs"
render={({ remove, push }) => {
return (
<Card
title={
<span className={styles.jobListTitle}>
<FormattedMessage id="connection.dbtCloudJobs.cardTitle" />
<DropdownMenu
options={availableDbtJobs
.filter((remoteJob) => !values.jobs.some((savedJob) => isSameJob(remoteJob, savedJob)))
.map((job) => ({ displayName: job.jobName, value: job }))}
onChange={(selection) => {
push(selection.value);
}}
>
{() => (
<Button variant="secondary" icon={<FontAwesomeIcon icon={faPlus} />}>
<FormattedMessage id="connection.dbtCloudJobs.addJob" />
</Button>
)}
</DropdownMenu>
</span>
}
>
<DbtJobsList jobs={values.jobs} remove={remove} dirty={dirty} isLoading={isSaving} />
</Card>
);
}}
/>
</Form>
);
}}
/>
);
};

interface DbtJobsListProps {
jobs: DbtCloudJob[];
remove: (i: number) => void;
dirty: boolean;
isLoading: boolean;
}

const DbtJobsList = ({ jobs, remove, dirty, isLoading }: DbtJobsListProps) => {
const { formatMessage } = useIntl();

return (
<div className={classNames(styles.jobListContainer)}>
{jobs.length ? (
<>
<Text className={styles.contextExplanation}>
<FormattedMessage id="connection.dbtCloudJobs.explanation" />
</Text>
{jobs.map((job, i) => (
<JobsListItem key={i} job={job} removeJob={() => remove(i)} isLoading={isLoading} />
))}
</>
) : (
<>
<img src={octaviaWorker} alt="" className={styles.emptyListImage} />
<FormattedMessage id="connection.dbtCloudJobs.noJobs" />
</>
)}
<div className={styles.jobListButtonGroup}>
<Button className={styles.jobListButton} type="reset" variant="secondary">
{formatMessage({ id: "form.cancel" })}
</Button>
<Button
className={styles.jobListButton}
type="submit"
variant="primary"
disabled={!dirty}
isLoading={isLoading}
>
{formatMessage({ id: "form.saveChanges" })}
</Button>
</div>
</div>
);
};

interface JobsListItemProps {
job: DbtCloudJob;
removeJob: () => void;
isLoading: boolean;
}
const JobsListItem = ({ job, removeJob, isLoading }: JobsListItemProps) => {
const { formatMessage } = useIntl();
// TODO if `job.jobName` is undefined, that means we failed to match any of the
// dbt-Cloud-supplied jobs with the saved job. This means one of two things has
// happened:
// 1) the user deleted the job in dbt Cloud, and we should make them delete it from
// their webhook operations. If we have a nonempty list of other dbt Cloud jobs,
// it's definitely this.
// 2) the API call to fetch the names failed somehow (possibly with a 200 status, if there's a bug)
const title = <Text>{job.jobName || formatMessage({ id: "connection.dbtCloudJobs.job.title" })}</Text>;

return (
<Card className={styles.jobListItem}>
<div className={styles.jobListItemIntegrationName}>
<img src={dbtLogo} alt="" className={styles.dbtLogo} />
{title}
</div>
<div className={styles.jobListItemIdFieldGroup}>
<div className={styles.jobListItemIdField}>
<Text size="sm">
{formatMessage({ id: "connection.dbtCloudJobs.job.accountId" })}: {job.accountId}
</Text>
</div>
<div className={styles.jobListItemIdField}>
<Text size="sm">
{formatMessage({ id: "connection.dbtCloudJobs.job.jobId" })}: {job.jobId}
</Text>
</div>
<Button
variant="clear"
size="lg"
className={styles.jobListItemDelete}
onClick={removeJob}
disabled={isLoading}
aria-label={formatMessage({ id: "connection.dbtCloudJobs.job.deleteButton" })}
>
<FontAwesomeIcon icon={faXmark} height="21" width="21" />
</Button>
</div>
</Card>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@use "scss/colors";
@use "scss/variables";

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

.cardBodyContainer {
display: flex;
flex-direction: column;
align-items: center;
padding: variables.$spacing-xl;
background-color: colors.$grey-50;
}

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

& a {
color: colors.$grey-300;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@forward "./DbtCloudCard.scss";
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@forward "./DbtCloudCard.scss";

.jobListForm {
width: 100%;
}
Loading