Skip to content
Snippets Groups Projects
Unverified Commit a0d9e138 authored by Anton Kulyk's avatar Anton Kulyk Committed by GitHub
Browse files

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: default avatarAnton 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: default avatarKyle 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: default avatarKyle Doherty <5248953+kdoh@users.noreply.github.com>
parent d39dce81
No related merge requests found
Showing
with 733 additions and 25 deletions
export { default } from "./DataTypePicker";
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;
`;
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;
export { default } from "./PanePicker";
import styled from "@emotion/styled";
import SelectList from "metabase/components/SelectList";
export const StyledSelectList = styled(SelectList)`
width: 100%;
padding: 0 1rem;
`;
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,
);
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;
export { default } from "./RawDataPickerContainer";
export const MIN_SEARCH_LENGTH = 2;
export { default } from "./DataPickerContainer";
export { useDataPicker } from "./DataPickerContext";
export { default as useDataPickerValue } from "./useDataPickerValue";
export type {
DataPickerDataType,
DataPickerValue,
DataPickerProps,
DataPickerFiltersProp,
} from "./types";
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;
};
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;
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;
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,
};
......@@ -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,
......
......@@ -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"]),
),
};
......
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;
export { default } from "./DataAppDataPicker";
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;
export { default } from "./DataAppScaffoldingDataPicker";
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment