Skip to content

Commit 074558c

Browse files
Joey Marshment-Howelldizel852
Joey Marshment-Howell
andcommitted
feat: custom sync catalog search and expansion (#13924)
Co-authored-by: Vladimir <[email protected]>
1 parent 59e9bdd commit 074558c

File tree

7 files changed

+452
-19
lines changed

7 files changed

+452
-19
lines changed

airbyte-webapp/cypress/commands/api/payloads.ts

+7
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ export const getPostgresCreateDestinationBody = (name: string): DestinationCreat
6767
},
6868
});
6969

70+
export const getFakerCreateSourceBody = (sourceName: string): SourceCreate => ({
71+
name: sourceName,
72+
workspaceId: getWorkspaceId(),
73+
sourceDefinitionId: ConnectorIds.Sources.Faker,
74+
connectionConfiguration: {},
75+
});
76+
7077
export const getPokeApiCreateSourceBody = (sourceName: string, pokeName: string): SourceCreate => ({
7178
name: sourceName,
7279
workspaceId: getWorkspaceId(),

airbyte-webapp/cypress/commands/connection.ts

+12
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { openCreateConnection } from "pages/destinationPage";
1717

1818
import {
1919
getConnectionCreateRequest,
20+
getFakerCreateSourceBody,
2021
getLocalJSONCreateDestinationBody,
2122
getPokeApiCreateSourceBody,
2223
getPostgresCreateDestinationBody,
@@ -90,6 +91,17 @@ export const startManualReset = () => {
9091
cy.get("[data-id='clear-data']").click();
9192
};
9293

94+
export const createFakerSourceViaApi = () => {
95+
let source: SourceRead;
96+
return requestWorkspaceId().then(() => {
97+
const sourceRequestBody = getFakerCreateSourceBody(appendRandomString("Faker Source"));
98+
requestCreateSource(sourceRequestBody).then((sourceResponse) => {
99+
source = sourceResponse;
100+
});
101+
return source;
102+
});
103+
};
104+
93105
export const createPokeApiSourceViaApi = () => {
94106
let source: SourceRead;
95107
return requestWorkspaceId().then(() => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {
2+
createJsonDestinationViaApi,
3+
createNewConnectionViaApi,
4+
createFakerSourceViaApi,
5+
} from "@cy/commands/connection";
6+
import { visit } from "@cy/pages/connection/connectionPageObject";
7+
import { setFeatureFlags } from "@cy/support/e2e";
8+
import { DestinationRead, SourceRead, WebBackendConnectionRead } from "@src/core/api/types/AirbyteClient";
9+
10+
const CATALOG_SEARCH_INPUT = '[data-testid="sync-catalog-search"]';
11+
12+
describe("Sync catalog", () => {
13+
let fakerSource: SourceRead;
14+
let jsonDestination: DestinationRead;
15+
let connection: WebBackendConnectionRead;
16+
17+
before(() => {
18+
setFeatureFlags({ "connection.syncCatalogV2": true });
19+
20+
createFakerSourceViaApi()
21+
.then((source) => {
22+
fakerSource = source;
23+
})
24+
.then(() => createJsonDestinationViaApi())
25+
.then((destination) => {
26+
jsonDestination = destination;
27+
})
28+
.then(() => createNewConnectionViaApi(fakerSource, jsonDestination))
29+
.then((connectionResponse) => {
30+
connection = connectionResponse;
31+
});
32+
});
33+
34+
after(() => {
35+
setFeatureFlags({});
36+
});
37+
38+
describe("catalog search functionality", () => {
39+
before(() => {
40+
visit(connection, "replication");
41+
});
42+
43+
it("Should find a nested field in the sync catalog", () => {
44+
// Intentionally search for a partial match of a nested field (address.city)
45+
cy.get(CATALOG_SEARCH_INPUT).type("address.cit");
46+
// Expect the parent field to exist
47+
cy.contains(/^address$/).should("exist");
48+
// Exppect the nested field to exist
49+
cy.contains(/^address\.city$/).should("exist");
50+
51+
// Search for a stream
52+
cy.get(CATALOG_SEARCH_INPUT).clear();
53+
cy.get(CATALOG_SEARCH_INPUT).type("products");
54+
cy.contains(/^products$/).should("exist");
55+
cy.contains(/^users$/).should("not.exist");
56+
});
57+
});
58+
});

airbyte-webapp/src/components/connection/ConnectionForm/SyncCatalogTable/SyncCatalogTable.tsx

+5-19
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,13 @@ import {
44
ExpandedState,
55
flexRender,
66
getCoreRowModel,
7-
getExpandedRowModel,
8-
getFilteredRowModel,
97
getGroupedRowModel,
108
getSortedRowModel,
119
useReactTable,
1210
} from "@tanstack/react-table";
1311
import classnames from "classnames";
1412
import set from "lodash/set";
15-
import React, { FC, useCallback, useEffect, useMemo, useState, useDeferredValue } from "react";
13+
import React, { FC, useCallback, useMemo, useState, useDeferredValue } from "react";
1614
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
1715
import { FormattedMessage, useIntl } from "react-intl";
1816
import { ItemProps, TableComponents, TableVirtuoso } from "react-virtuoso";
@@ -46,6 +44,8 @@ import { StreamFieldNameCell } from "./components/StreamFieldCell";
4644
import { StreamNameCell } from "./components/StreamNameCell";
4745
import { FilterTabId, StreamsFilterTabs } from "./components/StreamsFilterTabs";
4846
import { SyncModeCell } from "./components/SyncModeCell";
47+
import { getExpandedRowModel } from "./getExpandedRowModel";
48+
import { getFilteredRowModel } from "./getFilteredRowModel";
4949
import { useInitialRowIndex } from "./hooks/useInitialRowIndex";
5050
import styles from "./SyncCatalogTable.module.scss";
5151
import { getRowChangeStatus, getSyncCatalogRows, isNamespaceRow, isStreamRow } from "./utils";
@@ -330,6 +330,7 @@ export const SyncCatalogTable: FC<SyncCatalogTableProps> = ({ scrollParentContai
330330
});
331331

332332
const rows = getRowModel().rows;
333+
333334
const initialTopMostItemIndex = useInitialRowIndex(rows);
334335

335336
const [isAllStreamRowsExpanded, setIsAllStreamRowsExpanded] = useState(false);
@@ -347,22 +348,6 @@ export const SyncCatalogTable: FC<SyncCatalogTableProps> = ({ scrollParentContai
347348
[initialExpandedState, toggleAllRowsExpanded]
348349
);
349350

350-
useEffect(() => {
351-
// collapse all rows if global filter is empty and all rows are expanded
352-
if (!filtering && isAllStreamRowsExpanded) {
353-
toggleAllStreamRowsExpanded(false);
354-
return;
355-
}
356-
357-
// if global filter is empty or all rows already expanded then return
358-
if (!filtering || (filtering && isAllStreamRowsExpanded)) {
359-
return;
360-
}
361-
362-
toggleAllStreamRowsExpanded(true);
363-
// eslint-disable-next-line react-hooks/exhaustive-deps
364-
}, [filtering]);
365-
366351
const Table: TableComponents["Table"] = ({ style, ...props }) => (
367352
<table className={classnames(styles.table)} {...props} style={style} />
368353
);
@@ -480,6 +465,7 @@ export const SyncCatalogTable: FC<SyncCatalogTableProps> = ({ scrollParentContai
480465
// We do not want to submit the connection form when pressing Enter in the search field
481466
e.key === "Enter" && e.preventDefault();
482467
}}
468+
data-testid="sync-catalog-search"
483469
/>
484470
<FlexContainer>
485471
<FlexContainer justifyContent="flex-end" alignItems="center" direction="row" gap="lg">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { RowModel, Table, createRow, Row, RowData } from "@tanstack/react-table";
3+
4+
import { SyncCatalogUIModel } from "./SyncCatalogTable";
5+
6+
/**
7+
* This method is unchanged from the original implementation, except for removing the generic typings:
8+
* https://github.com/TanStack/table/blob/v8.7.0/packages/table-core/src/utils/filterRowsUtils.ts#L4C1-L14C2
9+
*/
10+
export function filterRows(
11+
rows: Array<Row<SyncCatalogUIModel>>,
12+
filterRowImpl: (row: Row<SyncCatalogUIModel>) => any,
13+
table: Table<SyncCatalogUIModel>
14+
) {
15+
if (table.options.filterFromLeafRows) {
16+
return filterRowModelFromLeafs(rows, filterRowImpl, table);
17+
}
18+
19+
return filterRowModelFromRoot(rows, filterRowImpl, table);
20+
}
21+
22+
/**
23+
* This method has been customized to always include sibling fields of a matching row. Original implementation:
24+
* https://github.com/TanStack/table/blob/v8.7.0/packages/table-core/src/utils/filterRowsUtils.ts#L16-L76
25+
*/
26+
function filterRowModelFromLeafs(
27+
rowsToFilter: Array<Row<SyncCatalogUIModel>>,
28+
filterRow: (row: Row<SyncCatalogUIModel>) => Array<Row<SyncCatalogUIModel>>,
29+
table: Table<SyncCatalogUIModel>
30+
): RowModel<SyncCatalogUIModel> {
31+
const maxDepth = table.options.maxLeafRowFilterDepth ?? 100;
32+
33+
const recurseFilterRows = (rowsToFilter: Array<Row<SyncCatalogUIModel>>, depth = 0) => {
34+
const rows: Array<Row<SyncCatalogUIModel>> = [];
35+
36+
for (let i = 0; i < rowsToFilter.length; i++) {
37+
const row = rowsToFilter[i];
38+
39+
const newRow = createRow(table, row.id, row.original, row.index, row.depth);
40+
newRow.columnFilters = row.columnFilters;
41+
42+
if (row.subRows?.length && depth < maxDepth) {
43+
// Recursing again here to do a depth first search (deep leaf nodes first)
44+
newRow.subRows = recurseFilterRows(row.subRows, depth + 1);
45+
46+
// If a stream is a match, include all its subrows
47+
if (newRow.original.rowType === "stream" && filterRow(newRow)) {
48+
newRow.subRows = rowsToFilter[i].subRows;
49+
rows.push(newRow);
50+
continue;
51+
}
52+
53+
// If a stream contains at least one match, include all its subrows
54+
if (newRow.original.rowType === "stream" && newRow.subRows.length) {
55+
newRow.subRows = rowsToFilter[i].subRows;
56+
rows.push(newRow);
57+
continue;
58+
}
59+
60+
if (filterRow(newRow) && !newRow.subRows.length) {
61+
rows.push(newRow);
62+
continue;
63+
}
64+
65+
if (filterRow(newRow) || newRow.subRows.length) {
66+
rows.push(newRow);
67+
continue;
68+
}
69+
} else if (filterRow(newRow)) {
70+
rows.push(newRow);
71+
}
72+
}
73+
74+
return rows;
75+
};
76+
77+
const filteredRows: Array<Row<SyncCatalogUIModel>> = recurseFilterRows(rowsToFilter);
78+
const filteredRowsById: Record<string, Row<SyncCatalogUIModel>> = {};
79+
80+
function flattenRows(rows: Array<Row<SyncCatalogUIModel>>) {
81+
const flattenedRows: Array<Row<SyncCatalogUIModel>> = [];
82+
for (let i = 0; i < rows.length; i++) {
83+
const row = rows[i];
84+
flattenedRows.push(row);
85+
filteredRowsById[row.id] = row;
86+
if (row.subRows) {
87+
flattenedRows.push(...flattenRows(row.subRows));
88+
}
89+
}
90+
return flattenedRows;
91+
}
92+
93+
return {
94+
rows: filteredRows,
95+
rowsById: filteredRowsById,
96+
flatRows: flattenRows(filteredRows),
97+
};
98+
}
99+
100+
/**
101+
* This method is unchanged from the original implementation:
102+
* https://github.com/TanStack/table/blob/v8.7.0/packages/table-core/src/utils/filterRowsUtils.ts#L78-L126
103+
*/
104+
export function filterRowModelFromRoot<TData extends RowData>(
105+
rowsToFilter: Array<Row<TData>>,
106+
filterRow: (row: Row<TData>) => any,
107+
table: Table<TData>
108+
): RowModel<TData> {
109+
const newFilteredFlatRows: Array<Row<TData>> = [];
110+
const newFilteredRowsById: Record<string, Row<TData>> = {};
111+
const maxDepth = table.options.maxLeafRowFilterDepth ?? 100;
112+
113+
// Filters top level and nested rows
114+
const recurseFilterRows = (rowsToFilter: Array<Row<TData>>, depth = 0) => {
115+
// Filter from parents downward first
116+
117+
const rows = [];
118+
119+
// Apply the filter to any subRows
120+
for (let i = 0; i < rowsToFilter.length; i++) {
121+
let row = rowsToFilter[i];
122+
123+
const pass = filterRow(row);
124+
125+
if (pass) {
126+
if (row.subRows?.length && depth < maxDepth) {
127+
const newRow = createRow(table, row.id, row.original, row.index, row.depth);
128+
newRow.subRows = recurseFilterRows(row.subRows, depth + 1);
129+
row = newRow;
130+
}
131+
132+
rows.push(row);
133+
newFilteredFlatRows.push(row);
134+
newFilteredRowsById[row.id] = row;
135+
}
136+
}
137+
138+
return rows;
139+
};
140+
141+
return {
142+
rows: recurseFilterRows(rowsToFilter),
143+
flatRows: newFilteredFlatRows,
144+
rowsById: newFilteredRowsById,
145+
};
146+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { Table, Row, RowModel, TableOptionsResolved, memo } from "@tanstack/react-table";
3+
4+
import { SyncCatalogUIModel } from "./SyncCatalogTable";
5+
6+
export function getMemoOptions(
7+
tableOptions: Partial<TableOptionsResolved<any>>,
8+
debugLevel: "debugAll" | "debugTable" | "debugColumns" | "debugRows" | "debugHeaders",
9+
key: string,
10+
onChange?: (result: any) => void
11+
) {
12+
return {
13+
debug: () => tableOptions?.debugAll ?? tableOptions[debugLevel],
14+
key: process.env.NODE_ENV === "development" && key,
15+
onChange,
16+
};
17+
}
18+
19+
/**
20+
* A custom implementation of getExpandedRowModel() from tanstack-table.
21+
* Compare with original implementation at: https://github.com/TanStack/table/blob/7fe650d666cfc3807b8408a1205bc1686a479cdb/packages/table-core/src/utils/getExpandedRowModel.ts
22+
*/
23+
export function getExpandedRowModel(): (table: Table<SyncCatalogUIModel>) => () => RowModel<SyncCatalogUIModel> {
24+
return (table) =>
25+
memo(
26+
() => [table.getState().expanded, table.getPreExpandedRowModel(), table.options.paginateExpandedRows],
27+
(expanded, rowModel) => {
28+
if (!rowModel.rows.length || (expanded !== true && !Object.keys(expanded ?? {}).length)) {
29+
return rowModel;
30+
}
31+
32+
return expandRows(rowModel, table.getState().globalFilter);
33+
},
34+
getMemoOptions(table.options, "debugTable", "getExpandedRowModel")
35+
);
36+
}
37+
38+
export function expandRows(rowModel: RowModel<SyncCatalogUIModel>, globalFilter: string) {
39+
const expandedRows: Array<Row<SyncCatalogUIModel>> = [];
40+
41+
const recurseMatchingSubRows = (subRows: Array<Row<SyncCatalogUIModel>>): Array<Row<SyncCatalogUIModel>> => {
42+
const matchingRows: Array<Row<SyncCatalogUIModel>> = [];
43+
44+
subRows.forEach((row) => {
45+
if (rowHasSearchTerm(row, globalFilter)) {
46+
matchingRows.push(row);
47+
}
48+
if (row.subRows?.length) {
49+
const matchingSubrows = recurseMatchingSubRows(row.subRows);
50+
// If any subrows match, include the parent row and the matching subrows
51+
if (matchingSubrows.length) {
52+
matchingRows.push(row);
53+
matchingRows.push(...matchingSubrows);
54+
}
55+
}
56+
});
57+
58+
return matchingRows;
59+
};
60+
61+
const handleRow = (row: Row<SyncCatalogUIModel>) => {
62+
expandedRows.push(row);
63+
64+
if (row.subRows?.length && row.getIsExpanded()) {
65+
row.subRows.forEach(handleRow);
66+
// Even if the parent is not expanded, we need to iterate through the subRows to look for rows that match the search
67+
} else if (row.subRows?.length && globalFilter !== "" && !row.getIsExpanded()) {
68+
expandedRows.push(...recurseMatchingSubRows(row.subRows));
69+
}
70+
};
71+
72+
rowModel.rows.forEach(handleRow);
73+
74+
return {
75+
rows: expandedRows,
76+
flatRows: rowModel.flatRows,
77+
rowsById: rowModel.rowsById,
78+
};
79+
}
80+
81+
function rowHasSearchTerm(row: Row<SyncCatalogUIModel>, globalFilter: string) {
82+
return row.original.name.toLocaleLowerCase().includes(globalFilter.toLocaleLowerCase());
83+
}

0 commit comments

Comments
 (0)