diff --git a/frontend/src/metabase/entities/tables.js b/frontend/src/metabase/entities/tables.js index fb6589cdaf712718a519a54ae3a04bbec524fd34..dbb472a252f3fa152234a31a1015742d7626d9b0 100644 --- a/frontend/src/metabase/entities/tables.js +++ b/frontend/src/metabase/entities/tables.js @@ -21,7 +21,7 @@ import Metrics from "metabase/entities/metrics"; import Segments from "metabase/entities/segments"; import Questions from "metabase/entities/questions"; -import { GET, PUT } from "metabase/lib/api"; +import { PUT } from "metabase/lib/api"; import { getMetadata, getMetadataUnfiltered, @@ -31,11 +31,9 @@ import { getQuestionVirtualTableId, } from "metabase-lib/metadata/utils/saved-questions"; -const listTables = GET("/api/table"); const listTablesForDatabase = async (...args) => // HACK: no /api/database/:dbId/tables endpoint - (await GET("/api/database/:dbId/metadata")(...args)).tables; -const listTablesForSchema = GET("/api/database/:dbId/schema/:schemaName"); + (await MetabaseApi.db_metadata(...args)).tables; const updateFieldOrder = PUT("/api/table/:id/fields/order"); const updateTables = PUT("/api/table"); @@ -57,11 +55,11 @@ const Tables = createEntity({ api: { list: async (params, ...args) => { if (params.dbId != null && params.schemaName != null) { - return listTablesForSchema(params, ...args); + return MetabaseApi.db_schema_tables(params, ...args); } else if (params.dbId != null) { return listTablesForDatabase(params, ...args); } else { - return listTables(params, ...args); + return MetabaseApi.table_list(params, ...args); } }, }, diff --git a/frontend/src/metabase/query_builder/components/dataref/DatabaseTablesPane.tsx b/frontend/src/metabase/query_builder/components/dataref/DatabaseTablesPane.tsx index 39878d961938eb284c94ecfdfd67353665096294..a6e64d1a2b1578284ff54b4eb600ad3d374bd839 100644 --- a/frontend/src/metabase/query_builder/components/dataref/DatabaseTablesPane.tsx +++ b/frontend/src/metabase/query_builder/components/dataref/DatabaseTablesPane.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { ngettext, msgid } from "ttag"; import _ from "underscore"; +import { SearchResult } from "metabase-types/api"; import Search from "metabase/entities/search"; import SidebarContent from "metabase/query_builder/components/SidebarContent"; import type { State } from "metabase-types/store"; @@ -18,15 +19,15 @@ import { } from "./NodeList.styled"; import { PaneContent } from "./Pane.styled"; -interface DatabaseTablesPaneProps { +export interface DatabaseTablesPaneProps { onBack: () => void; onClose: () => void; onItemClick: (type: string, item: unknown) => void; database: Database; - searchResults: any[]; // TODO: /api/search is yet to be typed + searchResults: SearchResult[]; } -const DatabaseTablesPane = ({ +export const DatabaseTablesPane = ({ database, onItemClick, searchResults, @@ -97,7 +98,10 @@ const DatabaseTablesPane = ({ <ul> {tables.map(table => ( <li key={table.id}> - <NodeListItemLink onClick={() => onItemClick("table", table)}> + <NodeListItemLink + disabled={table.initial_sync_status !== "complete"} + onClick={() => onItemClick("table", table)} + > <NodeListItemIcon name="table" /> <NodeListItemName>{table.table_name}</NodeListItemName> </NodeListItemLink> diff --git a/frontend/src/metabase/query_builder/components/dataref/DatabaseTablesPane.unit.spec.tsx b/frontend/src/metabase/query_builder/components/dataref/DatabaseTablesPane.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d75555b6f65507ef162ad3957e86a5db7e1ac9eb --- /dev/null +++ b/frontend/src/metabase/query_builder/components/dataref/DatabaseTablesPane.unit.spec.tsx @@ -0,0 +1,119 @@ +import userEvent from "@testing-library/user-event"; + +import { createMockMetadata } from "__support__/metadata"; +import { renderWithProviders, screen } from "__support__/ui"; +import { getNextId } from "__support__/utils"; +import { + createMockDatabase, + createMockSearchResult, +} from "metabase-types/api/mocks"; +import { checkNotNull } from "metabase/core/utils/types"; + +import type { DatabaseTablesPaneProps } from "./DatabaseTablesPane"; +import { DatabaseTablesPane } from "./DatabaseTablesPane"; + +const database = createMockDatabase(); + +const metadata = createMockMetadata({ + databases: [database], +}); + +const incompleteTableSearchResult = createMockSearchResult({ + id: getNextId(), + table_name: "Incomplete result", + model: "table", + initial_sync_status: "incomplete", +}); + +const abortedTableSearchResult = createMockSearchResult({ + id: getNextId(), + table_name: "Aborted result", + model: "table", + initial_sync_status: "aborted", +}); + +const completeTableSearchResult = createMockSearchResult({ + id: getNextId(), + table_name: "Complete result", + model: "table", + initial_sync_status: "complete", +}); + +const defaultProps = { + database: checkNotNull(metadata.database(database.id)), + searchResults: [], + onBack: jest.fn(), + onClose: jest.fn(), + onItemClick: jest.fn(), +}; + +const setup = (options?: Partial<DatabaseTablesPaneProps>) => { + return renderWithProviders( + <DatabaseTablesPane {...defaultProps} {...options} />, + ); +}; + +describe("DatabaseTablesPane", () => { + it("should show tables with initial_sync_status='incomplete' as non-interactive (disabled)", () => { + setup({ + searchResults: [incompleteTableSearchResult], + }); + + const textElement = screen.getByText( + checkNotNull(incompleteTableSearchResult.table_name), + ); + + expect(textElement).toBeInTheDocument(); + expectToBeDisabled(textElement); + }); + + it("should show tables with initial_sync_status='aborted' as non-interactive (disabled)", () => { + setup({ + searchResults: [abortedTableSearchResult], + }); + + const textElement = screen.getByText( + checkNotNull(abortedTableSearchResult.table_name), + ); + + expect(textElement).toBeInTheDocument(); + expectToBeDisabled(textElement); + }); + + it("should show tables with initial_sync_status='complete' as interactive (enabled)", () => { + setup({ + searchResults: [completeTableSearchResult], + }); + + const textElement = screen.getByText( + checkNotNull(completeTableSearchResult.table_name), + ); + + expect(textElement).toBeInTheDocument(); + expectToBeEnabled(textElement); + }); +}); + +/** + * We're dealing with <a> here, which are presented as disabled thanks to: + * - not having "href" attribute + * - using "pointer-events: none" + * + * Due to this "expect().toBeDisabled()" and "expect().toBeEnabled()" won't work as expected. + * + * Clicking the element allows us to detect interactiveness (being enabled/disabled) with certainty. + */ +function expectToBeDisabled(element: Element) { + expect(() => { + userEvent.click(element); + }).toThrow(); +} + +/** + * @see expectToBeDisabled + */ +function expectToBeEnabled(element: Element) { + expect(() => { + userEvent.click(element); + }).not.toThrow(); +} diff --git a/frontend/src/metabase/query_builder/components/dataref/NodeList.styled.tsx b/frontend/src/metabase/query_builder/components/dataref/NodeList.styled.tsx index 4ea69e64f3960317fe44820923bac3bd66de0a07..fafe5010cbd7169ddecdd5bc3aadbed0d2b22dac 100644 --- a/frontend/src/metabase/query_builder/components/dataref/NodeList.styled.tsx +++ b/frontend/src/metabase/query_builder/components/dataref/NodeList.styled.tsx @@ -1,3 +1,4 @@ +import { css } from "@emotion/react"; import styled from "@emotion/styled"; import { Icon } from "metabase/core/components/Icon"; @@ -20,7 +21,11 @@ export const NodeListItemIcon = styled(Icon)` width: ${space(2)}; `; -export const NodeListItemLink = styled.a` +interface NodeListItemLinkProps { + disabled?: boolean; +} + +export const NodeListItemLink = styled.a<NodeListItemLinkProps>` border-radius: 8px; display: flex; align-items: center; @@ -35,6 +40,18 @@ export const NodeListItemLink = styled.a` :hover { background-color: ${color("bg-medium")}; } + + ${props => + props.disabled && + css` + pointer-events: none; + opacity: 0.4; + color: inherit; + + ${NodeListItemIcon} { + color: inherit; + } + `}; `; export const NodeListContainer = styled.ul`