Skip to content

Commit

Permalink
Extract UI helper components from DbtCloudTransformationsCard.tsx
Browse files Browse the repository at this point in the history
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`)
  • Loading branch information
ambirdsall committed Feb 1, 2023
1 parent bfb5dc7 commit 9634479
Show file tree
Hide file tree
Showing 13 changed files with 317 additions and 255 deletions.
Original file line number Diff line number Diff line change
@@ -1,35 +1,23 @@
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 React, { 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 { TrackErrorFn, useAppMonitoringService } from "hooks/services/AppMonitoringService";
import { DbtCloudJob, isSameJob, useDbtIntegration, useAvailableDbtJobs } from "packages/cloud/services/dbtCloud";
import { RoutePaths } from "pages/routePaths";
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";

interface DbtJobListValues {
jobs: DbtCloudJob[];
}
import styles from "./DbtCloudTransformationsCard/DbtCloudTransformationsCard.module.scss";
import { DbtJobsForm } from "./DbtCloudTransformationsCard/DbtJobsForm";
import { NoDbtIntegration } from "./DbtCloudTransformationsCard/NoDbtIntegration";

interface DbtCloudErrorBoundaryProps {
trackError: TrackErrorFn;
workspaceId: string;
}

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

Expand All @@ -55,12 +43,12 @@ class DbtCloudErrorBoundary extends React.Component<React.PropsWithChildren<DbtC
return (
<Card
title={
<span className={styles.jobListTitle}>
<span className={styles.cardTitle}>
<FormattedMessage id="connection.dbtCloudJobs.cardTitle" />
</span>
}
>
<Text centered className={styles.jobListContainer}>
<Text centered className={styles.cardBodyContainer}>
{displayMessage ? (
<FormattedMessage id="connection.dbtCloudJobs.dbtError" values={{ displayMessage }} />
) : (
Expand All @@ -87,198 +75,20 @@ export const DbtCloudTransformationsCard = ({ connection }: { connection: WebBac
// THEN show the jobs list and the "+ Add transformation" button

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

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

const NoDbtIntegration = () => {
const workspaceId = useCurrentWorkspaceId();
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%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Form, Formik, FieldArray, FormikHelpers } from "formik";
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 { DbtCloudJobInfo } from "packages/cloud/lib/domain/dbtCloud";
import { DbtCloudJob, isSameJob } from "packages/cloud/services/dbtCloud";

import styles from "./DbtJobsForm.module.scss";
import { JobsList } from "./JobsList";

interface DbtJobListValues {
jobs: DbtCloudJob[];
}

interface DbtJobsFormProps {
saveJobs: (jobs: DbtCloudJob[]) => Promise<unknown>;
isSaving: boolean;
dbtCloudJobs: DbtCloudJob[];
availableDbtCloudJobs: DbtCloudJobInfo[];
}

export const DbtJobsForm: React.FC<DbtJobsFormProps> = ({
saveJobs,
isSaving,
dbtCloudJobs,
availableDbtCloudJobs,
}) => {
const onSubmit = (values: DbtJobListValues, { resetForm }: FormikHelpers<DbtJobListValues>) => {
saveJobs(values.jobs).then(() => resetForm({ values }));
};

// 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 } = availableDbtCloudJobs.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.cardTitle}>
<FormattedMessage id="connection.dbtCloudJobs.cardTitle" />
<DropdownMenu
options={availableDbtCloudJobs
.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>
}
>
<JobsList jobs={values.jobs} remove={remove} dirty={dirty} isLoading={isSaving} />
</Card>
);
}}
/>
</Form>
);
}}
/>
);
};
Loading

0 comments on commit 9634479

Please sign in to comment.