Skip to content

Commit

Permalink
🪟 🐛 Add DbtCloudErrorBoundary (#20616)
Browse files Browse the repository at this point in the history
* Add WIP DbtCloudErrorBoundary

* get workspaceId via useCurrentWorkspaceId

It's a bit more efficient and I don't need any of the other things
provided by `useCurrentWorkspace`.

* Track dbt Cloud errors via AppMonitoringService

* Extract UI helper components from DbtCloudTransformationsCard.tsx

I put a little effort into keeping all of the API interactions within
the top-level component, and a linting rule required me to split out
individual stylesheets for each helper component (plus one non-module
scss file for shared card styles that individual scss modules can
`@forward`)

* Put available jobs query within error boundary
  • Loading branch information
ambirdsall authored Feb 6, 2023
1 parent dbe5cd8 commit 3ef7edf
Show file tree
Hide file tree
Showing 14 changed files with 378 additions and 252 deletions.
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

0 comments on commit 3ef7edf

Please sign in to comment.