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" ;
8
3
9
- import { FormChangeTracker } from "components/common/FormChangeTracker" ;
10
- import { Button } from "components/ui/Button" ;
11
4
import { Card } from "components/ui/Card" ;
12
- import { DropdownMenu } from "components/ui/DropdownMenu" ;
13
5
import { Text } from "components/ui/Text" ;
14
6
15
7
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 " ;
19
11
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 " ;
23
15
24
- interface DbtJobListValues {
25
- jobs : DbtCloudJob [ ] ;
16
+ interface DbtCloudErrorBoundaryProps {
17
+ trackError : TrackErrorFn ;
18
+ workspaceId : string ;
26
19
}
27
20
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
+
28
80
export const DbtCloudTransformationsCard = ( { connection } : { connection : WebBackendConnectionRead } ) => {
29
81
// Possible render paths:
30
82
// 1) IF the workspace has no dbt cloud account linked
@@ -37,194 +89,14 @@ export const DbtCloudTransformationsCard = ({ connection }: { connection: WebBac
37
89
// THEN show the jobs list and the "+ Add transformation" button
38
90
39
91
const { hasDbtIntegration, isSaving, saveJobs, dbtCloudJobs } = useDbtIntegration ( connection ) ;
92
+ const { trackError } = useAppMonitoringService ( ) ;
93
+ const workspaceId = useCurrentWorkspaceId ( ) ;
40
94
41
95
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 >
43
99
) : (
44
100
< NoDbtIntegration />
45
101
) ;
46
102
} ;
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
- } ;
0 commit comments