Skip to content

Commit 6c6b657

Browse files
feat: schema propagation events in timeline UI (#13892)
Co-authored-by: Chandler Prall <[email protected]>
1 parent 9b701d5 commit 6c6b657

File tree

10 files changed

+246
-11
lines changed

10 files changed

+246
-11
lines changed

airbyte-webapp/src/components/connection/CatalogDiffModal/CatalogDiffModal.tsx

+9-7
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import { getSortedDiff } from "./utils";
1313

1414
interface CatalogDiffModalProps {
1515
catalogDiff: CatalogDiff;
16-
catalog: AirbyteCatalog;
17-
onComplete: () => void;
16+
catalog?: AirbyteCatalog;
17+
onComplete?: () => void;
1818
}
1919

2020
export const CatalogDiffModal: React.FC<CatalogDiffModalProps> = ({ catalogDiff, catalog, onComplete }) => {
@@ -32,11 +32,13 @@ export const CatalogDiffModal: React.FC<CatalogDiffModalProps> = ({ catalogDiff,
3232
{changedItems.length > 0 && <FieldSection streams={changedItems} diffVerb="changed" />}
3333
</div>
3434
</ModalBody>
35-
<ModalFooter>
36-
<Button onClick={onComplete} data-testid="update-schema-confirm-btn">
37-
<FormattedMessage id="connection.updateSchema.confirm" />
38-
</Button>
39-
</ModalFooter>
35+
{onComplete && (
36+
<ModalFooter>
37+
<Button onClick={onComplete} data-testid="update-schema-confirm-btn">
38+
<FormattedMessage id="connection.updateSchema.confirm" />
39+
</Button>
40+
</ModalFooter>
41+
)}
4042
</>
4143
);
4244
};

airbyte-webapp/src/components/ui/Modal/Modal.module.scss

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
max-width: calc(100vw - #{variables.$spacing-lg} * 2);
4343
display: flex;
4444
flex-direction: column;
45+
overflow: hidden;
4546

4647
&.sm {
4748
width: variables.$width-modal-sm;

airbyte-webapp/src/hooks/services/Experiment/experiments.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface Experiments {
1515
"connection.onboarding.sources": string;
1616
"connection.rateLimitedUI": boolean;
1717
"connection.timeline": boolean;
18+
"connection.timeline.schemaUpdates": boolean;
1819
"connector.airbyteCloudIpAddresses": string;
1920
"connector.suggestedSourceConnectors": string;
2021
"connector.suggestedDestinationConnectors": string;

airbyte-webapp/src/locales/en.json

+7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"general.unicodeBullet": "",
33
"general.dash": "-",
44
"general.maskedString": "***********",
5+
"general.airbyte": "Airbyte",
56
"webapp.cannotReachServer": "Cannot reach server. The server may still be starting up.",
67
"notifications.error.health": "Cannot reach server",
78
"notifications.error.somethingWentWrong": "Something went wrong",
@@ -758,6 +759,12 @@
758759
"connection.timeline.connection_settings_update.descriptionWithUserMulti": "{user} changed the {field} from \"{from}\" to \"{to}\" and {otherChanges, plural, one {# other change} other {# other changes}}.",
759760
"connection.timeline.connection_settings_update.descriptionWithoutUser": "<b>{field}</b> changed from \"{from}\" to \"{to}\"",
760761
"connection.timeline.connection_settings_update.View details": "View details",
762+
"connection.timeline.schema_update": "Schema updated",
763+
"connection.timeline.schema_update.description": "{user} applied a change in the schema: {schemaMessage}",
764+
"connection.timeline.schema_update.streamsAdded": "{streams, plural, one {# stream added} other {# streams added}}",
765+
"connection.timeline.schema_update.streamsRemoved": "{streams, plural, one {# stream removed} other {# streams removed}}",
766+
"connection.timeline.schema_update.fieldChanges": "{streams, plural, one {# stream} other {# streams}} changed",
767+
"connection.timeline.schema_update.viewDetails": "View details",
761768

762769
"connectionAutoDisabledReason.ONLY_FAILED_JOBS_RECENTLY": "Airbyte disabled the connection due to consecutive failures.",
763770
"connectionAutoDisabledReason.TOO_MANY_CONSECUTIVE_FAILED_JOBS_IN_A_ROW": "Airbyte disabled the connection due to consecutive failures.",

airbyte-webapp/src/pages/connections/ConnectionTimelinePage/ConnectionTimelineEventIcon.module.scss

+5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
height: 30px;
1111
border-radius: 50%;
1212
background-color: colors.$grey-100;
13+
14+
&--lg {
15+
width: 40px;
16+
height: 40px;
17+
}
1318
}
1419

1520
.connectionTimelineEventIcon__icon {

airbyte-webapp/src/pages/connections/ConnectionTimelinePage/ConnectionTimelineEventIcon.tsx

+8-3
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@ export const ConnectionTimelineEventIcon: React.FC<{
88
icon: IconProps["type"];
99
statusIcon?: IconProps["type"];
1010
running?: boolean;
11-
}> = ({ icon, statusIcon, running }) => {
11+
size?: "lg" | "sm";
12+
}> = ({ icon, statusIcon, running, size = "sm" }) => {
1213
return (
13-
<div className={classNames(styles.connectionTimelineEventIcon)}>
14+
<div
15+
className={classNames(styles.connectionTimelineEventIcon, {
16+
[styles["connectionTimelineEventIcon--lg"]]: size === "lg",
17+
})}
18+
>
1419
{statusIcon && (
1520
<div className={styles.connectionTimelineEventIcon__statusIndicator}>
1621
<Icon
@@ -34,7 +39,7 @@ export const ConnectionTimelineEventIcon: React.FC<{
3439
{running && !statusIcon && (
3540
<CircleLoader title="syncing" className={styles.connectionTimelineEventIcon__running} />
3641
)}
37-
<Icon type={icon} size="sm" color="disabled" className={styles.connectionTimelineEventIcon__icon} />
42+
<Icon type={icon} size={size} color="disabled" className={styles.connectionTimelineEventIcon__icon} />
3843
</div>
3944
);
4045
};

airbyte-webapp/src/pages/connections/ConnectionTimelinePage/components/EventLineItem.tsx

+12
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { InferType } from "yup";
33
import { Box } from "components/ui/Box";
44

55
import { ConnectionEvent } from "core/api/types/AirbyteClient";
6+
import { useExperiment } from "hooks/services/Experiment";
67

78
import { ClearEventItem } from "./ClearEventItem";
89
import { ConnectionDisabledEventItem } from "./ConnectionDisabledEventItem";
@@ -11,6 +12,7 @@ import { ConnectionSettingsUpdateEventItem } from "./ConnectionSettingsUpdateEve
1112
import { JobStartEventItem } from "./JobStartEventItem";
1213
import { RefreshEventItem } from "./RefreshEventItem";
1314
import { RunningJobItem } from "./RunningJobItem";
15+
import { SchemaUpdateEventItem } from "./SchemaUpdateEventItem";
1416
import { SyncEventItem } from "./SyncEventItem";
1517
import { SyncFailEventItem } from "./SyncFailEventItem";
1618
import {
@@ -23,9 +25,12 @@ import {
2325
connectionEnabledEventSchema,
2426
connectionDisabledEventSchema,
2527
connectionSettingsUpdateEventSchema,
28+
schemaUpdateEventSchema,
2629
} from "../types";
2730

2831
export const EventLineItem: React.FC<{ event: ConnectionEvent | InferType<typeof jobRunningSchema> }> = ({ event }) => {
32+
const showSchemaUpdates = useExperiment("connection.timeline.schemaUpdates", false);
33+
2934
if (jobRunningSchema.isValidSync(event, { recursive: true, stripUnknown: true })) {
3035
return (
3136
<Box py="lg" key={event.id}>
@@ -80,6 +85,13 @@ export const EventLineItem: React.FC<{ event: ConnectionEvent | InferType<typeof
8085
<ConnectionSettingsUpdateEventItem event={event} />
8186
</Box>
8287
);
88+
} else if (showSchemaUpdates && schemaUpdateEventSchema.isValidSync(event, { recursive: true, stripUnknown: true })) {
89+
return (
90+
<Box py="lg" key={event.id}>
91+
<SchemaUpdateEventItem event={event} />
92+
</Box>
93+
);
8394
}
95+
8496
return null;
8597
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { useMemo } from "react";
2+
import { FormattedDate, FormattedList, FormattedMessage, useIntl } from "react-intl";
3+
import { InferType } from "yup";
4+
5+
import { CatalogDiffModal } from "components/connection/CatalogDiffModal";
6+
import { getSortedDiff } from "components/connection/CatalogDiffModal/utils";
7+
import { Button } from "components/ui/Button";
8+
import { FlexContainer } from "components/ui/Flex";
9+
import { Text } from "components/ui/Text";
10+
11+
import { useModalService } from "hooks/services/Modal";
12+
13+
import { TimelineEventUser } from "./TimelineEventUser";
14+
import { ConnectionTimelineEventActions } from "../ConnectionTimelineEventActions";
15+
import { ConnectionTimelineEventIcon } from "../ConnectionTimelineEventIcon";
16+
import { ConnectionTimelineEventItem } from "../ConnectionTimelineEventItem";
17+
import { ConnectionTimelineEventSummary } from "../ConnectionTimelineEventSummary";
18+
import { schemaUpdateEventSchema } from "../types";
19+
import { titleIdMap } from "../utils";
20+
21+
export const SchemaUpdateEventItem: React.FC<{ event: InferType<typeof schemaUpdateEventSchema> }> = ({ event }) => {
22+
const titleId = titleIdMap[event.eventType];
23+
const { formatMessage } = useIntl();
24+
const { openModal } = useModalService();
25+
26+
const { newItems, removedItems, changedItems } = useMemo(
27+
() => getSortedDiff(event.summary.catalogDiff.transforms),
28+
[event.summary.catalogDiff.transforms]
29+
);
30+
31+
const schemaMessage = useMemo(() => {
32+
const parts = [];
33+
34+
if (newItems.length > 0) {
35+
parts.push(formatMessage({ id: "connection.timeline.schema_update.streamsAdded" }, { streams: newItems.length }));
36+
}
37+
38+
if (removedItems.length > 0) {
39+
parts.push(
40+
formatMessage({ id: "connection.timeline.schema_update.streamsRemoved" }, { streams: removedItems.length })
41+
);
42+
}
43+
if (changedItems.length > 0) {
44+
parts.push(
45+
formatMessage({ id: "connection.timeline.schema_update.fieldChanges" }, { streams: changedItems.length })
46+
);
47+
}
48+
49+
return (
50+
<>
51+
<FormattedList value={parts} />.
52+
</>
53+
);
54+
}, [changedItems.length, formatMessage, newItems.length, removedItems.length]);
55+
56+
const triggerSchemaChangesModal = () =>
57+
openModal<void>({
58+
title: (
59+
<FlexContainer alignItems="center">
60+
<ConnectionTimelineEventIcon icon="schema" size="lg" />
61+
<FlexContainer gap="xs" direction="column">
62+
<Text size="lg">
63+
<FormattedMessage id="connection.timeline.schema_update" />
64+
</Text>
65+
<Text size="lg" color="grey400">
66+
<FormattedDate value={event.createdAt * 1000} timeStyle="short" dateStyle="medium" />
67+
</Text>
68+
</FlexContainer>
69+
</FlexContainer>
70+
),
71+
size: "md",
72+
testId: "catalog-diff-modal",
73+
content: () => <CatalogDiffModal catalogDiff={event.summary.catalogDiff} />,
74+
});
75+
76+
return (
77+
<ConnectionTimelineEventItem>
78+
<ConnectionTimelineEventIcon icon="schema" />
79+
<ConnectionTimelineEventSummary>
80+
<FlexContainer gap="xs" direction="column">
81+
<Text bold>
82+
<FormattedMessage id={titleId} />
83+
</Text>
84+
85+
<Text as="span" size="sm" color="grey400">
86+
<FormattedMessage
87+
id="connection.timeline.schema_update.description"
88+
values={{
89+
user: !!event.user ? (
90+
<TimelineEventUser user={event.user} />
91+
) : (
92+
<FormattedMessage id="general.airbyte" />
93+
),
94+
schemaMessage,
95+
}}
96+
/>{" "}
97+
<Button variant="link" onClick={triggerSchemaChangesModal}>
98+
<FormattedMessage id="connection.timeline.schema_update.viewDetails" />
99+
</Button>
100+
</Text>
101+
</FlexContainer>
102+
</ConnectionTimelineEventSummary>
103+
<ConnectionTimelineEventActions createdAt={event.createdAt} />
104+
</ConnectionTimelineEventItem>
105+
);
106+
};

airbyte-webapp/src/pages/connections/ConnectionTimelinePage/types.ts

+96
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import {
66
FailureOrigin,
77
FailureReason,
88
FailureType,
9+
FieldTransformTransformType,
910
Geography,
1011
JobConfigType,
1112
NamespaceDefinitionType,
1213
NonBreakingChangesPreference,
14+
StreamAttributeTransformTransformType,
15+
StreamTransformTransformType,
1316
} from "core/api/types/AirbyteClient";
1417

1518
/**
@@ -38,6 +41,9 @@ const connectionAutoDisabledReasons = [
3841
];
3942

4043
// property-specific schemas
44+
/**
45+
* @typedef {import("core/api/types/AirbyteClient").StreamDescriptor}
46+
*/
4147
const streamDescriptorSchema = yup.object({
4248
name: yup.string().required(),
4349
namespace: yup.string().optional(),
@@ -49,6 +55,80 @@ const jobRunningStreamSchema = yup.object({
4955
configType: yup.mixed<JobConfigType>().oneOf(["sync", "refresh", "clear", "reset_connection"]).required(),
5056
});
5157

58+
/**
59+
* @typedef {import("core/api/types/AirbyteClient").FieldSchema}
60+
*/
61+
const fieldSchema = yup.object({
62+
schema: yup.object().optional(),
63+
});
64+
65+
/**
66+
* @typedef {import("core/api/types/AirbyteClient").FieldSchemaUpdate}
67+
*/
68+
const fieldSchemaUpdateSchema = yup.object({
69+
newSchema: fieldSchema.required(),
70+
oldSchema: fieldSchema.required(),
71+
});
72+
73+
/**
74+
* @typedef {import("core/api/types/AirbyteClient").FieldTransform}
75+
*/
76+
const fieldTransformSchema = yup.object({
77+
addField: fieldSchema.optional(),
78+
breaking: yup.boolean().required(),
79+
fieldName: yup.array().of(yup.string()).required(),
80+
removeField: fieldSchema.optional(),
81+
transformType: yup
82+
.mixed<FieldTransformTransformType>()
83+
.oneOf(["add_field", "remove_field", "update_field_schema"])
84+
.required(),
85+
updateFieldSchema: fieldSchemaUpdateSchema.optional(),
86+
});
87+
88+
/**
89+
* @typedef {import("core/api/types/AirbyteClient").StreamAttributePrimaryKeyUpdate}
90+
*/
91+
const streamAttributePrimaryKeyUpdateSchema = yup.object({
92+
newPrimaryKey: yup.array().of(yup.array().of(yup.string())).optional(),
93+
oldPrimaryKey: yup.array().of(yup.array().of(yup.string())).optional(),
94+
});
95+
96+
/**
97+
* @typedef {import("core/api/types/AirbyteClient").StreamAttributeTransform}
98+
*/
99+
const streamAttributeTransformSchema = yup.object({
100+
breaking: yup.boolean().required(),
101+
transformType: yup.mixed<StreamAttributeTransformTransformType>().oneOf(["update_primary_key"]).required(),
102+
updatePrimaryKey: streamAttributePrimaryKeyUpdateSchema.optional(),
103+
});
104+
105+
/**
106+
* @typedef {import("core/api/types/AirbyteClient").StreamTransformUpdateStream}
107+
*/
108+
const streamTransformUpdateStreamSchema = yup.object({
109+
fieldTransforms: yup.array().of(fieldTransformSchema).optional(),
110+
streamAttributeTransforms: yup.array().of(streamAttributeTransformSchema).optional(),
111+
});
112+
113+
/**
114+
* @typedef {import("core/api/types/AirbyteClient").StreamTransform}
115+
*/
116+
const streamTransformsSchema = yup.object({
117+
streamDescriptor: streamDescriptorSchema.required(),
118+
transformType: yup
119+
.mixed<StreamTransformTransformType>()
120+
.oneOf(["add_stream", "remove_stream", "update_stream"])
121+
.required(),
122+
updateStream: streamTransformUpdateStreamSchema.optional(),
123+
});
124+
125+
/**
126+
* @typedef {import("core/api/types/AirbyteClient").CatalogDiff}
127+
*/
128+
const catalogDiffSchema = yup.object({
129+
transforms: yup.array().of(streamTransformsSchema).required(),
130+
});
131+
52132
export type TimelineFailureReason = Omit<FailureReason, "timestamp">;
53133

54134
export const jobFailureReasonSchema = yup.object({
@@ -61,6 +141,9 @@ export const jobFailureReasonSchema = yup.object({
61141
stacktrace: yup.string().optional(),
62142
});
63143

144+
/**
145+
* @typedef {import("core/api/types/AirbyteClient").UserReadInConnectionEvent}
146+
*/
64147
export const userInEventSchema = yup.object({
65148
email: yup.string().optional(),
66149
id: yup.string().optional(),
@@ -161,6 +244,14 @@ export const connectionSettingsUpdateEventSummarySchema = yup.object({
161244
.required(),
162245
});
163246

247+
export const schemaUpdateSummarySchema = yup.object({
248+
catalogDiff: catalogDiffSchema.required(),
249+
updateReason: yup.mixed().oneOf(["SCHEMA_CHANGE_AUTO_PROPAGATE"]).optional(),
250+
});
251+
252+
/**
253+
* @typedef {import("core/api/types/AirbyteClient").ConnectionEvent}
254+
*/
164255
export const generalEventSchema = yup.object({
165256
id: yup.string().required(),
166257
connectionId: yup.string().required(),
@@ -241,3 +332,8 @@ export const connectionSettingsUpdateEventSchema = generalEventSchema.shape({
241332
eventType: yup.mixed<ConnectionEventType>().oneOf([ConnectionEventType.CONNECTION_SETTINGS_UPDATE]).required(),
242333
summary: connectionSettingsUpdateEventSummarySchema.required(),
243334
});
335+
336+
export const schemaUpdateEventSchema = generalEventSchema.shape({
337+
eventType: yup.mixed<ConnectionEventType>().oneOf([ConnectionEventType.SCHEMA_UPDATE]).required(),
338+
summary: schemaUpdateSummarySchema.required(),
339+
});

0 commit comments

Comments
 (0)