From a0d9e1383d84362b81fbd9c8df63115970de70dc Mon Sep 17 00:00:00 2001 From: Anton Kulyk <kuliks.anton@gmail.com> Date: Tue, 13 Dec 2022 10:15:47 +0000 Subject: [PATCH] Cherry pick new data picker component to master (#27174) * Add basic raw data picker for app scaffolding (#25997) * Show data app page ID params in editing mode (#25943) * Data apps layout updates (#25930) * play with grid sizes to make apps feel appier [ci skip] * move app nav to bar inside app * only hide other header buttons outside of edit mode * tweak overflow issue * fix header width on data apps * add control to see app contents * set data apps layout constants contextually [ci skip] * remove hardcoded value [ci skip] * extract conextual header styles [ci skip] * set max-width on paramaters for data apps [ci skip] * move data apps nav deeper, only show if not editing [ci skip] * add spacing to contents trigger, rm old code [ci skip] * rm table thing for now [ci skip] * Fixes for data app layout updates (#25962) * Reorder import * Fix type * Fix missing translation, move out URL * Remove not used import * Rework `getSelectedItems` utility for data apps * Fix selected data app page highlighting * Don't reload app navbar on page change * Change nav item links look * Remove `DataAppPageSidebarLink` * Turn nav into HTML list * Use styled components * Fix edit button covered by visualizations * Fix opening homepage * Remove redundant prop Co-authored-by: Anton Kulyk <kuliks.anton@gmail.com> * First take at parameterized data app page titles (#25938) * Bring `DataAppContext` back * Pass `onFocus` and `onBlur` to `EditableText` * Export `EditableTextProps` * Add basic `DataAppPageTitle` component * Use `DataAppPageTitle` * Fix component name * Add primitive autocompletion to page title input * Add `title_template` to `DataAppNavItem` type * Tweak value management in `DataAppPageTitle` * Handle `null` values in titles more gracefully * Don't show suggestions without detail cards * Don't add whitespace when pasting a token * Don't update app's collection all the time * Add data app related selectors to dashboard selectors * Add redux state to keep title template changes * Update page title template on page save * Wire up `DataAppPageTitle` with dashboard code * Fix unit tests * Reset state on exiting editing mode * Add back button to template data picker * Add `PanePicker` UI component * Add basic `RawDataPanePicker` component * displays databases, schemas, tables * allows picking a database and a schema (no tables yet) * Allow picking tables Supports single-selection and multi-selection modes * Auto-select single database schema * Use `.ts` instead of `.tsx` for hook file * Style `PanePicker` * Add `onTablesChange` callback prop to data picker * Use new data picker in new app modal * Use new data picker in add data modal * Remove `DataAppDataPicker` Co-authored-by: Kyle Doherty <5248953+kdoh@users.noreply.github.com> * Make `DataPicker` controlled (#26018) * Add data type picker to scaffolding data picker (#26019) * Move `useSelectedTables` one level up * Extract `DataPickerSelectedItem` type * Add `type` to `DataPickerValue` type * Handle `type` in `useDataPickerValue` hook * Add data type picker step * Add back button to raw data picker * Add `CardPicker` * Tweak spacing * Move `VirtualTable` to shared picker types * Temporary disable saved questions data type * Simplify schema loading for models and questions * Remove redundant `useMemo` * Rename `RawDataPanePicker` to `RawDataPicker` (#26020) * Add safety checks to scaffolding data picker (#26023) * Pass available data types as a prop to the picker * Use nested queries settings for data types list * Offer models only if instance has at least one * Check data access * Break `DataPicker` into container and view * Make `DataPicker` cover the whole container * Add items filters API to data app scaffolding data picker (#26024) * Uncomment saved questions data type * Add data picker filters API types * Apply data type filters * Auto pick a data type when only one is available * Export data picker types * Add `DataAppScaffoldingDataPicker` component * Use `DataAppScaffoldingDataPicker` * Improve collection selection for scaffolding data picker (#26033) * Add collection ID to data picker value type * Handle collection ID in `onChange` * Handle collection ID in `CardPicker` * Automatically open root collection * Add basic search to scaffolding data picker (#26039) * Add data picker context * Add global search to data picker * Add basic search UI to new app modal * Don't use database ID in search * Scope search depending on selected data type * Expose more data picker types * Style search input * Keep filtered out data types in mind for search * Move `MIN_SEARCH_LENGTH` to constants file * Fix can't select nested collection in card picker (#26122) * Fix conflicts Co-authored-by: Kyle Doherty <5248953+kdoh@users.noreply.github.com> --- .../metadata/utils/saved-questions.js | 4 + .../SelectList/BaseSelectListItem.jsx | 2 +- .../src/metabase/components/tree/Tree.tsx | 1 + .../components/tree/TreeNodeList.styled.tsx | 3 + .../metabase/components/tree/TreeNodeList.tsx | 17 +- .../CardPicker/CardPicker.styled.tsx | 8 + .../CardPicker/CardPickerContainer.tsx | 161 ++++++++++++++++ .../DataPicker/CardPicker/CardPickerView.tsx | 128 +++++++++++++ .../containers/DataPicker/CardPicker/index.ts | 1 + .../containers/DataPicker/CardPicker/utils.ts | 78 ++++++++ .../DataPicker/DataPickerContainer.tsx | 149 +++++++++++++++ .../DataPickerContext/DataPickerContext.ts | 20 ++ .../DataPickerContextProvider.tsx | 28 +++ .../DataPicker/DataPickerContext/index.ts | 2 + .../DataPicker/DataPickerView.styled.tsx | 13 ++ .../containers/DataPicker/DataPickerView.tsx | 89 +++++++++ .../DataPicker/DataSearch/DataSearch.tsx | 120 ++++++++++++ .../containers/DataPicker/DataSearch/index.ts | 1 + .../DataTypePicker/DataTypePicker.styled.tsx | 47 +++++ .../DataTypePicker/DataTypePicker.tsx | 59 ++++++ .../DataPicker/DataTypePicker/index.ts | 1 + .../PanePicker/PanePicker.styled.tsx | 57 ++++++ .../DataPicker/PanePicker/PanePicker.tsx | 50 +++++ .../containers/DataPicker/PanePicker/index.ts | 1 + .../RawDataPicker/RawDataPicker.styled.tsx | 8 + .../RawDataPicker/RawDataPickerContainer.tsx | 180 ++++++++++++++++++ .../RawDataPicker/RawDataPickerView.tsx | 157 +++++++++++++++ .../DataPicker/RawDataPicker/index.ts | 1 + .../containers/DataPicker/constants.ts | 1 + .../metabase/containers/DataPicker/index.ts | 11 ++ .../metabase/containers/DataPicker/types.ts | 43 +++++ .../DataPicker/useDataPickerValue.ts | 68 +++++++ .../DataPicker/useSelectedTables.ts | 75 ++++++++ .../metabase/containers/DataPicker/utils.ts | 54 ++++++ .../entities/containers/EntityListLoader.jsx | 4 + .../data-search/SearchResults.jsx | 2 +- .../DataAppDataPicker/DataAppDataPicker.tsx | 23 --- .../components/DataAppDataPicker/index.ts | 1 - .../DataAppScaffoldingDataPicker.tsx | 20 ++ .../DataAppScaffoldingDataPicker/index.ts | 1 + .../CreateDataAppModal.styled.tsx | 44 ++++- .../CreateDataAppModal/CreateDataAppModal.tsx | 84 +++++--- .../ScaffoldDataAppPagesModal.styled.tsx | 2 +- .../ScaffoldDataAppPagesModal.tsx | 20 +- 44 files changed, 1777 insertions(+), 62 deletions(-) create mode 100644 frontend/src/metabase/components/tree/TreeNodeList.styled.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/CardPicker/CardPicker.styled.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/CardPicker/CardPickerContainer.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/CardPicker/CardPickerView.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/CardPicker/index.ts create mode 100644 frontend/src/metabase/containers/DataPicker/CardPicker/utils.ts create mode 100644 frontend/src/metabase/containers/DataPicker/DataPickerContainer.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/DataPickerContext/DataPickerContext.ts create mode 100644 frontend/src/metabase/containers/DataPicker/DataPickerContext/DataPickerContextProvider.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/DataPickerContext/index.ts create mode 100644 frontend/src/metabase/containers/DataPicker/DataPickerView.styled.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/DataPickerView.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/DataSearch/DataSearch.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/DataSearch/index.ts create mode 100644 frontend/src/metabase/containers/DataPicker/DataTypePicker/DataTypePicker.styled.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/DataTypePicker/DataTypePicker.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/DataTypePicker/index.ts create mode 100644 frontend/src/metabase/containers/DataPicker/PanePicker/PanePicker.styled.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/PanePicker/PanePicker.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/PanePicker/index.ts create mode 100644 frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPicker.styled.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPickerContainer.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPickerView.tsx create mode 100644 frontend/src/metabase/containers/DataPicker/RawDataPicker/index.ts create mode 100644 frontend/src/metabase/containers/DataPicker/constants.ts create mode 100644 frontend/src/metabase/containers/DataPicker/index.ts create mode 100644 frontend/src/metabase/containers/DataPicker/types.ts create mode 100644 frontend/src/metabase/containers/DataPicker/useDataPickerValue.ts create mode 100644 frontend/src/metabase/containers/DataPicker/useSelectedTables.ts create mode 100644 frontend/src/metabase/containers/DataPicker/utils.ts delete mode 100644 frontend/src/metabase/writeback/components/DataAppDataPicker/DataAppDataPicker.tsx delete mode 100644 frontend/src/metabase/writeback/components/DataAppDataPicker/index.ts create mode 100644 frontend/src/metabase/writeback/components/DataAppScaffoldingDataPicker/DataAppScaffoldingDataPicker.tsx create mode 100644 frontend/src/metabase/writeback/components/DataAppScaffoldingDataPicker/index.ts diff --git a/frontend/src/metabase-lib/metadata/utils/saved-questions.js b/frontend/src/metabase-lib/metadata/utils/saved-questions.js index 594418a90e6..1edeb3c6d34 100644 --- a/frontend/src/metabase-lib/metadata/utils/saved-questions.js +++ b/frontend/src/metabase-lib/metadata/utils/saved-questions.js @@ -24,6 +24,10 @@ export function getCollectionVirtualSchemaId(collection, { isDatasets } = {}) { ); } +export function getRootCollectionVirtualSchemaId({ isModels }) { + return getCollectionVirtualSchemaId(null, { isDatasets: isModels }); +} + export function getQuestionVirtualTableId(card) { return `card__${card.id}`; } diff --git a/frontend/src/metabase/components/SelectList/BaseSelectListItem.jsx b/frontend/src/metabase/components/SelectList/BaseSelectListItem.jsx index da32bcafecc..0b76b63232e 100644 --- a/frontend/src/metabase/components/SelectList/BaseSelectListItem.jsx +++ b/frontend/src/metabase/components/SelectList/BaseSelectListItem.jsx @@ -6,7 +6,7 @@ import { useScrollOnMount } from "metabase/hooks/use-scroll-on-mount"; import { BaseItemRoot } from "./SelectListItem.styled"; const propTypes = { - id: PropTypes.string.isRequired, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, name: PropTypes.string.isRequired, onSelect: PropTypes.func.isRequired, isSelected: PropTypes.bool, diff --git a/frontend/src/metabase/components/tree/Tree.tsx b/frontend/src/metabase/components/tree/Tree.tsx index 03abd09617e..e38b4a0244c 100644 --- a/frontend/src/metabase/components/tree/Tree.tsx +++ b/frontend/src/metabase/components/tree/Tree.tsx @@ -73,4 +73,5 @@ function BaseTree({ export const Tree = Object.assign(BaseTree, { Node: DefaultTreeNode, + NodeList: TreeNodeList, }); diff --git a/frontend/src/metabase/components/tree/TreeNodeList.styled.tsx b/frontend/src/metabase/components/tree/TreeNodeList.styled.tsx new file mode 100644 index 00000000000..2c7a1960c83 --- /dev/null +++ b/frontend/src/metabase/components/tree/TreeNodeList.styled.tsx @@ -0,0 +1,3 @@ +import styled from "@emotion/styled"; + +export const ListRoot = styled.ul``; diff --git a/frontend/src/metabase/components/tree/TreeNodeList.tsx b/frontend/src/metabase/components/tree/TreeNodeList.tsx index dd84ac36d41..f371c086a0f 100644 --- a/frontend/src/metabase/components/tree/TreeNodeList.tsx +++ b/frontend/src/metabase/components/tree/TreeNodeList.tsx @@ -1,6 +1,9 @@ import React from "react"; + import { useScrollOnMount } from "metabase/hooks/use-scroll-on-mount"; + import { ITreeNodeItem, TreeNodeComponent } from "./types"; +import { ListRoot } from "./TreeNodeList.styled"; interface TreeNodeListProps { items: ITreeNodeItem[]; @@ -8,14 +11,16 @@ interface TreeNodeListProps { selectedId?: ITreeNodeItem["id"]; depth: number; role?: string; + className?: string; onToggleExpand: (id: ITreeNodeItem["id"]) => void; onSelect?: (item: ITreeNodeItem) => void; TreeNode: TreeNodeComponent; } -export function TreeNodeList({ +function BaseTreeNodeList({ items, role, + className, expandedIds, selectedId, depth, @@ -26,7 +31,7 @@ export function TreeNodeList({ const selectedRef = useScrollOnMount(); return ( - <ul role={role}> + <ListRoot className={className} role={role}> {items.map(item => { const isSelected = selectedId === item.id; const hasChildren = @@ -49,7 +54,7 @@ export function TreeNodeList({ depth={depth} /> {isExpanded && ( - <TreeNodeList + <BaseTreeNodeList // eslint-disable-next-line @typescript-eslint/no-non-null-assertion items={item.children!} expandedIds={expandedIds} @@ -63,6 +68,10 @@ export function TreeNodeList({ </React.Fragment> ); })} - </ul> + </ListRoot> ); } + +export const TreeNodeList = Object.assign(BaseTreeNodeList, { + Root: ListRoot, +}); diff --git a/frontend/src/metabase/containers/DataPicker/CardPicker/CardPicker.styled.tsx b/frontend/src/metabase/containers/DataPicker/CardPicker/CardPicker.styled.tsx new file mode 100644 index 00000000000..98464679d16 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/CardPicker/CardPicker.styled.tsx @@ -0,0 +1,8 @@ +import styled from "@emotion/styled"; + +import SelectList from "metabase/components/SelectList"; + +export const StyledSelectList = styled(SelectList)` + width: 100%; + padding: 0 1rem; +`; diff --git a/frontend/src/metabase/containers/DataPicker/CardPicker/CardPickerContainer.tsx b/frontend/src/metabase/containers/DataPicker/CardPicker/CardPickerContainer.tsx new file mode 100644 index 00000000000..1a58178e15a --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/CardPicker/CardPickerContainer.tsx @@ -0,0 +1,161 @@ +import React, { useCallback, useMemo } from "react"; +import _ from "underscore"; +import { connect } from "react-redux"; + +import Collections from "metabase/entities/collections"; +import Schemas from "metabase/entities/schemas"; + +import { getUser } from "metabase/selectors/user"; + +import type { Collection, User } from "metabase-types/api"; +import type { State } from "metabase-types/store"; +import type Table from "metabase-lib/metadata/Table"; +import type Schema from "metabase-lib/metadata/Schema"; + +import { getCollectionVirtualSchemaId } from "metabase-lib/metadata/utils/saved-questions"; + +import type { + DataPickerProps, + DataPickerSelectedItem, + VirtualTable, +} from "../types"; +import useSelectedTables from "../useSelectedTables"; + +import { buildCollectionTree } from "./utils"; + +import CardPickerView from "./CardPickerView"; + +interface CardPickerOwnProps extends DataPickerProps { + targetModel: "model" | "question"; + onBack?: () => void; +} + +interface CardPickerStateProps { + currentUser: User; +} + +interface CollectionsLoaderProps { + collectionTree: Collection[]; + collections: Collection[]; + rootCollection: Collection; +} + +interface SchemaLoaderProps { + schema?: Schema & { tables: VirtualTable[] }; +} + +type CardPickerProps = CardPickerOwnProps & + CardPickerStateProps & + CollectionsLoaderProps & + SchemaLoaderProps; + +function mapStateToProps(state: State) { + return { + currentUser: getUser(state), + }; +} + +function CardPickerContainer({ + value, + collections, + collectionTree, + rootCollection, + schema: selectedSchema, + currentUser, + targetModel, + onChange, + onBack, +}: CardPickerProps) { + const { collectionId } = value; + + const { selectedTableIds, toggleTableIdSelection } = useSelectedTables({ + initialValues: value.tableIds, + mode: "multiple", + }); + + const collectionsMap = useMemo( + () => _.indexBy(collections, "id"), + [collections], + ); + + const tree = useMemo( + () => + buildCollectionTree({ + collections: collectionTree, + rootCollection, + currentUser, + targetModel, + }), + [collectionTree, rootCollection, currentUser, targetModel], + ); + + const selectedItems = useMemo(() => { + const items: DataPickerSelectedItem[] = []; + + if (collectionId) { + items.push({ type: "collection", id: collectionId }); + } + + const tables: DataPickerSelectedItem[] = selectedTableIds.map(id => ({ + type: "table", + id, + })); + + items.push(...tables); + + return items; + }, [collectionId, selectedTableIds]); + + const handleSelectedCollectionChange = useCallback( + (id: Collection["id"]) => { + const collection = id === "root" ? rootCollection : collectionsMap[id]; + if (collection) { + const schemaId = getCollectionVirtualSchemaId(collection, { + isDatasets: targetModel === "model", + }); + onChange({ ...value, schemaId, collectionId: id, tableIds: [] }); + } + }, + [value, collectionsMap, rootCollection, targetModel, onChange], + ); + + const handleSelectedTablesChange = useCallback( + (tableId: Table["id"]) => { + const tableIds = toggleTableIdSelection(tableId); + onChange({ ...value, tableIds }); + }, + [value, toggleTableIdSelection, onChange], + ); + + return ( + <CardPickerView + collectionTree={tree} + virtualTables={selectedSchema?.tables} + selectedItems={selectedItems} + targetModel={targetModel} + onSelectCollection={handleSelectedCollectionChange} + onSelectedVirtualTable={handleSelectedTablesChange} + onBack={onBack} + /> + ); +} + +export default _.compose( + Collections.load({ + id: "root", + entityAlias: "rootCollection", + loadingAndErrorWrapper: false, + }), + Collections.loadList({ + query: () => ({ tree: true }), + listName: "collectionTree", + }), + Collections.loadList({ + listName: "collections", + }), + Schemas.load({ + id: (state: State, props: CardPickerOwnProps) => props.value.schemaId, + loadingAndErrorWrapper: false, + }), + connect(mapStateToProps), +)(CardPickerContainer); diff --git a/frontend/src/metabase/containers/DataPicker/CardPicker/CardPickerView.tsx b/frontend/src/metabase/containers/DataPicker/CardPicker/CardPickerView.tsx new file mode 100644 index 00000000000..c009227033b --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/CardPicker/CardPickerView.tsx @@ -0,0 +1,128 @@ +import React, { useCallback, useMemo } from "react"; +import _ from "underscore"; + +import SelectList from "metabase/components/SelectList"; + +import { canonicalCollectionId } from "metabase/collections/utils"; + +import type { ITreeNodeItem } from "metabase/components/tree/types"; +import type { Collection } from "metabase-types/api"; +import type Table from "metabase-lib/metadata/Table"; + +import type { DataPickerSelectedItem, VirtualTable } from "../types"; +import PanePicker from "../PanePicker"; + +import { StyledSelectList } from "./CardPicker.styled"; + +type TargetModel = "model" | "question"; + +interface CardPickerViewProps { + collectionTree: ITreeNodeItem[]; + virtualTables?: VirtualTable[]; + selectedItems: DataPickerSelectedItem[]; + targetModel: TargetModel; + onSelectCollection: (id: Collection["id"]) => void; + onSelectedVirtualTable: (id: Table["id"]) => void; + onBack?: () => void; +} + +function getTableIcon({ + isSelected, + targetModel, +}: { + isSelected: boolean; + targetModel: TargetModel; +}) { + if (isSelected) { + return "check"; + } + return targetModel === "model" ? "model" : "table2"; +} + +function TableSelectListItem({ + table, + targetModel, + isSelected, + onSelect, +}: { + table: VirtualTable; + targetModel: "model" | "question"; + isSelected: boolean; + onSelect: (id: Table["id"]) => void; +}) { + return ( + <SelectList.Item + id={table.id} + name={table.display_name} + isSelected={isSelected} + icon={getTableIcon({ isSelected, targetModel })} + onSelect={onSelect} + > + {table.display_name} + </SelectList.Item> + ); +} + +function formatCollectionId(id: string | number | null) { + const canonicalId = canonicalCollectionId(id); + return canonicalId === null ? "root" : canonicalId; +} + +function CardPickerView({ + collectionTree, + virtualTables, + selectedItems, + targetModel, + onSelectCollection, + onSelectedVirtualTable, + onBack, +}: CardPickerViewProps) { + const { selectedCollectionId, selectedVirtualTableIds } = useMemo(() => { + const { collection: collections = [], table: tables = [] } = _.groupBy( + selectedItems, + "type", + ); + + const [collection] = collections; + + return { + selectedCollectionId: collection?.id, + selectedVirtualTableIds: tables.map(table => table.id), + }; + }, [selectedItems]); + + const handlePanePickerSelect = useCallback( + (item: ITreeNodeItem) => { + onSelectCollection(formatCollectionId(item.id)); + }, + [onSelectCollection], + ); + + const renderVirtualTable = useCallback( + (table: VirtualTable) => ( + <TableSelectListItem + key={table.id} + table={table} + targetModel={targetModel} + isSelected={selectedVirtualTableIds.includes(table.id)} + onSelect={onSelectedVirtualTable} + /> + ), + [selectedVirtualTableIds, targetModel, onSelectedVirtualTable], + ); + + return ( + <PanePicker + data={collectionTree} + selectedId={selectedCollectionId} + onSelect={handlePanePickerSelect} + onBack={onBack} + > + <StyledSelectList> + {virtualTables?.map?.(renderVirtualTable)} + </StyledSelectList> + </PanePicker> + ); +} + +export default CardPickerView; diff --git a/frontend/src/metabase/containers/DataPicker/CardPicker/index.ts b/frontend/src/metabase/containers/DataPicker/CardPicker/index.ts new file mode 100644 index 00000000000..35e03aa494d --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/CardPicker/index.ts @@ -0,0 +1 @@ +export { default } from "./CardPickerContainer"; diff --git a/frontend/src/metabase/containers/DataPicker/CardPicker/utils.ts b/frontend/src/metabase/containers/DataPicker/CardPicker/utils.ts new file mode 100644 index 00000000000..70e2428047c --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/CardPicker/utils.ts @@ -0,0 +1,78 @@ +import _ from "underscore"; + +import { + PERSONAL_COLLECTIONS, + buildCollectionTree as _buildCollectionTree, +} from "metabase/entities/collections"; +import { + isPersonalCollection, + nonPersonalOrArchivedCollection, + currentUserPersonalCollections, +} from "metabase/collections/utils"; + +import type { + Collection, + CollectionContentModel, + User, +} from "metabase-types/api"; + +function getOurAnalyticsCollection(collectionEntity: any) { + return { + ...collectionEntity, + schemaName: "Everything else", + icon: "folder", + }; +} + +const ALL_PERSONAL_COLLECTIONS_ROOT = { + ...PERSONAL_COLLECTIONS, +}; + +export function buildCollectionTree({ + collections, + rootCollection, + currentUser, + targetModel = "question", +}: { + collections: Collection[]; + rootCollection: Collection; + currentUser: User; + targetModel?: "model" | "question"; +}) { + const preparedCollections: Collection[] = []; + const userPersonalCollections = currentUserPersonalCollections( + collections, + currentUser.id, + ); + const nonPersonalOrArchivedCollections = collections.filter( + nonPersonalOrArchivedCollection, + ); + + preparedCollections.push(...userPersonalCollections); + preparedCollections.push(...nonPersonalOrArchivedCollections); + + if (currentUser.is_superuser) { + const otherPersonalCollections = collections.filter( + collection => + isPersonalCollection(collection) && + collection.personal_owner_id !== currentUser.id, + ); + + if (otherPersonalCollections.length > 0) { + preparedCollections.push({ + ...ALL_PERSONAL_COLLECTIONS_ROOT, + children: otherPersonalCollections, + } as Collection); + } + } + + const targetModels: CollectionContentModel[] = + targetModel === "model" ? ["dataset"] : ["card"]; + const tree = _buildCollectionTree(preparedCollections, { targetModels }); + + if (rootCollection) { + tree.unshift(getOurAnalyticsCollection(rootCollection)); + } + + return tree; +} diff --git a/frontend/src/metabase/containers/DataPicker/DataPickerContainer.tsx b/frontend/src/metabase/containers/DataPicker/DataPickerContainer.tsx new file mode 100644 index 00000000000..548803224ef --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataPickerContainer.tsx @@ -0,0 +1,149 @@ +import React, { useCallback, useMemo } from "react"; +import { connect } from "react-redux"; +import _ from "underscore"; + +import { getHasDataAccess } from "metabase/selectors/data"; +import { getSetting } from "metabase/selectors/settings"; + +import { useOnMount } from "metabase/hooks/use-on-mount"; + +import Databases from "metabase/entities/databases"; +import Search from "metabase/entities/search"; + +import type { State } from "metabase-types/store"; + +import { + getRootCollectionVirtualSchemaId, + SAVED_QUESTIONS_VIRTUAL_DB_ID, +} from "metabase-lib/metadata/utils/saved-questions"; + +import type { + DataPickerProps as DataPickerOwnProps, + DataPickerDataType, +} from "./types"; + +import { DataPickerContextProvider, useDataPicker } from "./DataPickerContext"; +import { getDataTypes, DEFAULT_DATA_PICKER_FILTERS } from "./utils"; + +import DataPickerView from "./DataPickerView"; + +interface DataPickerStateProps { + hasNestedQueriesEnabled: boolean; + hasDataAccess: boolean; +} + +interface SearchListLoaderProps { + search: unknown[]; +} + +type DataPickerProps = DataPickerOwnProps & + DataPickerStateProps & + SearchListLoaderProps; + +function mapStateToProps(state: State) { + return { + hasNestedQueriesEnabled: getSetting(state, "enable-nested-queries"), + hasDataAccess: getHasDataAccess(state), + }; +} + +function DataPicker({ + search: modelLookupResult, + filters: customFilters = {}, + hasNestedQueriesEnabled, + hasDataAccess, + ...props +}: DataPickerProps) { + const { onChange } = props; + + const { search } = useDataPicker(); + + const filters = useMemo( + () => ({ + ...DEFAULT_DATA_PICKER_FILTERS, + ...customFilters, + }), + [customFilters], + ); + + const dataTypes = useMemo( + () => + getDataTypes({ + hasModels: modelLookupResult.length > 0, + hasNestedQueriesEnabled, + }).filter(type => filters.types(type.id)), + [filters, modelLookupResult, hasNestedQueriesEnabled], + ); + + const handleDataTypeChange = useCallback( + (type: DataPickerDataType) => { + const isModels = type === "models"; + const isUsingVirtualTables = isModels || type === "questions"; + + // When switching to models or questions, + // we want to automatically open Our analytics collection + const databaseId = isUsingVirtualTables + ? SAVED_QUESTIONS_VIRTUAL_DB_ID + : undefined; + const schemaId = isUsingVirtualTables + ? getRootCollectionVirtualSchemaId({ isModels }) + : undefined; + const collectionId = isUsingVirtualTables ? "root" : undefined; + + onChange({ + type, + databaseId, + schemaId, + collectionId, + tableIds: [], + }); + }, + [onChange], + ); + + useOnMount(() => { + if (dataTypes.length === 1) { + handleDataTypeChange(dataTypes[0].id); + } + }); + + const handleBack = useCallback(() => { + onChange({ + type: undefined, + databaseId: undefined, + schemaId: undefined, + tableIds: [], + }); + }, [onChange]); + + return ( + <DataPickerView + {...props} + dataTypes={dataTypes} + searchQuery={search.query} + hasDataAccess={hasDataAccess} + onDataTypeChange={handleDataTypeChange} + onBack={handleBack} + /> + ); +} + +const DataPickerContainer = _.compose( + // Required for `hasDataAccess` check + Databases.loadList(), + + // Lets the picker check there is + // at least one model, to offer for selection + Search.loadList({ + query: { + models: "dataset", + limit: 1, + }, + }), + + connect(mapStateToProps), +)(DataPicker); + +export default Object.assign(DataPickerContainer, { + Provider: DataPickerContextProvider, +}); diff --git a/frontend/src/metabase/containers/DataPicker/DataPickerContext/DataPickerContext.ts b/frontend/src/metabase/containers/DataPicker/DataPickerContext/DataPickerContext.ts new file mode 100644 index 00000000000..8b8e0d84950 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataPickerContext/DataPickerContext.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from "react"; +import _ from "underscore"; + +export interface IDataPickerContext { + search: { + query: string; + setQuery: (query: string) => void; + }; +} + +export const DataPickerContext = createContext<IDataPickerContext>({ + search: { + query: "", + setQuery: _.noop, + }, +}); + +export function useDataPicker() { + return useContext(DataPickerContext); +} diff --git a/frontend/src/metabase/containers/DataPicker/DataPickerContext/DataPickerContextProvider.tsx b/frontend/src/metabase/containers/DataPicker/DataPickerContext/DataPickerContextProvider.tsx new file mode 100644 index 00000000000..419af8eed7f --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataPickerContext/DataPickerContextProvider.tsx @@ -0,0 +1,28 @@ +import React, { useMemo, useState } from "react"; +import { DataPickerContext, IDataPickerContext } from "./DataPickerContext"; + +function DataPickerContextProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [searchQuery, setSearchQuery] = useState(""); + + const value: IDataPickerContext = useMemo( + () => ({ + search: { + query: searchQuery, + setQuery: setSearchQuery, + }, + }), + [searchQuery], + ); + + return ( + <DataPickerContext.Provider value={value}> + {children} + </DataPickerContext.Provider> + ); +} + +export default DataPickerContextProvider; diff --git a/frontend/src/metabase/containers/DataPicker/DataPickerContext/index.ts b/frontend/src/metabase/containers/DataPicker/DataPickerContext/index.ts new file mode 100644 index 00000000000..89c2831f5f6 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataPickerContext/index.ts @@ -0,0 +1,2 @@ +export { useDataPicker } from "./DataPickerContext"; +export { default as DataPickerContextProvider } from "./DataPickerContextProvider"; diff --git a/frontend/src/metabase/containers/DataPicker/DataPickerView.styled.tsx b/frontend/src/metabase/containers/DataPicker/DataPickerView.styled.tsx new file mode 100644 index 00000000000..563b43aea8c --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataPickerView.styled.tsx @@ -0,0 +1,13 @@ +import styled from "@emotion/styled"; + +export const Root = styled.div` + display: flex; + flex: 1; +`; + +export const EmptyStateContainer = styled.div` + display: flex; + flex: 1; + align-items: center; + justify-content: center; +`; diff --git a/frontend/src/metabase/containers/DataPicker/DataPickerView.tsx b/frontend/src/metabase/containers/DataPicker/DataPickerView.tsx new file mode 100644 index 00000000000..fedf1acdc10 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataPickerView.tsx @@ -0,0 +1,89 @@ +import React, { useMemo } from "react"; +import { t } from "ttag"; + +import EmptyState from "metabase/components/EmptyState"; + +import { MIN_SEARCH_LENGTH } from "./constants"; + +import type { DataPickerProps, DataPickerDataType } from "./types"; +import type { DataTypeInfoItem } from "./utils"; + +import CardPicker from "./CardPicker"; +import DataTypePicker from "./DataTypePicker"; +import DataSearch from "./DataSearch"; +import RawDataPicker from "./RawDataPicker"; + +import { Root, EmptyStateContainer } from "./DataPickerView.styled"; + +interface DataPickerViewProps extends DataPickerProps { + dataTypes: DataTypeInfoItem[]; + searchQuery: string; + hasDataAccess: boolean; + onDataTypeChange: (type: DataPickerDataType) => void; + onBack?: () => void; +} + +function DataPickerViewContent({ + dataTypes, + searchQuery, + hasDataAccess, + onDataTypeChange, + ...props +}: DataPickerViewProps) { + const { value, onChange } = props; + + const availableDataTypes = useMemo( + () => dataTypes.map(type => type.id), + [dataTypes], + ); + + if (!hasDataAccess) { + return ( + <EmptyStateContainer> + <EmptyState + message={t`To pick some data, you'll need to add some first`} + icon="database" + /> + </EmptyStateContainer> + ); + } + + if (searchQuery.trim().length > MIN_SEARCH_LENGTH) { + return ( + <DataSearch + value={value} + searchQuery={searchQuery} + availableDataTypes={availableDataTypes} + onChange={onChange} + /> + ); + } + + if (!value.type) { + return <DataTypePicker types={dataTypes} onChange={onDataTypeChange} />; + } + + if (value.type === "raw-data") { + return <RawDataPicker {...props} />; + } + + if (value.type === "models") { + return <CardPicker {...props} targetModel="model" />; + } + + if (value.type === "questions") { + return <CardPicker {...props} targetModel="question" />; + } + + return null; +} + +function DataPickerView(props: DataPickerViewProps) { + return ( + <Root> + <DataPickerViewContent {...props} /> + </Root> + ); +} + +export default DataPickerView; diff --git a/frontend/src/metabase/containers/DataPicker/DataSearch/DataSearch.tsx b/frontend/src/metabase/containers/DataPicker/DataSearch/DataSearch.tsx new file mode 100644 index 00000000000..f4e93c90ffa --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataSearch/DataSearch.tsx @@ -0,0 +1,120 @@ +import React, { useCallback, useMemo } from "react"; + +import { SearchResults } from "metabase/query_builder/components/DataSelector/data-search"; + +import type { Collection } from "metabase-types/api"; + +import { + getCollectionVirtualSchemaId, + getQuestionVirtualTableId, + SAVED_QUESTIONS_VIRTUAL_DB_ID, +} from "metabase-lib/metadata/utils/saved-questions"; +import { generateSchemaId } from "metabase-lib/metadata/utils/schema"; + +import { useDataPicker } from "../DataPickerContext"; + +import type { DataPickerValue, DataPickerDataType } from "../types"; + +interface DataSearchProps { + value: DataPickerValue; + searchQuery: string; + availableDataTypes: DataPickerDataType[]; + onChange: (value: DataPickerValue) => void; +} + +type TableSearchResult = { + database_id: number; + table_schema: string; + table_id: number; + model: "table" | "dataset" | "card"; + collection: Collection | null; +}; + +type SearchModel = "card" | "dataset" | "table"; + +const DATA_TYPE_SEARCH_MODEL_MAP: Record<DataPickerDataType, SearchModel> = { + "raw-data": "table", + models: "dataset", + questions: "card", +}; + +function getDataTypeForSearchResult( + table: TableSearchResult, +): DataPickerDataType { + switch (table.model) { + case "table": + return "raw-data"; + case "card": + return "questions"; + case "dataset": + return "models"; + } +} + +function getValueForRawTable(table: TableSearchResult): DataPickerValue { + return { + type: "raw-data", + databaseId: table.database_id, + schemaId: generateSchemaId(table.database_id, table.table_schema), + collectionId: undefined, + tableIds: [table.table_id], + }; +} + +function getValueForVirtualTable(table: TableSearchResult): DataPickerValue { + const type = getDataTypeForSearchResult(table); + const schemaId = getCollectionVirtualSchemaId(table.collection, { + isDatasets: type === "models", + }); + return { + type: "models", + databaseId: SAVED_QUESTIONS_VIRTUAL_DB_ID, + schemaId, + collectionId: table.collection?.id || "root", + tableIds: [getQuestionVirtualTableId(table)], + }; +} + +function getNextValue(table: TableSearchResult): DataPickerValue { + const type = getDataTypeForSearchResult(table); + const isVirtualTable = type === "models" || type === "questions"; + return isVirtualTable + ? getValueForVirtualTable(table) + : getValueForRawTable(table); +} + +function DataSearch({ + value, + searchQuery, + availableDataTypes, + onChange, +}: DataSearchProps) { + const { search } = useDataPicker(); + const { setQuery } = search; + + const searchModels: SearchModel[] = useMemo(() => { + if (!value.type) { + return availableDataTypes.map(type => DATA_TYPE_SEARCH_MODEL_MAP[type]); + } + return [DATA_TYPE_SEARCH_MODEL_MAP[value.type]]; + }, [value.type, availableDataTypes]); + + const onSelect = useCallback( + (table: TableSearchResult) => { + const nextValue = getNextValue(table); + onChange(nextValue); + setQuery(""); + }, + [onChange, setQuery], + ); + + return ( + <SearchResults + searchModels={searchModels} + searchQuery={searchQuery.trim()} + onSelect={onSelect} + /> + ); +} + +export default DataSearch; diff --git a/frontend/src/metabase/containers/DataPicker/DataSearch/index.ts b/frontend/src/metabase/containers/DataPicker/DataSearch/index.ts new file mode 100644 index 00000000000..79f6d02b6e8 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataSearch/index.ts @@ -0,0 +1 @@ +export { default } from "./DataSearch"; diff --git a/frontend/src/metabase/containers/DataPicker/DataTypePicker/DataTypePicker.styled.tsx b/frontend/src/metabase/containers/DataPicker/DataTypePicker/DataTypePicker.styled.tsx new file mode 100644 index 00000000000..16b5a3ba021 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataTypePicker/DataTypePicker.styled.tsx @@ -0,0 +1,47 @@ +import styled from "@emotion/styled"; + +import Icon from "metabase/components/Icon"; +import SelectList from "metabase/components/SelectList"; + +import { color } from "metabase/lib/colors"; +import { space } from "metabase/styled-components/theme"; + +export const List = styled(SelectList)` + padding: ${space(0)} ${space(1)} 12px ${space(1)}; +`; + +export const ItemIcon = styled(Icon)` + color: ${color("text-dark")}; +`; + +export const TitleContainer = styled.div` + display: flex; + align-items: center; +`; + +export const ItemTitle = styled.span` + color: ${color("text-dark")}; + font-weight: 700; + font-size: 14px; + margin-left: ${space(1)}; +`; + +export const ItemDescriptionContainer = styled.div` + margin-top: ${space(0)}; +`; + +export const ItemDescription = styled.span` + color: ${color("text-light")}; + font-weight: 700; + font-size: 12px; +`; + +export const ItemContainer = styled(SelectList.BaseItem as any)` + &:hover { + ${ItemIcon}, + ${ItemTitle}, + ${ItemDescription} { + color: ${color("text-white")}; + } + } +`; diff --git a/frontend/src/metabase/containers/DataPicker/DataTypePicker/DataTypePicker.tsx b/frontend/src/metabase/containers/DataPicker/DataTypePicker/DataTypePicker.tsx new file mode 100644 index 00000000000..d3ee7d2b0c5 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataTypePicker/DataTypePicker.tsx @@ -0,0 +1,59 @@ +import React from "react"; + +import type { DataTypeInfoItem } from "../utils"; +import type { DataPickerDataType } from "../types"; + +import { + List, + ItemContainer, + TitleContainer, + ItemIcon, + ItemTitle, + ItemDescriptionContainer, + ItemDescription, +} from "./DataTypePicker.styled"; + +interface DataTypePickerProps { + types: DataTypeInfoItem[]; + onChange: (value: DataPickerDataType) => void; +} + +interface ListItemProps extends DataTypeInfoItem { + onSelect: () => void; +} + +function DataTypePickerListItem({ + id, + name, + icon, + description, + onSelect, +}: ListItemProps) { + return ( + <ItemContainer id={id} name={name} onSelect={onSelect}> + <TitleContainer> + <ItemIcon name={icon} size={18} /> + <ItemTitle>{name}</ItemTitle> + </TitleContainer> + <ItemDescriptionContainer> + <ItemDescription>{description}</ItemDescription> + </ItemDescriptionContainer> + </ItemContainer> + ); +} + +function DataTypePicker({ types, onChange }: DataTypePickerProps) { + return ( + <List> + {types.map(dataType => ( + <DataTypePickerListItem + {...dataType} + key={dataType.id} + onSelect={() => onChange(dataType.id)} + /> + ))} + </List> + ); +} + +export default DataTypePicker; diff --git a/frontend/src/metabase/containers/DataPicker/DataTypePicker/index.ts b/frontend/src/metabase/containers/DataPicker/DataTypePicker/index.ts new file mode 100644 index 00000000000..e148b996fab --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/DataTypePicker/index.ts @@ -0,0 +1 @@ +export { default } from "./DataTypePicker"; diff --git a/frontend/src/metabase/containers/DataPicker/PanePicker/PanePicker.styled.tsx b/frontend/src/metabase/containers/DataPicker/PanePicker/PanePicker.styled.tsx new file mode 100644 index 00000000000..01edf4030bb --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/PanePicker/PanePicker.styled.tsx @@ -0,0 +1,57 @@ +import styled from "@emotion/styled"; + +import { Tree } from "metabase/components/tree"; + +import { color } from "metabase/lib/colors"; + +import { breakpointMaxSmall } from "metabase/styled-components/theme/media-queries"; + +export const Root = styled.div` + display: flex; + flex: 1; + overflow: hidden; + + ${breakpointMaxSmall} { + flex-direction: column; + overflow: auto; + } +`; + +export const LeftPaneContainer = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; + + border-right: 1px solid ${color("border")}; + + ${Tree.Node.Root} { + border-radius: 6px; + } + + ${Tree.NodeList.Root} { + padding: 0 1rem; + } +`; + +export const BackButton = styled.a` + display: flex; + align-items: center; + + color: ${color("text-dark")}; + font-weight: 700; + + margin-left: 1rem; + padding-bottom: 1rem; + + &:hover { + color: ${color("brand")}; + } +`; + +export const TreeContainer = styled.div``; + +export const RightPaneContainer = styled.div` + display: flex; + flex: 1; +`; diff --git a/frontend/src/metabase/containers/DataPicker/PanePicker/PanePicker.tsx b/frontend/src/metabase/containers/DataPicker/PanePicker/PanePicker.tsx new file mode 100644 index 00000000000..e3232f587dc --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/PanePicker/PanePicker.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { t } from "ttag"; + +import Icon from "metabase/components/Icon"; +import { Tree } from "metabase/components/tree"; + +import type { ITreeNodeItem } from "metabase/components/tree/types"; + +import { + Root, + LeftPaneContainer, + TreeContainer, + BackButton, + RightPaneContainer, +} from "./PanePicker.styled"; + +interface PanePickerProps { + data: ITreeNodeItem[]; + selectedId?: ITreeNodeItem["id"]; + onSelect: (item: ITreeNodeItem) => void; + onBack?: () => void; + children?: React.ReactNode; +} + +function PanePicker({ + data, + selectedId, + onSelect, + onBack, + children, +}: PanePickerProps) { + return ( + <Root> + <LeftPaneContainer> + {onBack && ( + <BackButton onClick={onBack}> + <Icon name="chevronleft" className="mr1" /> + {t`Back`} + </BackButton> + )} + <TreeContainer> + <Tree selectedId={selectedId} data={data} onSelect={onSelect} /> + </TreeContainer> + </LeftPaneContainer> + <RightPaneContainer>{children}</RightPaneContainer> + </Root> + ); +} + +export default PanePicker; diff --git a/frontend/src/metabase/containers/DataPicker/PanePicker/index.ts b/frontend/src/metabase/containers/DataPicker/PanePicker/index.ts new file mode 100644 index 00000000000..deef2443d79 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/PanePicker/index.ts @@ -0,0 +1 @@ +export { default } from "./PanePicker"; diff --git a/frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPicker.styled.tsx b/frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPicker.styled.tsx new file mode 100644 index 00000000000..98464679d16 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPicker.styled.tsx @@ -0,0 +1,8 @@ +import styled from "@emotion/styled"; + +import SelectList from "metabase/components/SelectList"; + +export const StyledSelectList = styled(SelectList)` + width: 100%; + padding: 0 1rem; +`; diff --git a/frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPickerContainer.tsx b/frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPickerContainer.tsx new file mode 100644 index 00000000000..77a70e39306 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPickerContainer.tsx @@ -0,0 +1,180 @@ +import React, { useCallback, useMemo } from "react"; +import _ from "underscore"; + +import Databases from "metabase/entities/databases"; +import Schemas from "metabase/entities/schemas"; +import Tables from "metabase/entities/tables"; + +import type Database from "metabase-lib/metadata/Database"; +import type Table from "metabase-lib/metadata/Table"; + +import { DataPickerProps, DataPickerSelectedItem } from "../types"; + +import useSelectedTables from "../useSelectedTables"; + +import RawDataPickerView from "./RawDataPickerView"; + +interface DatabaseListLoaderProps { + databases: Database[]; +} + +interface TableListLoaderProps { + tables: Table[]; +} + +interface RawDataPickerOwnProps extends DataPickerProps { + onBack?: () => void; +} + +type RawDataPickerProps = RawDataPickerOwnProps & DatabaseListLoaderProps; + +function RawDataPicker({ + value, + databases, + onChange, + onBack, +}: RawDataPickerProps) { + const { databaseId: selectedDatabaseId, schemaId: selectedSchemaId } = value; + + const { selectedTableIds, toggleTableIdSelection } = useSelectedTables({ + initialValues: value.tableIds, + mode: "multiple", + }); + + const selectedDatabase = useMemo(() => { + if (!selectedDatabaseId) { + return; + } + return databases.find(db => db.id === selectedDatabaseId); + }, [databases, selectedDatabaseId]); + + const selectedSchema = useMemo(() => { + if (!selectedDatabase) { + return; + } + const schemas = selectedDatabase.getSchemas(); + return schemas.find(schema => schema.id === selectedSchemaId); + }, [selectedDatabase, selectedSchemaId]); + + const selectedItems = useMemo(() => { + const items: DataPickerSelectedItem[] = []; + + if (selectedDatabaseId) { + items.push({ type: "database", id: selectedDatabaseId }); + } + + if (selectedSchemaId) { + items.push({ type: "schema", id: selectedSchemaId }); + } + + const tables: DataPickerSelectedItem[] = selectedTableIds.map(id => ({ + type: "table", + id, + })); + + items.push(...tables); + + return items; + }, [selectedDatabaseId, selectedSchemaId, selectedTableIds]); + + const handleSelectedSchemaIdChange = useCallback( + (schemaId?: string) => { + onChange({ ...value, schemaId, tableIds: [] }); + }, + [value, onChange], + ); + + const handleSelectedDatabaseIdChange = useCallback( + (databaseId: Database["id"]) => { + const database = databases.find(db => db.id === databaseId); + if (!database) { + return; + } + let nextSchemaId = undefined; + const schemas = database.getSchemas() ?? []; + const hasSchemasLoaded = schemas.length > 0; + if (hasSchemasLoaded) { + const hasSingleSchema = schemas.length === 1; + nextSchemaId = hasSingleSchema ? schemas[0].id : undefined; + } + onChange({ ...value, databaseId, schemaId: nextSchemaId, tableIds: [] }); + }, + [value, databases, onChange], + ); + + const handleSelectedTablesChange = useCallback( + (tableId: Table["id"]) => { + const tableIds = toggleTableIdSelection(tableId); + onChange({ ...value, tableIds }); + }, + [value, toggleTableIdSelection, onChange], + ); + + const onDatabaseSchemasLoaded = useCallback(() => { + if (!selectedSchemaId) { + const schemas = selectedDatabase?.getSchemas() ?? []; + const hasSingleSchema = schemas.length === 1; + if (hasSingleSchema) { + const [schema] = schemas; + handleSelectedSchemaIdChange(schema.id); + } + } + }, [selectedDatabase, selectedSchemaId, handleSelectedSchemaIdChange]); + + const renderPicker = useCallback( + ({ tables }: { tables?: Table[] } = {}) => { + return ( + <RawDataPickerView + databases={databases} + tables={tables} + selectedItems={selectedItems} + onSelectDatabase={handleSelectedDatabaseIdChange} + onSelectSchema={handleSelectedSchemaIdChange} + onSelectedTable={handleSelectedTablesChange} + onBack={onBack} + /> + ); + }, + [ + databases, + selectedItems, + handleSelectedDatabaseIdChange, + handleSelectedSchemaIdChange, + handleSelectedTablesChange, + onBack, + ], + ); + + if (selectedDatabaseId) { + return ( + <Schemas.ListLoader + query={{ dbId: selectedDatabaseId }} + loadingAndErrorWrapper={false} + onLoaded={onDatabaseSchemasLoaded} + > + {() => { + if (!selectedSchema) { + return renderPicker(); + } + return ( + <Tables.ListLoader + query={{ + dbId: selectedDatabaseId, + schemaName: selectedSchema.name, + }} + loadingAndErrorWrapper={false} + > + {({ tables }: TableListLoaderProps) => renderPicker({ tables })} + </Tables.ListLoader> + ); + }} + </Schemas.ListLoader> + ); + } + + return renderPicker(); +} + +export default Databases.loadList({ loadingAndErrorWrapper: false })( + RawDataPicker, +); diff --git a/frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPickerView.tsx b/frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPickerView.tsx new file mode 100644 index 00000000000..e411bdc88ef --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/RawDataPicker/RawDataPickerView.tsx @@ -0,0 +1,157 @@ +import React, { useCallback, useMemo } from "react"; +import _ from "underscore"; + +import SelectList from "metabase/components/SelectList"; + +import type { ITreeNodeItem } from "metabase/components/tree/types"; + +import type Database from "metabase-lib/metadata/Database"; +import type Table from "metabase-lib/metadata/Table"; +import type Schema from "metabase-lib/metadata/Schema"; + +import type { DataPickerSelectedItem } from "../types"; + +import PanePicker from "../PanePicker"; +import { StyledSelectList } from "./RawDataPicker.styled"; + +interface RawDataPickerViewProps { + databases: Database[]; + tables?: Table[]; + selectedItems: DataPickerSelectedItem[]; + onSelectDatabase: (id: Database["id"]) => void; + onSelectSchema: (id: Schema["id"]) => void; + onSelectedTable: (id: Table["id"]) => void; + onBack?: () => void; +} + +function schemaToTreeItem(schema: Schema): ITreeNodeItem { + return { + id: String(schema.id), + name: schema.name, + icon: "folder", + }; +} + +function dbToTreeItem(database: Database): ITreeNodeItem { + const schemas = database.getSchemas(); + const hasSingleSchema = schemas.length === 1; + return { + id: database.id, + name: database.name, + icon: "database", + + // If a database has a single schema, + // we just want to automatically select it + // and exclude it from the tree picker + children: hasSingleSchema ? [] : schemas.map(schemaToTreeItem), + }; +} + +function TableSelectListItem({ + table, + isSelected, + onSelect, +}: { + table: Table; + isSelected: boolean; + onSelect: (id: Table["id"]) => void; +}) { + const name = table.displayName(); + return ( + <SelectList.Item + id={table.id} + name={name} + isSelected={isSelected} + icon={isSelected ? "check" : "table2"} + onSelect={onSelect} + > + {name} + </SelectList.Item> + ); +} + +function RawDataPickerView({ + databases, + tables, + selectedItems, + onSelectDatabase, + onSelectSchema, + onSelectedTable, + onBack, +}: RawDataPickerViewProps) { + const treeData = useMemo(() => databases.map(dbToTreeItem), [databases]); + + const { selectedDatabaseId, selectedSchemaId, selectedTableIds } = + useMemo(() => { + const { + database: databases = [], + schema: schemas = [], + table: tables = [], + } = _.groupBy(selectedItems, "type"); + + const [db] = databases; + const [schema] = schemas; + + return { + selectedDatabaseId: db?.id, + selectedSchemaId: schema?.id, + selectedTableIds: tables.map(table => table.id), + }; + }, [selectedItems]); + + const selectedDatabase = useMemo( + () => databases.find(db => db.id === selectedDatabaseId), + [databases, selectedDatabaseId], + ); + + const isSelectedDatabaseSingleSchema = useMemo( + () => selectedDatabase?.getSchemas().length === 1, + [selectedDatabase], + ); + + const selectedTreeItemId = useMemo(() => { + if (selectedSchemaId) { + return isSelectedDatabaseSingleSchema + ? selectedDatabaseId + : selectedSchemaId; + } + return selectedDatabaseId; + }, [selectedDatabaseId, selectedSchemaId, isSelectedDatabaseSingleSchema]); + + const handlePanePickerSelect = useCallback( + (item: ITreeNodeItem) => { + if (item.icon === "database") { + return onSelectDatabase(Number(item.id)); + } + if (item.icon === "folder") { + return onSelectSchema(String(item.id)); + } + }, + [onSelectDatabase, onSelectSchema], + ); + + const renderTable = useCallback( + (table: Table) => ( + <TableSelectListItem + key={table.id} + table={table} + isSelected={selectedTableIds.includes(table.id)} + onSelect={onSelectedTable} + /> + ), + [selectedTableIds, onSelectedTable], + ); + + return ( + <PanePicker + data={treeData} + selectedId={selectedTreeItemId} + onSelect={handlePanePickerSelect} + onBack={onBack} + > + <StyledSelectList>{tables?.map?.(renderTable)}</StyledSelectList> + </PanePicker> + ); +} + +export default RawDataPickerView; diff --git a/frontend/src/metabase/containers/DataPicker/RawDataPicker/index.ts b/frontend/src/metabase/containers/DataPicker/RawDataPicker/index.ts new file mode 100644 index 00000000000..41003489171 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/RawDataPicker/index.ts @@ -0,0 +1 @@ +export { default } from "./RawDataPickerContainer"; diff --git a/frontend/src/metabase/containers/DataPicker/constants.ts b/frontend/src/metabase/containers/DataPicker/constants.ts new file mode 100644 index 00000000000..adb548e3d6d --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/constants.ts @@ -0,0 +1 @@ +export const MIN_SEARCH_LENGTH = 2; diff --git a/frontend/src/metabase/containers/DataPicker/index.ts b/frontend/src/metabase/containers/DataPicker/index.ts new file mode 100644 index 00000000000..cf5c29fe92b --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/index.ts @@ -0,0 +1,11 @@ +export { default } from "./DataPickerContainer"; +export { useDataPicker } from "./DataPickerContext"; + +export { default as useDataPickerValue } from "./useDataPickerValue"; + +export type { + DataPickerDataType, + DataPickerValue, + DataPickerProps, + DataPickerFiltersProp, +} from "./types"; diff --git a/frontend/src/metabase/containers/DataPicker/types.ts b/frontend/src/metabase/containers/DataPicker/types.ts new file mode 100644 index 00000000000..fa00a86605c --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/types.ts @@ -0,0 +1,43 @@ +import type { Collection } from "metabase-types/api"; + +import type Database from "metabase-lib/metadata/Database"; +import type Table from "metabase-lib/metadata/Table"; +import type Schema from "metabase-lib/metadata/Schema"; + +export type DataPickerDataType = "models" | "raw-data" | "questions"; + +export type DataPickerValue = { + type?: DataPickerDataType; + databaseId?: Database["id"]; + schemaId?: Schema["id"]; + collectionId?: Collection["id"]; + tableIds: Table["id"][]; +}; + +export interface VirtualTable { + id: Table["id"]; + display_name: string; + schema: { + id: Schema["id"]; + }; +} + +export interface DataPickerFilters { + types: (type: DataPickerDataType) => boolean; + databases: (database: Database) => boolean; + schemas: (schema: Schema) => boolean; + tables: (table: Table | VirtualTable) => boolean; +} + +export type DataPickerFiltersProp = Partial<DataPickerFilters>; + +export interface DataPickerProps { + value: DataPickerValue; + onChange: (value: DataPickerValue) => void; + filters?: DataPickerFiltersProp; +} + +export type DataPickerSelectedItem = { + type: "database" | "schema" | "collection" | "table"; + id: string | number; +}; diff --git a/frontend/src/metabase/containers/DataPicker/useDataPickerValue.ts b/frontend/src/metabase/containers/DataPicker/useDataPickerValue.ts new file mode 100644 index 00000000000..8a20406c0af --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/useDataPickerValue.ts @@ -0,0 +1,68 @@ +import { useCallback, useState } from "react"; + +import { SAVED_QUESTIONS_VIRTUAL_DB_ID } from "metabase-lib/metadata/utils/saved-questions"; + +import { DataPickerValue } from "./types"; + +function cleanDatabaseValue({ type, databaseId }: Partial<DataPickerValue>) { + const isUsingVirtualTables = type === "models" || type === "questions"; + if (isUsingVirtualTables) { + return SAVED_QUESTIONS_VIRTUAL_DB_ID; + } + return databaseId; +} + +function cleanSchemaValue({ databaseId, schemaId }: Partial<DataPickerValue>) { + return databaseId ? schemaId : undefined; +} + +function cleanTablesValue({ + databaseId, + schemaId, + tableIds, +}: Partial<DataPickerValue>) { + if (!tableIds) { + return []; + } + return databaseId && schemaId ? tableIds : []; +} + +function cleanCollectionValue({ + type, + databaseId, + collectionId, +}: Partial<DataPickerValue>) { + const isUsingVirtualTables = type === "models" || type === "questions"; + if (isUsingVirtualTables && databaseId === SAVED_QUESTIONS_VIRTUAL_DB_ID) { + return collectionId; + } + return undefined; +} + +function cleanValue(value: Partial<DataPickerValue>): DataPickerValue { + return { + type: value.type, + databaseId: cleanDatabaseValue(value), + schemaId: cleanSchemaValue(value), + collectionId: cleanCollectionValue(value), + tableIds: cleanTablesValue(value), + }; +} + +type HookResult = [DataPickerValue, (value: DataPickerValue) => void]; + +function useDataPickerValue( + initialValue: Partial<DataPickerValue> = {}, +): HookResult { + const [value, _setValue] = useState<DataPickerValue>( + cleanValue(initialValue), + ); + + const setValue = useCallback((nextValue: DataPickerValue) => { + _setValue(cleanValue(nextValue)); + }, []); + + return [value, setValue]; +} + +export default useDataPickerValue; diff --git a/frontend/src/metabase/containers/DataPicker/useSelectedTables.ts b/frontend/src/metabase/containers/DataPicker/useSelectedTables.ts new file mode 100644 index 00000000000..74bc6ea9b78 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/useSelectedTables.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import type { TableId } from "metabase-types/api"; + +interface SelectedTablesHookOpts { + initialValues?: TableId[]; + mode?: "single" | "multiple"; +} + +function useSelectedTables({ + initialValues = [], + mode = "single", +}: SelectedTablesHookOpts = {}) { + const [selectedTableIds, setSelectedTableIds] = useState( + new Set(initialValues), + ); + + useEffect(() => { + setSelectedTableIds(new Set(initialValues)); + }, [initialValues]); + + const addSelectedTableId = useCallback( + (id: TableId) => { + const nextState = + mode === "multiple" + ? new Set([...selectedTableIds, id]) + : new Set([id]); + setSelectedTableIds(nextState); + return Array.from(nextState); + }, + [selectedTableIds, mode], + ); + + const removeSelectedTableId = useCallback( + (id: TableId) => { + if (selectedTableIds.has(id)) { + const nextState = new Set([...selectedTableIds].filter(i => i !== id)); + setSelectedTableIds(nextState); + return Array.from(nextState); + } + return Array.from(selectedTableIds); + }, + [selectedTableIds], + ); + + const toggleTableIdSelection = useCallback( + (id: TableId) => { + if (selectedTableIds.has(id)) { + return removeSelectedTableId(id); + } else { + return addSelectedTableId(id); + } + }, + [selectedTableIds, addSelectedTableId, removeSelectedTableId], + ); + + const clearSelectedTables = useCallback(() => { + setSelectedTableIds(new Set()); + }, []); + + const selectedTableIdList = useMemo( + () => Array.from(selectedTableIds), + [selectedTableIds], + ); + + return { + selectedTableIds: selectedTableIdList, + addSelectedTableId, + removeSelectedTableId, + toggleTableIdSelection, + clearSelectedTables, + }; +} + +export default useSelectedTables; diff --git a/frontend/src/metabase/containers/DataPicker/utils.ts b/frontend/src/metabase/containers/DataPicker/utils.ts new file mode 100644 index 00000000000..553eac82f29 --- /dev/null +++ b/frontend/src/metabase/containers/DataPicker/utils.ts @@ -0,0 +1,54 @@ +import { t } from "ttag"; + +import type { DataPickerDataType, DataPickerFilters } from "./types"; + +export type DataTypeInfoItem = { + id: DataPickerDataType; + icon: string; + name: string; + description: string; +}; + +export function getDataTypes({ + hasNestedQueriesEnabled, + hasModels, +}: { + hasNestedQueriesEnabled: boolean; + hasModels: boolean; +}): DataTypeInfoItem[] { + const dataTypes: DataTypeInfoItem[] = [ + { + id: "raw-data", + icon: "database", + name: t`Raw Data`, + description: t`Unaltered tables in connected databases.`, + }, + ]; + + if (hasNestedQueriesEnabled) { + if (hasModels) { + dataTypes.unshift({ + id: "models", + icon: "model", + name: t`Models`, + description: t`The best starting place for new questions.`, + }); + } + + dataTypes.push({ + id: "questions", + name: t`Saved Questions`, + icon: "folder", + description: t`Use any question’s results to start a new question.`, + }); + } + + return dataTypes; +} + +export const DEFAULT_DATA_PICKER_FILTERS: DataPickerFilters = { + types: () => true, + databases: () => true, + schemas: () => true, + tables: () => true, +}; diff --git a/frontend/src/metabase/entities/containers/EntityListLoader.jsx b/frontend/src/metabase/entities/containers/EntityListLoader.jsx index d4c9eb7fa6e..92d11360b4c 100644 --- a/frontend/src/metabase/entities/containers/EntityListLoader.jsx +++ b/frontend/src/metabase/entities/containers/EntityListLoader.jsx @@ -26,6 +26,7 @@ const propTypes = { listName: PropTypes.string, selectorName: PropTypes.string, children: PropTypes.func, + onLoaded: PropTypes.func, // via entityType HOC entityDef: PropTypes.object, @@ -118,6 +119,9 @@ class EntityListLoaderInner extends React.Component { !result.payload.result || result.payload.result.length === pageSize, ); } + + this.props?.onLoaded?.(result); + return result; }, 250, diff --git a/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx b/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx index 1efa8ca8fa7..1c7e816791b 100644 --- a/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx +++ b/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx @@ -13,7 +13,7 @@ const propTypes = { searchQuery: PropTypes.string.required, onSelect: PropTypes.func.required, searchModels: PropTypes.arrayOf( - PropTypes.arrayOf(PropTypes.oneOf(["card", "table"])), + PropTypes.oneOf(["card", "dataset", "table"]), ), }; diff --git a/frontend/src/metabase/writeback/components/DataAppDataPicker/DataAppDataPicker.tsx b/frontend/src/metabase/writeback/components/DataAppDataPicker/DataAppDataPicker.tsx deleted file mode 100644 index a5ee2a3c706..00000000000 --- a/frontend/src/metabase/writeback/components/DataAppDataPicker/DataAppDataPicker.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; - -import { DatabaseSchemaAndTableDataSelector } from "metabase/query_builder/components/DataSelector"; - -import type { TableId } from "metabase-types/api"; - -interface Props { - tableId: TableId | null; - onTableChange: (tableId: TableId | null) => void; -} - -function DataAppDataPicker({ tableId, onTableChange }: Props) { - return ( - <DatabaseSchemaAndTableDataSelector - selectedTableId={tableId} - setSourceTableFn={onTableChange} - requireWriteback - isPopover={false} - /> - ); -} - -export default DataAppDataPicker; diff --git a/frontend/src/metabase/writeback/components/DataAppDataPicker/index.ts b/frontend/src/metabase/writeback/components/DataAppDataPicker/index.ts deleted file mode 100644 index a370f39fa3e..00000000000 --- a/frontend/src/metabase/writeback/components/DataAppDataPicker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./DataAppDataPicker"; diff --git a/frontend/src/metabase/writeback/components/DataAppScaffoldingDataPicker/DataAppScaffoldingDataPicker.tsx b/frontend/src/metabase/writeback/components/DataAppScaffoldingDataPicker/DataAppScaffoldingDataPicker.tsx new file mode 100644 index 00000000000..8ffcf28eb82 --- /dev/null +++ b/frontend/src/metabase/writeback/components/DataAppScaffoldingDataPicker/DataAppScaffoldingDataPicker.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import DataPicker, { + DataPickerProps, + DataPickerFiltersProp, +} from "metabase/containers/DataPicker"; + +const FILTERS: DataPickerFiltersProp = { + types: type => type !== "questions", +}; + +type DataAppScaffoldingDataPickerProps = Omit<DataPickerProps, "filters">; + +function DataAppScaffoldingDataPicker( + props: DataAppScaffoldingDataPickerProps, +) { + return <DataPicker {...props} filters={FILTERS} />; +} + +export default DataAppScaffoldingDataPicker; diff --git a/frontend/src/metabase/writeback/components/DataAppScaffoldingDataPicker/index.ts b/frontend/src/metabase/writeback/components/DataAppScaffoldingDataPicker/index.ts new file mode 100644 index 00000000000..abc22da165d --- /dev/null +++ b/frontend/src/metabase/writeback/components/DataAppScaffoldingDataPicker/index.ts @@ -0,0 +1 @@ +export { default } from "./DataAppScaffoldingDataPicker"; diff --git a/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.styled.tsx b/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.styled.tsx index 9e164b52f52..ebc28e0e8c5 100644 --- a/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.styled.tsx +++ b/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.styled.tsx @@ -1,5 +1,9 @@ import styled from "@emotion/styled"; + +import Icon from "metabase/components/Icon"; + import { color } from "metabase/lib/colors"; +import { space } from "metabase/styled-components/theme"; export const ModalRoot = styled.div` display: flex; @@ -11,6 +15,7 @@ export const ModalRoot = styled.div` export const ModalHeader = styled.div` display: flex; align-items: center; + justify-content: space-between; padding: 1.5rem 2rem; `; @@ -26,7 +31,7 @@ export const ModalBody = styled.div` display: flex; flex: 1; - padding: 0 1rem 1rem 1rem; + padding: 0 1rem 0 1rem; overflow-y: scroll; `; @@ -40,3 +45,40 @@ export const ModalFooter = styled.div` border-top: 1px solid ${color("border")}; `; + +export const SearchInputContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + position: relative; + + max-width: 14rem; + padding: 12px 14px; + + border: 1px solid ${color("border")}; + border-radius: 8px; +`; + +export const SearchIcon = styled(Icon)` + color: ${color("text-light")}; +`; + +export const SearchInput = styled.input` + background-color: transparent; + border: none; + + color: ${color("text-medium")}; + font-weight: 700; + + width: 100%; + margin-left: 8px; + + &:focus { + outline: none; + } + + &::placeholder { + color: ${color("text-light")}; + font-weight: 700; + } +`; diff --git a/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.tsx b/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.tsx index f2e452e9409..18bf48a2ce4 100644 --- a/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.tsx +++ b/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback } from "react"; import { t } from "ttag"; import { connect } from "react-redux"; import { push } from "react-router-redux"; @@ -10,9 +10,14 @@ import * as Urls from "metabase/lib/urls"; import DataApps, { ScaffoldNewAppParams } from "metabase/entities/data-apps"; -import DataAppDataPicker from "metabase/writeback/components/DataAppDataPicker"; +import DataPicker, { + useDataPicker, + useDataPickerValue, + DataPickerValue, +} from "metabase/containers/DataPicker"; +import DataAppScaffoldingDataPicker from "metabase/writeback/components/DataAppScaffoldingDataPicker"; -import type { DataApp, TableId } from "metabase-types/api"; +import type { DataApp } from "metabase-types/api"; import type { Dispatch, State } from "metabase-types/store"; import { @@ -21,6 +26,9 @@ import { ModalTitle, ModalBody, ModalFooter, + SearchInputContainer, + SearchInput, + SearchIcon, } from "./CreateDataAppModal.styled"; interface OwnProps { @@ -47,35 +55,67 @@ function mapDispatchToProps(dispatch: Dispatch) { }; } +function getSearchInputPlaceholder(value: DataPickerValue) { + if (value?.type === "models") { + return t`Search for a model…`; + } + if (value?.type === "raw-data") { + return t`Search for a table…`; + } + return t`Search for some data…`; +} + +function DataPickerSearchInput({ value }: { value: DataPickerValue }) { + const { search } = useDataPicker(); + + return ( + <SearchInputContainer> + <SearchIcon name="search" size={16} /> + <SearchInput + value={search.query} + onChange={e => search.setQuery(e.target.value)} + placeholder={getSearchInputPlaceholder(value)} + /> + </SearchInputContainer> + ); +} + function CreateDataAppModal({ onCreate, onChangeLocation, onClose }: Props) { - const [tableId, setTableId] = useState<TableId | null>(null); + const [value, setValue] = useDataPickerValue(); + + const { tableIds } = value; const handleCreate = useCallback(async () => { const dataApp = await onCreate({ name: t`New App`, - tables: [tableId] as number[], + tables: tableIds as number[], }); onClose(); onChangeLocation(Urls.dataApp(dataApp)); - }, [tableId, onCreate, onChangeLocation, onClose]); + }, [tableIds, onCreate, onChangeLocation, onClose]); + + const canSubmit = tableIds.length > 0; return ( - <ModalRoot> - <ModalHeader> - <ModalTitle>{t`Pick your starting data`}</ModalTitle> - </ModalHeader> - <ModalBody> - <DataAppDataPicker tableId={tableId} onTableChange={setTableId} /> - </ModalBody> - <ModalFooter> - <Button onClick={onClose}>{t`Cancel`}</Button> - <Button - primary - disabled={tableId == null} - onClick={handleCreate} - >{t`Create`}</Button> - </ModalFooter> - </ModalRoot> + <DataPicker.Provider> + <ModalRoot> + <ModalHeader> + <ModalTitle>{t`Pick your starting data`}</ModalTitle> + <DataPickerSearchInput value={value} /> + </ModalHeader> + <ModalBody> + <DataAppScaffoldingDataPicker value={value} onChange={setValue} /> + </ModalBody> + <ModalFooter> + <Button onClick={onClose}>{t`Cancel`}</Button> + <Button + primary + disabled={!canSubmit} + onClick={handleCreate} + >{t`Create`}</Button> + </ModalFooter> + </ModalRoot> + </DataPicker.Provider> ); } diff --git a/frontend/src/metabase/writeback/containers/ScaffoldDataAppPagesModal/ScaffoldDataAppPagesModal.styled.tsx b/frontend/src/metabase/writeback/containers/ScaffoldDataAppPagesModal/ScaffoldDataAppPagesModal.styled.tsx index 9e164b52f52..8e510742d8e 100644 --- a/frontend/src/metabase/writeback/containers/ScaffoldDataAppPagesModal/ScaffoldDataAppPagesModal.styled.tsx +++ b/frontend/src/metabase/writeback/containers/ScaffoldDataAppPagesModal/ScaffoldDataAppPagesModal.styled.tsx @@ -26,7 +26,7 @@ export const ModalBody = styled.div` display: flex; flex: 1; - padding: 0 1rem 1rem 1rem; + padding: 0 1rem 0 1rem; overflow-y: scroll; `; diff --git a/frontend/src/metabase/writeback/containers/ScaffoldDataAppPagesModal/ScaffoldDataAppPagesModal.tsx b/frontend/src/metabase/writeback/containers/ScaffoldDataAppPagesModal/ScaffoldDataAppPagesModal.tsx index 8fe75ab0d5b..139b0dfbb33 100644 --- a/frontend/src/metabase/writeback/containers/ScaffoldDataAppPagesModal/ScaffoldDataAppPagesModal.tsx +++ b/frontend/src/metabase/writeback/containers/ScaffoldDataAppPagesModal/ScaffoldDataAppPagesModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback } from "react"; import { t } from "ttag"; import { connect } from "react-redux"; @@ -6,9 +6,10 @@ import Button from "metabase/core/components/Button"; import DataApps, { ScaffoldNewPagesParams } from "metabase/entities/data-apps"; -import DataAppDataPicker from "metabase/writeback/components/DataAppDataPicker"; +import { useDataPickerValue } from "metabase/containers/DataPicker"; +import DataAppScaffoldingDataPicker from "metabase/writeback/components/DataAppScaffoldingDataPicker"; -import type { DataApp, TableId } from "metabase-types/api"; +import type { DataApp } from "metabase-types/api"; import type { Dispatch, State } from "metabase-types/store"; import { @@ -48,16 +49,19 @@ function ScaffoldDataAppPagesModal({ onScaffold, onClose, }: Props) { - const [tableId, setTableId] = useState<TableId | null>(null); + const [value, setValue] = useDataPickerValue(); + const { tableIds } = value; const handleAdd = useCallback(async () => { const dataApp = await onScaffold({ dataAppId, - tables: [tableId] as number[], + tables: tableIds as number[], }); onClose(); onAdd(dataApp); - }, [dataAppId, tableId, onAdd, onScaffold, onClose]); + }, [dataAppId, tableIds, onAdd, onScaffold, onClose]); + + const canSubmit = tableIds.length > 0; return ( <ModalRoot> @@ -65,13 +69,13 @@ function ScaffoldDataAppPagesModal({ <ModalTitle>{t`Pick your data`}</ModalTitle> </ModalHeader> <ModalBody> - <DataAppDataPicker tableId={tableId} onTableChange={setTableId} /> + <DataAppScaffoldingDataPicker value={value} onChange={setValue} /> </ModalBody> <ModalFooter> <Button onClick={onClose}>{t`Cancel`}</Button> <Button primary - disabled={tableId == null} + disabled={!canSubmit} onClick={handleAdd} >{t`Add`}</Button> </ModalFooter> -- GitLab