Skip to content

Commit

Permalink
🪟 🎉 New connection header CTAs (#11981)
Browse files Browse the repository at this point in the history
Co-authored-by: Chandler Prall <[email protected]>
  • Loading branch information
dizel852 and chandlerprall committed Apr 17, 2024
1 parent 7a50d2f commit da36da8
Show file tree
Hide file tree
Showing 31 changed files with 729 additions and 45 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { Switch } from "components/ui/Switch";
import { useCurrentWorkspace, useUpdateConnection } from "core/api";
import { ConnectionId, ConnectionStatus, SchemaChange } from "core/api/types/AirbyteClient";
import { useIntent } from "core/utils/rbac";

import { useAnalyticsTrackFunctions } from "../useAnalyticTrackFunction";
import { useAnalyticsTrackFunctions } from "hooks/services/ConnectionEdit/useAnalyticsTrackFunctions";

interface StateSwitchCellProps {
connectionId: ConnectionId;
Expand All @@ -16,7 +15,7 @@ interface StateSwitchCellProps {
}

export const StateSwitchCell: React.FC<StateSwitchCellProps> = ({ connectionId, enabled, schemaChange }) => {
const { trackConnectionUpdate } = useAnalyticsTrackFunctions();
const { trackConnectionStatusUpdate } = useAnalyticsTrackFunctions();
const { workspaceId } = useCurrentWorkspace();
const canEditConnection = useIntent("EditConnection", { workspaceId });
const { mutateAsync: updateConnection, isLoading } = useUpdateConnection();
Expand All @@ -26,7 +25,7 @@ export const StateSwitchCell: React.FC<StateSwitchCellProps> = ({ connectionId,
connectionId,
status: checked ? ConnectionStatus.active : ConnectionStatus.inactive,
});
trackConnectionUpdate(updatedConnection);
trackConnectionStatusUpdate(updatedConnection);
};

const isDisabled = schemaChange === SchemaChange.breaking || !canEditConnection || isLoading;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.switch {
width: 90px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from "react";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";

import { Box } from "components/ui/Box";
import { Button } from "components/ui/Button";
import { FlexContainer } from "components/ui/Flex";
import { SwitchNext } from "components/ui/SwitchNext";
import { Text } from "components/ui/Text";
import { Tooltip } from "components/ui/Tooltip";

import { ConnectionStatus } from "core/api/types/AirbyteClient";
import { useSchemaChanges } from "hooks/connection/useSchemaChanges";
import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService";
import { useConnectionFormService } from "hooks/services/ConnectionForm/ConnectionFormService";
import { ConnectionRoutePaths } from "pages/routePaths";

import styles from "./ConnectionHeaderControls.module.scss";
import { FormattedScheduleDataMessage } from "./FormattedScheduleDataMessage";
import { useConnectionStatus } from "../ConnectionStatus/useConnectionStatus";
import { useConnectionSyncContext } from "../ConnectionSync/ConnectionSyncContext";
import { FreeHistoricalSyncIndicator } from "../EnabledControl/FreeHistoricalSyncIndicator";

export const ConnectionHeaderControls: React.FC = () => {
const { mode } = useConnectionFormService();
const { connection, updateConnectionStatus, connectionUpdating } = useConnectionEditService();
const { hasBreakingSchemaChange } = useSchemaChanges(connection.schemaChange);
const navigate = useNavigate();

const connectionStatus = useConnectionStatus(connection.connectionId ?? "");
const isReadOnly = mode === "readonly";

const { syncStarting, cancelStarting, cancelJob, syncConnection, connectionEnabled, resetStarting, jobResetRunning } =
useConnectionSyncContext();

const onScheduleBtnClick = () => {
navigate(`${ConnectionRoutePaths.Settings}`, {
state: { action: "scheduleType" },
});
};

const onChangeStatus = async (checked: boolean) =>
await updateConnectionStatus(checked ? ConnectionStatus.active : ConnectionStatus.inactive);

const isDisabled = isReadOnly || syncStarting || cancelStarting || resetStarting;
const isStartSyncBtnDisabled = isDisabled || !connectionEnabled;
const isCancelBtnDisabled = isDisabled || connectionUpdating;
const isSwitchDisabled = isDisabled || hasBreakingSchemaChange;

return (
<FlexContainer alignItems="center" gap="none">
<FreeHistoricalSyncIndicator />
<Tooltip
control={
<Button icon="clockOutline" variant="clear" onClick={onScheduleBtnClick}>
<FormattedScheduleDataMessage
scheduleType={connection.scheduleType}
scheduleData={connection.scheduleData}
/>
</Button>
}
placement="top"
>
<FormattedMessage id="connection.header.frequency.tooltip" />
</Tooltip>
{!connectionStatus.isRunning && (
<Button
onClick={syncConnection}
variant="clear"
data-testid="manual-sync-button"
disabled={isStartSyncBtnDisabled}
icon={syncStarting ? "loading" : "sync"}
iconSize="sm"
iconColor="primary"
>
<Text size="md" color="blue" bold>
<FormattedMessage id="connection.startSync" />
</Text>
</Button>
)}
{connectionStatus.isRunning && cancelJob && (
<Button
onClick={cancelJob}
disabled={isCancelBtnDisabled}
variant="clear"
icon={cancelStarting ? "loading" : "cross"}
iconColor="error"
>
<Text size="md" color="red" bold>
<FormattedMessage
id={resetStarting || jobResetRunning ? "connection.cancelReset" : "connection.cancelSync"}
/>
</Text>
</Button>
)}
<Box p="md">
<SwitchNext
onChange={onChangeStatus}
checked={connection.status === ConnectionStatus.active}
loading={connectionUpdating}
disabled={isSwitchDisabled}
className={styles.switch}
/>
</Box>
</FlexContainer>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render } from "@testing-library/react";

import { TestWrapper } from "test-utils";

import { ConnectionScheduleData, ConnectionScheduleDataBasicScheduleTimeUnit } from "core/api/types/AirbyteClient";

import { FormattedScheduleDataMessage, FormattedScheduleDataMessageProps } from "./FormattedScheduleDataMessage";

describe("FormattedScheduleDataMessage", () => {
const renderComponent = (props: FormattedScheduleDataMessageProps) => {
return render(
<TestWrapper>
<FormattedScheduleDataMessage {...props} />
</TestWrapper>
);
};

it("should render 'Manual' schedule type if scheduleData wasn't provided", () => {
const { getByText } = renderComponent({ scheduleType: "manual" });
expect(getByText("Manual")).toBeInTheDocument();
});

it("should render '24 hours' schedule type", () => {
const scheduleData = {
basicSchedule: {
units: 24,
timeUnit: "hours" as ConnectionScheduleDataBasicScheduleTimeUnit,
},
};
const { getByText } = renderComponent({ scheduleType: "basic", scheduleData });
expect(getByText("Every 24 hours")).toBeInTheDocument();
});

it("should render 'Cron' schedule type with humanized format", () => {
const scheduleData = {
cron: {
cronExpression: "0 0 14 ? * THU" as string,
cronTimeZone: "UTC",
},
};
const { getByText } = renderComponent({ scheduleType: "cron", scheduleData });
expect(getByText("At 02:00 PM, only on Thursday")).toBeInTheDocument();
});

it("should NOT render anything", () => {
const scheduleData = {
basic: {
units: 24,
timeUnit: "hours" as ConnectionScheduleDataBasicScheduleTimeUnit,
},
};
const { queryByText } = renderComponent({
scheduleType: "cron",
scheduleData: scheduleData as unknown as ConnectionScheduleData, // for testing purposes
});
expect(queryByText("24")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from "react";
import { FormattedMessage } from "react-intl";

import { ConnectionScheduleData, ConnectionScheduleType } from "core/api/types/AirbyteClient";
import { humanizeCron } from "core/utils/cron";

export interface FormattedScheduleDataMessageProps {
scheduleType?: ConnectionScheduleType;
scheduleData?: ConnectionScheduleData;
}

/**
* Formats schedule data based on the schedule type and schedule data.
* If schedule type is "manual" returns "Manual".
* If schedule type is "basic" returns "Every {units} {timeUnit}".
* If schedule type is "cron" returns humanized cron expression.
* @param scheduleType
* @param scheduleData
*/
export const FormattedScheduleDataMessage: React.FC<FormattedScheduleDataMessageProps> = ({
scheduleType,
scheduleData,
}: {
scheduleType?: ConnectionScheduleType;
scheduleData?: ConnectionScheduleData;
}) => {
if (scheduleType === "manual") {
return <FormattedMessage id="frequency.manual" />;
}

if (scheduleType === "basic" && scheduleData?.basicSchedule) {
return (
<FormattedMessage
id={`form.every.${scheduleData.basicSchedule.timeUnit}`}
values={{ value: scheduleData.basicSchedule.units }}
/>
);
}

if (scheduleType === "cron" && scheduleData?.cron) {
return <>{humanizeCron(scheduleData.cron.cronExpression)}</>;
}

return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ConnectionHeaderControls } from "./ConnectionHeaderControls";
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

.status {
position: relative;
transform: scale(1.2);

.icon {
transform: scale(1.1);
width: 20px;
height: 20px;
display: flex;
Expand Down Expand Up @@ -34,9 +36,3 @@
}
}
}

.spinner {
position: absolute;
top: -1px;
left: -1px;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
@use "scss/colors";
@use "scss/variables";

@keyframes highlight {
0%,
50% {
position: relative;
box-shadow: variables.$box-shadow-highlight colors.$blue-200;
z-index: 1;
}

99% {
z-index: 1;
}

100% {
box-shadow: 0 0 0 0 transparent;
z-index: 0;
}
}

.container {
width: 300px;

&.highlighted {
animation: highlight 2s ease-out;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
import classNames from "classnames";
import { useState } from "react";
import { Location, useLocation, useNavigate } from "react-router-dom";
import { useEffectOnce } from "react-use";

import styles from "./InputContainer.module.scss";

export const InputContainer: React.FC<React.PropsWithChildren> = ({ children }) => {
return <div className={styles.container}>{children}</div>;
export interface LocationWithState extends Location {
state: { action?: "scheduleType" };
}

export const InputContainer: React.FC<React.PropsWithChildren<{ highlightAfterRedirect?: boolean }>> = ({
children,
highlightAfterRedirect,
}) => {
const [highlighted, setHighlighted] = useState(false);
const navigate = useNavigate();
const { state: locationState, pathname } = useLocation() as LocationWithState;

useEffectOnce(() => {
let highlightTimeout: number;

if (highlightAfterRedirect && locationState?.action === "scheduleType") {
setHighlighted(true);
highlightTimeout = window.setTimeout(() => {
setHighlighted(false);
}, 1500);
}
// remove the redirection info from the location state
navigate(pathname, { replace: true });

return () => {
window.clearTimeout(highlightTimeout);
};
});

return <div className={classNames(styles.container, { [styles.highlighted]: highlighted })}>{children}</div>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const SimplifiedScheduleTypeFormControl: React.FC<{ disabled: boolean }> = ({ di
</FlexContainer>
}
/>
<InputContainer>
<InputContainer highlightAfterRedirect>
<ListBox<ConnectionScheduleType>
isDisabled={disabled}
id={controlId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
0%,
50% {
position: relative;
box-shadow: 0 0 47px -5px colors.$blue-200;
box-shadow: variables.$box-shadow-highlight colors.$blue-200;
z-index: 1;
}

Expand Down
2 changes: 1 addition & 1 deletion airbyte-webapp/src/components/ui/Button/Button.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@

&:disabled:not(.isLoading),
&.disabled:not(.isLoading) {
opacity: 0.25;
opacity: 0.5;
}

.buttonIcon {
Expand Down
6 changes: 3 additions & 3 deletions airbyte-webapp/src/components/ui/Heading/Heading.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import classNames from "classnames";
import React from "react";
import React, { HTMLAttributes } from "react";

import styles from "./Heading.module.scss";

type HeadingSize = "sm" | "md" | "lg" | "xl";
type HeadingColor = "darkBlue" | "blue";
type HeadingElementType = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";

interface HeadingProps {
type HeadingProps = HTMLAttributes<HTMLHeadingElement> & {
className?: string;
centered?: boolean;
as: HeadingElementType;
size?: HeadingSize;
color?: HeadingColor;
inverseColor?: boolean;
}
};

const sizes: Record<HeadingSize, string> = {
sm: styles.sm,
Expand Down
Loading

0 comments on commit da36da8

Please sign in to comment.