Skip to content

Commit 3ef7edf

Browse files
authored
🪟 🐛 Add DbtCloudErrorBoundary (#20616)
* 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
1 parent dbe5cd8 commit 3ef7edf

File tree

14 files changed

+378
-252
lines changed

14 files changed

+378
-252
lines changed

airbyte-webapp/src/packages/cloud/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161

6262
"connection.dbtCloudJobs.cardTitle": "Transformations",
6363
"connection.dbtCloudJobs.addJob": "Add transformation",
64+
"connection.dbtCloudJobs.dbtError": "There was an error communicating with dbt Cloud: {displayMessage}",
65+
"connection.dbtCloudJobs.genericError": "There was an error communicating with dbt Cloud.",
6466
"connection.dbtCloudJobs.explanation": "After an Airbyte sync job has completed, the following jobs will run",
6567
"connection.dbtCloudJobs.noJobs": "No transformations",
6668
"connection.dbtCloudJobs.job.title": "dbt Cloud transform",
Lines changed: 75 additions & 203 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,82 @@
1-
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
2-
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3-
import classNames from "classnames";
4-
import { Form, Formik, FieldArray, FormikHelpers } from "formik";
5-
import { ReactNode } from "react";
6-
import { FormattedMessage, useIntl } from "react-intl";
7-
import { Link } from "react-router-dom";
1+
import React from "react";
2+
import { FormattedMessage } from "react-intl";
83

9-
import { FormChangeTracker } from "components/common/FormChangeTracker";
10-
import { Button } from "components/ui/Button";
114
import { Card } from "components/ui/Card";
12-
import { DropdownMenu } from "components/ui/DropdownMenu";
135
import { Text } from "components/ui/Text";
146

157
import { WebBackendConnectionRead } from "core/request/AirbyteClient";
16-
import { useCurrentWorkspace } from "hooks/services/useWorkspace";
17-
import { DbtCloudJob, isSameJob, useDbtIntegration, useAvailableDbtJobs } from "packages/cloud/services/dbtCloud";
18-
import { RoutePaths } from "pages/routePaths";
8+
import { TrackErrorFn, useAppMonitoringService } from "hooks/services/AppMonitoringService";
9+
import { useDbtIntegration, useAvailableDbtJobs } from "packages/cloud/services/dbtCloud";
10+
import { useCurrentWorkspaceId } from "services/workspaces/WorkspacesService";
1911

20-
import dbtLogo from "./dbt-bit_tm.svg";
21-
import styles from "./DbtCloudTransformationsCard.module.scss";
22-
import octaviaWorker from "./octavia-worker.png";
12+
import styles from "./DbtCloudTransformationsCard/DbtCloudTransformationsCard.module.scss";
13+
import { DbtJobsForm } from "./DbtCloudTransformationsCard/DbtJobsForm";
14+
import { NoDbtIntegration } from "./DbtCloudTransformationsCard/NoDbtIntegration";
2315

24-
interface DbtJobListValues {
25-
jobs: DbtCloudJob[];
16+
interface DbtCloudErrorBoundaryProps {
17+
trackError: TrackErrorFn;
18+
workspaceId: string;
2619
}
2720

21+
class DbtCloudErrorBoundary extends React.Component<React.PropsWithChildren<DbtCloudErrorBoundaryProps>> {
22+
state = { error: null, displayMessage: null };
23+
24+
// TODO parse the error to determine if the source was the upstream network call to
25+
// the dbt Cloud API. If it is, extract the `user_message` field from dbt's error
26+
// response for display to user; if not, provide a more generic error message. If the
27+
// error was *definitely* not related to the dbt Cloud API, consider reraising it.
28+
static getDerivedStateFromError(error: Error) {
29+
// TODO I'm pretty sure I did not correctly mock the exact error response format.
30+
// eslint-disable-next-line
31+
const displayMessage = (error?.message as any)?.status?.user_message;
32+
return { error, displayMessage };
33+
}
34+
35+
componentDidCatch(error: Error) {
36+
const { trackError, workspaceId } = this.props;
37+
trackError(error, { workspaceId });
38+
}
39+
40+
render() {
41+
const { error, displayMessage } = this.state;
42+
if (error) {
43+
return (
44+
<Card
45+
title={
46+
<span className={styles.cardTitle}>
47+
<FormattedMessage id="connection.dbtCloudJobs.cardTitle" />
48+
</span>
49+
}
50+
>
51+
<Text centered className={styles.cardBodyContainer}>
52+
{displayMessage ? (
53+
<FormattedMessage id="connection.dbtCloudJobs.dbtError" values={{ displayMessage }} />
54+
) : (
55+
<FormattedMessage id="connection.dbtCloudJobs.genericError" />
56+
)}
57+
</Text>
58+
</Card>
59+
);
60+
}
61+
62+
return this.props.children;
63+
}
64+
}
65+
66+
type DbtIntegrationCardContentProps = Omit<ReturnType<typeof useDbtIntegration>, "hasDbtIntegration">;
67+
68+
const DbtIntegrationCardContent = ({ saveJobs, isSaving, dbtCloudJobs }: DbtIntegrationCardContentProps) => {
69+
const availableDbtJobs = useAvailableDbtJobs();
70+
return (
71+
<DbtJobsForm
72+
saveJobs={saveJobs}
73+
isSaving={isSaving}
74+
dbtCloudJobs={dbtCloudJobs}
75+
availableDbtCloudJobs={availableDbtJobs}
76+
/>
77+
);
78+
};
79+
2880
export const DbtCloudTransformationsCard = ({ connection }: { connection: WebBackendConnectionRead }) => {
2981
// Possible render paths:
3082
// 1) IF the workspace has no dbt cloud account linked
@@ -37,194 +89,14 @@ export const DbtCloudTransformationsCard = ({ connection }: { connection: WebBac
3789
// THEN show the jobs list and the "+ Add transformation" button
3890

3991
const { hasDbtIntegration, isSaving, saveJobs, dbtCloudJobs } = useDbtIntegration(connection);
92+
const { trackError } = useAppMonitoringService();
93+
const workspaceId = useCurrentWorkspaceId();
4094

4195
return hasDbtIntegration ? (
42-
<DbtJobsForm saveJobs={saveJobs} isSaving={isSaving} dbtCloudJobs={dbtCloudJobs} />
96+
<DbtCloudErrorBoundary trackError={trackError} workspaceId={workspaceId}>
97+
<DbtIntegrationCardContent saveJobs={saveJobs} isSaving={isSaving} dbtCloudJobs={dbtCloudJobs} />
98+
</DbtCloudErrorBoundary>
4399
) : (
44100
<NoDbtIntegration />
45101
);
46102
};
47-
48-
const NoDbtIntegration = () => {
49-
const { workspaceId } = useCurrentWorkspace();
50-
const dbtSettingsPath = `/${RoutePaths.Workspaces}/${workspaceId}/${RoutePaths.Settings}/dbt-cloud`;
51-
return (
52-
<Card
53-
title={
54-
<span className={styles.jobListTitle}>
55-
<FormattedMessage id="connection.dbtCloudJobs.cardTitle" />
56-
</span>
57-
}
58-
>
59-
<div className={classNames(styles.jobListContainer)}>
60-
<Text className={styles.contextExplanation}>
61-
<FormattedMessage
62-
id="connection.dbtCloudJobs.noIntegration"
63-
values={{
64-
settingsLink: (linkText: ReactNode) => <Link to={dbtSettingsPath}>{linkText}</Link>,
65-
}}
66-
/>
67-
</Text>
68-
</div>
69-
</Card>
70-
);
71-
};
72-
73-
interface DbtJobsFormProps {
74-
saveJobs: (jobs: DbtCloudJob[]) => Promise<unknown>;
75-
isSaving: boolean;
76-
dbtCloudJobs: DbtCloudJob[];
77-
}
78-
const DbtJobsForm: React.FC<DbtJobsFormProps> = ({ saveJobs, isSaving, dbtCloudJobs }) => {
79-
const onSubmit = (values: DbtJobListValues, { resetForm }: FormikHelpers<DbtJobListValues>) => {
80-
saveJobs(values.jobs).then(() => resetForm({ values }));
81-
};
82-
83-
const availableDbtJobs = useAvailableDbtJobs();
84-
// because we don't store names for saved jobs, just the account and job IDs needed for
85-
// webhook operation, we have to find the display names for saved jobs by comparing IDs
86-
// with the list of available jobs as provided by dbt Cloud.
87-
const jobs = dbtCloudJobs.map((savedJob) => {
88-
const { jobName } = availableDbtJobs.find((remoteJob) => isSameJob(remoteJob, savedJob)) || {};
89-
const { accountId, jobId } = savedJob;
90-
91-
return { accountId, jobId, jobName };
92-
});
93-
94-
return (
95-
<Formik
96-
onSubmit={onSubmit}
97-
initialValues={{ jobs }}
98-
render={({ values, dirty }) => {
99-
return (
100-
<Form className={styles.jobListForm}>
101-
<FormChangeTracker changed={dirty} />
102-
<FieldArray
103-
name="jobs"
104-
render={({ remove, push }) => {
105-
return (
106-
<Card
107-
title={
108-
<span className={styles.jobListTitle}>
109-
<FormattedMessage id="connection.dbtCloudJobs.cardTitle" />
110-
<DropdownMenu
111-
options={availableDbtJobs
112-
.filter((remoteJob) => !values.jobs.some((savedJob) => isSameJob(remoteJob, savedJob)))
113-
.map((job) => ({ displayName: job.jobName, value: job }))}
114-
onChange={(selection) => {
115-
push(selection.value);
116-
}}
117-
>
118-
{() => (
119-
<Button variant="secondary" icon={<FontAwesomeIcon icon={faPlus} />}>
120-
<FormattedMessage id="connection.dbtCloudJobs.addJob" />
121-
</Button>
122-
)}
123-
</DropdownMenu>
124-
</span>
125-
}
126-
>
127-
<DbtJobsList jobs={values.jobs} remove={remove} dirty={dirty} isLoading={isSaving} />
128-
</Card>
129-
);
130-
}}
131-
/>
132-
</Form>
133-
);
134-
}}
135-
/>
136-
);
137-
};
138-
139-
interface DbtJobsListProps {
140-
jobs: DbtCloudJob[];
141-
remove: (i: number) => void;
142-
dirty: boolean;
143-
isLoading: boolean;
144-
}
145-
146-
const DbtJobsList = ({ jobs, remove, dirty, isLoading }: DbtJobsListProps) => {
147-
const { formatMessage } = useIntl();
148-
149-
return (
150-
<div className={classNames(styles.jobListContainer)}>
151-
{jobs.length ? (
152-
<>
153-
<Text className={styles.contextExplanation}>
154-
<FormattedMessage id="connection.dbtCloudJobs.explanation" />
155-
</Text>
156-
{jobs.map((job, i) => (
157-
<JobsListItem key={i} job={job} removeJob={() => remove(i)} isLoading={isLoading} />
158-
))}
159-
</>
160-
) : (
161-
<>
162-
<img src={octaviaWorker} alt="" className={styles.emptyListImage} />
163-
<FormattedMessage id="connection.dbtCloudJobs.noJobs" />
164-
</>
165-
)}
166-
<div className={styles.jobListButtonGroup}>
167-
<Button className={styles.jobListButton} type="reset" variant="secondary">
168-
{formatMessage({ id: "form.cancel" })}
169-
</Button>
170-
<Button
171-
className={styles.jobListButton}
172-
type="submit"
173-
variant="primary"
174-
disabled={!dirty}
175-
isLoading={isLoading}
176-
>
177-
{formatMessage({ id: "form.saveChanges" })}
178-
</Button>
179-
</div>
180-
</div>
181-
);
182-
};
183-
184-
interface JobsListItemProps {
185-
job: DbtCloudJob;
186-
removeJob: () => void;
187-
isLoading: boolean;
188-
}
189-
const JobsListItem = ({ job, removeJob, isLoading }: JobsListItemProps) => {
190-
const { formatMessage } = useIntl();
191-
// TODO if `job.jobName` is undefined, that means we failed to match any of the
192-
// dbt-Cloud-supplied jobs with the saved job. This means one of two things has
193-
// happened:
194-
// 1) the user deleted the job in dbt Cloud, and we should make them delete it from
195-
// their webhook operations. If we have a nonempty list of other dbt Cloud jobs,
196-
// it's definitely this.
197-
// 2) the API call to fetch the names failed somehow (possibly with a 200 status, if there's a bug)
198-
const title = <Text>{job.jobName || formatMessage({ id: "connection.dbtCloudJobs.job.title" })}</Text>;
199-
200-
return (
201-
<Card className={styles.jobListItem}>
202-
<div className={styles.jobListItemIntegrationName}>
203-
<img src={dbtLogo} alt="" className={styles.dbtLogo} />
204-
{title}
205-
</div>
206-
<div className={styles.jobListItemIdFieldGroup}>
207-
<div className={styles.jobListItemIdField}>
208-
<Text size="sm">
209-
{formatMessage({ id: "connection.dbtCloudJobs.job.accountId" })}: {job.accountId}
210-
</Text>
211-
</div>
212-
<div className={styles.jobListItemIdField}>
213-
<Text size="sm">
214-
{formatMessage({ id: "connection.dbtCloudJobs.job.jobId" })}: {job.jobId}
215-
</Text>
216-
</div>
217-
<Button
218-
variant="clear"
219-
size="lg"
220-
className={styles.jobListItemDelete}
221-
onClick={removeJob}
222-
disabled={isLoading}
223-
aria-label={formatMessage({ id: "connection.dbtCloudJobs.job.deleteButton" })}
224-
>
225-
<FontAwesomeIcon icon={faXmark} height="21" width="21" />
226-
</Button>
227-
</div>
228-
</Card>
229-
);
230-
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
@use "scss/colors";
2+
@use "scss/variables";
3+
4+
.cardTitle {
5+
display: flex;
6+
justify-content: space-between;
7+
}
8+
9+
.cardBodyContainer {
10+
display: flex;
11+
flex-direction: column;
12+
align-items: center;
13+
padding: variables.$spacing-xl;
14+
background-color: colors.$grey-50;
15+
}
16+
17+
.contextExplanation {
18+
color: colors.$grey-300;
19+
width: 100%;
20+
21+
& a {
22+
color: colors.$grey-300;
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@forward "./DbtCloudCard.scss";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@forward "./DbtCloudCard.scss";
2+
3+
.jobListForm {
4+
width: 100%;
5+
}

0 commit comments

Comments
 (0)