diff --git a/frontend/src/metabase/App.jsx b/frontend/src/metabase/App.jsx index 3f3acbc8380998e76fb1a859913036cf239caabf..0ea04a41a38d9ddd404c4e844a799d7d2a3592c2 100644 --- a/frontend/src/metabase/App.jsx +++ b/frontend/src/metabase/App.jsx @@ -44,7 +44,10 @@ const getErrorComponent = ({ status, data, context }) => { } }; -const PATHS_WITHOUT_NAVBAR = [/\/dataset\/.*\/query/]; +const PATHS_WITHOUT_NAVBAR = [ + /\/dataset\/.*\/query/, + /\/dataset\/.*\/metadata/, +]; @connect(mapStateToProps) export default class App extends Component { diff --git a/frontend/src/metabase/components/EditBar.jsx b/frontend/src/metabase/components/EditBar.jsx index 32cb7fe1df89358d294e85e6727aca7b3dc08202..dabd95bf85d88ea0e84f66bd2a3247965a574eaa 100644 --- a/frontend/src/metabase/components/EditBar.jsx +++ b/frontend/src/metabase/components/EditBar.jsx @@ -6,9 +6,11 @@ class EditBar extends Component { static propTypes = { title: PropTypes.string.isRequired, subtitle: PropTypes.string, + center: PropTypes.node, buttons: PropTypes.oneOfType([PropTypes.element, PropTypes.array]) .isRequired, admin: PropTypes.bool, + className: PropTypes.string, }; static defaultProps = { @@ -16,18 +18,27 @@ class EditBar extends Component { }; render() { - const { admin, buttons, subtitle, title } = this.props; + const { admin, buttons, subtitle, title, center, className } = this.props; return ( <div - className={cx("EditHeader wrapper py1 flex align-center", { - "EditHeader--admin": admin, - })} - > - <span className="EditHeader-title">{title}</span> - {subtitle && ( - <span className="EditHeader-subtitle mx1">{subtitle}</span> + className={cx( + "EditHeader wrapper py1 flex align-center justify-between", + { + "EditHeader--admin": admin, + }, + className, )} - <span className="flex-align-right flex">{buttons}</span> + > + <div> + <span className="EditHeader-title">{title}</span> + {subtitle && ( + <span className="EditHeader-subtitle mx1">{subtitle}</span> + )} + </div> + {center && <div>{center}</div>} + <div> + <span className="flex">{buttons}</span> + </div> </div> ); } diff --git a/frontend/src/metabase/components/Icon.tsx b/frontend/src/metabase/components/Icon.tsx index 0f3c37227cd75c341e45c4945b08f8d7da0a44e9..29c99e4586ffc195a592389697b213e44125024b 100644 --- a/frontend/src/metabase/components/Icon.tsx +++ b/frontend/src/metabase/components/Icon.tsx @@ -57,11 +57,12 @@ export const iconPropTypes = { scale: stringOrNumberPropType, tooltip: PropTypes.string, className: PropTypes.string, - innerRef: PropTypes.any, onClick: PropTypes.func, }; -type IconProps = PropTypes.InferProps<typeof iconPropTypes>; +type IconProps = PropTypes.InferProps<typeof iconPropTypes> & { + innerRef?: () => void; +}; class BaseIcon extends Component<IconProps> { static propTypes = iconPropTypes; diff --git a/frontend/src/metabase/icon_paths.ts b/frontend/src/metabase/icon_paths.ts index 109f9d7997f70c0b70323a3b44e32771907f63b7..f86d54b117ebb830f8c79d0e4a53f9e191e7f768 100644 --- a/frontend/src/metabase/icon_paths.ts +++ b/frontend/src/metabase/icon_paths.ts @@ -467,6 +467,14 @@ export const ICON_PATHS: Record<string, any> = { "M1.6 0h28.8A1.6 1.6 0 0 1 32 1.6v28.8a1.6 1.6 0 0 1-1.6 1.6H1.6A1.6 1.6 0 0 1 0 30.4V1.6A1.6 1.6 0 0 1 1.6 0zm1.6 3.2v6.4h6.4V3.2H3.2zm9.6 0v6.4h16V3.2h-16zm-9.6 9.6v6.4h6.4v-6.4H3.2zm9.6 0v6.4h16v-6.4h-16zm-9.6 9.6v6.4h6.4v-6.4H3.2zm9.6 0v6.4h16v-6.4h-16z", table_spaced: "M0 0h7.784v7.784H0V0zm12.108 0h7.784v7.784h-7.784V0zm12.108 0H32v7.784h-7.784V0zM0 12.108h7.784v7.784H0v-7.784zm12.108 0h7.784v7.784h-7.784v-7.784zm12.108 0H32v7.784h-7.784v-7.784zM0 24.216h7.784V32H0v-7.784zm12.108 0h7.784V32h-7.784v-7.784zm12.108 0H32V32h-7.784v-7.784z", + three_dots: { + path: + "M3.35484 9.35484C4.1031 9.35484 4.70968 8.74826 4.70968 8C4.70968 7.25175 4.1031 6.64516 3.35484 6.64516C2.60658 6.64516 2 7.25175 2 8C2 8.74826 2.60658 9.35484 3.35484 9.35484ZM12.6452 9.35484C13.3934 9.35484 14 8.74826 14 8C14 7.25175 13.3934 6.64516 12.6452 6.64516C11.8969 6.64516 11.2903 7.25175 11.2903 8C11.2903 8.74826 11.8969 9.35484 12.6452 9.35484ZM9.35484 8C9.35484 8.74826 8.74826 9.35484 8 9.35484C7.25174 9.35484 6.64516 8.74826 6.64516 8C6.64516 7.25175 7.25174 6.64516 8 6.64516C8.74826 6.64516 9.35484 7.25175 9.35484 8Z M0 2C0 0.895431 0.895431 0 2 0H14C15.1046 0 16 0.895431 16 2V14C16 15.1046 15.1046 16 14 16H2C0.895431 16 0 15.1046 0 14V2Z", + attrs: { + fillRule: "evenodd", + viewBox: "0 0 16 16", + }, + }, trash: "M4.31904507,29.7285487 C4.45843264,30.9830366 5.59537721,32 6.85726914,32 L20.5713023,32 C21.8337371,32 22.9701016,30.9833707 23.1095264,29.7285487 L25.1428571,11.4285714 L2.28571429,11.4285714 L4.31904507,29.7285487 L4.31904507,29.7285487 Z M6.85714286,4.57142857 L8.57142857,0 L18.8571429,0 L20.5714286,4.57142857 L25.1428571,4.57142857 C27.4285714,4.57142857 27.4285714,9.14285714 27.4285714,9.14285714 L13.7142857,9.14285714 L-1.0658141e-14,9.14285714 C-1.0658141e-14,9.14285714 -1.0658141e-14,4.57142857 2.28571429,4.57142857 L6.85714286,4.57142857 L6.85714286,4.57142857 Z M9.14285714,4.57142857 L18.2857143,4.57142857 L17.1428571,2.28571429 L10.2857143,2.28571429 L9.14285714,4.57142857 L9.14285714,4.57142857 Z", triangle_left: "M21,0 L5,16 L21,32 L21,5.47117907e-13 L21,0 Z", diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index 52c246351c3aeaafdf2b333cdcd688e6f2732a32..812d5dfd852f2d474975ff06b64d6cc0ac96a21e 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -52,6 +52,7 @@ import { getIsPreviewing, getTableForeignKeys, getQueryBuilderMode, + getDatasetEditorTab, getIsShowingTemplateTagsEditor, getIsRunning, getNativeEditorCursorOffset, @@ -96,16 +97,17 @@ export const resetUIControls = createAction(RESET_UI_CONTROLS); export const setQueryBuilderMode = ( queryBuilderMode, - { shouldUpdateUrl = true } = {}, + { shouldUpdateUrl = true, datasetEditorTab = "query" } = {}, ) => async dispatch => { await dispatch( setUIControls({ queryBuilderMode, + datasetEditorTab, isShowingChartSettingsSidebar: false, }), ); if (shouldUpdateUrl) { - await dispatch(updateUrl(null, { queryBuilderMode })); + await dispatch(updateUrl(null, { queryBuilderMode, datasetEditorTab })); } if (queryBuilderMode === "notebook") { dispatch(cancelQuery()); @@ -155,10 +157,16 @@ export const popState = createThunkAction( await dispatch(setCurrentState(location.state)); } } - const queryBuilderModeFromURL = getQueryBuilderModeFromLocation(location); + + const { + mode: queryBuilderModeFromURL, + ...uiControls + } = getQueryBuilderModeFromLocation(location); + if (getQueryBuilderMode(getState()) !== queryBuilderModeFromURL) { await dispatch( setQueryBuilderMode(queryBuilderModeFromURL, { + ...uiControls, shouldUpdateUrl: queryBuilderModeFromURL === "dataset", }), ); @@ -226,7 +234,13 @@ export const updateUrl = createThunkAction( UPDATE_URL, ( card, - { dirty, replaceState, preserveParameters = true, queryBuilderMode } = {}, + { + dirty, + replaceState, + preserveParameters = true, + queryBuilderMode, + datasetEditorTab, + } = {}, ) => (dispatch, getState) => { let question; if (!card) { @@ -252,6 +266,9 @@ export const updateUrl = createThunkAction( if (!queryBuilderMode) { queryBuilderMode = getQueryBuilderMode(getState()); } + if (!datasetEditorTab) { + datasetEditorTab = getDatasetEditorTab(getState()); + } const copy = cleanCopyCard(card); @@ -269,6 +286,7 @@ export const updateUrl = createThunkAction( pathname: getPathNameFromQueryBuilderMode({ pathname: urlParsed.pathname || "", queryBuilderMode, + datasetEditorTab, }), search: preserveParameters ? window.location.search : "", hash: urlParsed.hash, @@ -282,8 +300,8 @@ export const updateUrl = createThunkAction( const isSameCard = currentState && currentState.serializedCard === newState.serializedCard; const isSameMode = - getQueryBuilderModeFromLocation(locationDescriptor) === - getQueryBuilderModeFromLocation(window.location); + getQueryBuilderModeFromLocation(locationDescriptor).mode === + getQueryBuilderModeFromLocation(window.location).mode; if (isSameCard && isSameURL) { return; @@ -343,10 +361,16 @@ export const initializeQB = (location, params, queryParams) => { const cardId = Urls.extractEntityId(params.slug); let card, originalCard; + + const { + mode: queryBuilderMode, + ...otherUiControls + } = getQueryBuilderModeFromLocation(location); const uiControls = { isEditing: false, isShowingTemplateTagsEditor: false, - queryBuilderMode: getQueryBuilderModeFromLocation(location), + queryBuilderMode, + ...otherUiControls, }; // load up or initialize the card we'll be working on @@ -1508,6 +1532,10 @@ export const revertToRevision = createThunkAction( }, ); +export const setDatasetEditorTab = datasetEditorTab => dispatch => { + dispatch(setQueryBuilderMode("dataset", { datasetEditorTab })); +}; + export const CANCEL_DATASET_CHANGES = "metabase/qb/CANCEL_DATASET_CHANGES"; export const onCancelDatasetChanges = () => (dispatch, getState) => { const cardBeforeChanges = getOriginalCard(getState()); diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx index 69e2d96dcebcd3ef3d75ebc08f1d2eb949a8cd47..e3e468dd74abeaf16a331430fc90832927493be7 100644 --- a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx @@ -1,13 +1,14 @@ -import React from "react"; +import React, { useEffect, useCallback, useMemo, useState } from "react"; import PropTypes from "prop-types"; +import { connect } from "react-redux"; import { t } from "ttag"; +import _ from "underscore"; import ActionButton from "metabase/components/ActionButton"; import Button from "metabase/core/components/Button"; import DebouncedFrame from "metabase/components/DebouncedFrame"; -import EditBar from "metabase/components/EditBar"; +import Icon from "metabase/components/Icon"; -import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor"; import QueryVisualization from "metabase/query_builder/components/QueryVisualization"; import ViewSidebar from "metabase/query_builder/components/view/ViewSidebar"; @@ -15,18 +16,30 @@ import DataReference from "metabase/query_builder/components/dataref/DataReferen import TagEditorSidebar from "metabase/query_builder/components/template_tags/TagEditorSidebar"; import SnippetSidebar from "metabase/query_builder/components/template_tags/SnippetSidebar"; -import ResizableNotebook from "./ResizableNotebook"; +import { setDatasetEditorTab } from "metabase/query_builder/actions"; +import { getDatasetEditorTab } from "metabase/query_builder/selectors"; + +import { isSameField } from "metabase/lib/query/field_ref"; + +import DatasetFieldMetadataSidebar from "./DatasetFieldMetadataSidebar"; +import DatasetQueryEditor from "./DatasetQueryEditor"; +import EditorTabs from "./EditorTabs"; + import { Root, + DatasetEditBar, MainContainer, QueryEditorContainer, + TableHeaderColumnName, TableContainer, } from "./DatasetEditor.styled"; const propTypes = { question: PropTypes.object.isRequired, + datasetEditorTab: PropTypes.oneOf(["query", "metadata"]).isRequired, height: PropTypes.number, setQueryBuilderMode: PropTypes.func.isRequired, + setDatasetEditorTab: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired, onCancelDatasetChanges: PropTypes.func.isRequired, handleResize: PropTypes.func.isRequired, @@ -41,8 +54,17 @@ const propTypes = { }; const INITIAL_NOTEBOOK_EDITOR_HEIGHT = 500; +const TABLE_HEADER_HEIGHT = 45; + +function mapStateToProps(state) { + return { + datasetEditorTab: getDatasetEditorTab(state), + }; +} -function getSidebar(props) { +const mapDispatchToProps = { setDatasetEditorTab }; + +function getSidebar(props, { datasetEditorTab, focusedField }) { const { question: dataset, isShowingTemplateTagsEditor, @@ -52,9 +74,15 @@ function getSidebar(props) { toggleDataReference, toggleSnippetSidebar, } = props; + + if (datasetEditorTab === "metadata") { + return <DatasetFieldMetadataSidebar field={focusedField} />; + } + if (!dataset.isNative()) { return null; } + if (isShowingTemplateTagsEditor) { return <TagEditorSidebar {...props} onClose={toggleTemplateTagsEditor} />; } @@ -64,39 +92,118 @@ function getSidebar(props) { if (isShowingSnippetSidebar) { return <SnippetSidebar {...props} onClose={toggleSnippetSidebar} />; } + return null; } function DatasetEditor(props) { const { question: dataset, + datasetEditorTab, height, setQueryBuilderMode, + setDatasetEditorTab, onCancelDatasetChanges, + onSave, handleResize, } = props; - const onCancel = () => { + const isEditingQuery = datasetEditorTab === "query"; + const isEditingMetadata = datasetEditorTab === "metadata"; + + const [editorHeight, setEditorHeight] = useState( + isEditingQuery ? INITIAL_NOTEBOOK_EDITOR_HEIGHT : 0, + ); + + const [focusedField, setFocusedField] = useState(); + + useEffect(() => { + const resultMetadata = dataset.getResultMetadata(); + if (!focusedField && resultMetadata?.length > 0) { + setFocusedField(resultMetadata[0]); + } + }, [dataset, focusedField]); + + const onChangeEditorTab = useCallback( + tab => { + setDatasetEditorTab(tab); + setEditorHeight(tab === "query" ? INITIAL_NOTEBOOK_EDITOR_HEIGHT : 0); + }, + [setDatasetEditorTab], + ); + + const handleCancel = useCallback(() => { onCancelDatasetChanges(); setQueryBuilderMode("view"); - }; + }, [setQueryBuilderMode, onCancelDatasetChanges]); - const onSave = async () => { - await props.onSave(dataset.card()); + const handleSave = useCallback(async () => { + await onSave(dataset.card()); setQueryBuilderMode("view"); - }; + }, [dataset, onSave, setQueryBuilderMode]); + + const sidebar = getSidebar(props, { datasetEditorTab, focusedField }); + + const handleTableElementClick = useCallback( + ({ element, ...clickedObject }) => { + const isColumnClick = + clickedObject?.column && Object.keys(clickedObject)?.length === 1; + + if (isColumnClick) { + const field = dataset + .getResultMetadata() + .find(f => + isSameField(clickedObject.column?.field_ref, f?.field_ref), + ); + setFocusedField(field); + } + }, + [dataset], + ); + + const renderSelectableTableColumnHeader = useCallback( + (element, column) => ( + <TableHeaderColumnName + isSelected={isSameField(column?.field_ref, focusedField?.field_ref)} + > + <Icon name="three_dots" size={14} /> + <span>{column.display_name}</span> + </TableHeaderColumnName> + ), + [focusedField], + ); - const sidebar = getSidebar(props); + const renderTableHeaderWrapper = useMemo( + () => + datasetEditorTab === "metadata" + ? renderSelectableTableColumnHeader + : undefined, + [datasetEditorTab, renderSelectableTableColumnHeader], + ); return ( <React.Fragment> - <EditBar + <DatasetEditBar title={t`You're editing ${dataset.displayName()}`} + center={ + <EditorTabs + currentTab={datasetEditorTab} + onChange={onChangeEditorTab} + options={[ + { id: "query", name: t`Query`, icon: "notebook" }, + { id: "metadata", name: t`Metadata`, icon: "label" }, + ]} + /> + } buttons={[ - <Button key="cancel" onClick={onCancel} small>{t`Cancel`}</Button>, + <Button + key="cancel" + onClick={handleCancel} + small + >{t`Cancel`}</Button>, <ActionButton key="save" - actionFn={onSave} + actionFn={handleSave} normalText={t`Save changes`} activeText={t`Saving…`} failedText={t`Save failed`} @@ -107,34 +214,25 @@ function DatasetEditor(props) { /> <Root> <MainContainer> - <QueryEditorContainer> - {dataset.isNative() ? ( - <NativeQueryEditor - {...props} - isInitiallyOpen - viewHeight={height} - hasParametersList={false} - // We need to rerun the query after saving changes or canceling edits - // By default, NativeQueryEditor cancels an active query on unmount, - // which can also cancel the expected query rerun - // (see https://github.com/metabase/metabase/issues/19180) - cancelQueryOnLeave={false} - /> - ) : ( - <ResizableNotebook - {...props} - height={INITIAL_NOTEBOOK_EDITOR_HEIGHT} - onResizeStop={handleResize} - /> - )} + <QueryEditorContainer isResizable={isEditingQuery}> + <DatasetQueryEditor + {...props} + isActive={isEditingQuery} + height={editorHeight} + viewHeight={height} + onResizeStop={handleResize} + /> </QueryEditorContainer> <TableContainer isSidebarOpen={!!sidebar}> - <DebouncedFrame className="flex-full" enabled={false}> + <DebouncedFrame className="flex-full" enabled> <QueryVisualization {...props} className="spread" noHeader - isVisualizationClickable={false} + queryBuilderMode="dataset" + handleVisualizationClick={handleTableElementClick} + tableHeaderHeight={isEditingMetadata && TABLE_HEADER_HEIGHT} + renderTableHeaderWrapper={renderTableHeaderWrapper} /> </DebouncedFrame> </TableContainer> @@ -149,4 +247,4 @@ function DatasetEditor(props) { DatasetEditor.propTypes = propTypes; -export default DatasetEditor; +export default connect(mapStateToProps, mapDispatchToProps)(DatasetEditor); diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.styled.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.styled.jsx index 4a5021adbb591b1262d60c747930f7b9266bd202..d65d7ee488354cf55e2feb91ee52829eadabadf3 100644 --- a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.styled.jsx +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.styled.jsx @@ -1,6 +1,44 @@ import styled, { css } from "styled-components"; +import EditBar from "metabase/components/EditBar"; import { color } from "metabase/lib/colors"; -import { breakpointMinSmall } from "metabase/styled-components/theme"; +import { breakpointMinSmall, space } from "metabase/styled-components/theme"; + +export const DatasetEditBar = styled(EditBar)` + background-color: ${color("nav")}; +`; + +export const TableHeaderColumnName = styled.div` + display: flex; + flex-direction: row; + align-items: center; + min-width: 35px; + + margin: 24px 0.75em; + padding: ${space(0)} ${space(1)}; + + white-space: nowrap; + text-overflow: ellipsis; + overflow-x: hidden; + + color: ${color("brand")}; + background-color: transparent; + font-weight: bold; + cursor: pointer; + + border: 1px solid ${color("brand")}; + border-radius: 8px; + + ${props => + props.isSelected && + css` + color: ${color("text-white")}; + background-color: ${color("brand")}; + `} + + .Icon { + margin-right: 4px; + } +`; // Mirrors styling of some QB View div elements @@ -19,10 +57,15 @@ export const MainContainer = styled.div` `; export const QueryEditorContainer = styled.div` - margin-bottom: 1rem; - border-bottom: 1px solid ${color("border")}; z-index: 2; width: 100%; + + ${props => + props.isResizable && + css` + margin-bottom: 1rem; + border-bottom: 1px solid ${color("border")}; + `} `; const tableVisibilityStyle = css` diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..707bf7dbf268adec6db788e0b50073835a49a412 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.jsx @@ -0,0 +1,130 @@ +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; + +import Field from "metabase-lib/lib/metadata/Field"; +import { + field_visibility_types, + field_semantic_types, + has_field_values_options, +} from "metabase/lib/core"; + +import RootForm from "metabase/containers/Form"; + +import SidebarContent from "metabase/query_builder/components/SidebarContent"; + +import { PaddedContent, Divider } from "./DatasetFieldMetadataSidebar.styled"; + +const propTypes = { + field: PropTypes.instanceOf(Field).isRequired, +}; + +function getVisibilityTypeName(visibilityType) { + if (visibilityType.id === "normal") { + return t`Table and details views`; + } + if (visibilityType.id === "details-only") { + return t`Detail views only`; + } + return visibilityType.name; +} + +function getFieldSemanticTypes() { + return [ + ...field_semantic_types, + { + id: null, + name: t`No special type`, + section: t`Other`, + }, + ]; +} + +const FORM_FIELDS = [ + { name: "display_name", title: t`Display name` }, + { + name: "description", + title: t`Description`, + placeholder: t`It’s optional, but oh, so helpful`, + type: "text", + }, + { + name: "semantic_type", + type: "select", + options: getFieldSemanticTypes().map(type => ({ + name: type.name, + value: type.id, + })), + }, + { + name: "visibility_type", + title: t`This column should appear in…`, + type: "radio", + options: field_visibility_types + .filter(type => type.id !== "sensitive") + .map(type => ({ + name: getVisibilityTypeName(type), + value: type.id, + })), + }, + { + name: "display_as", + title: t`Display as`, + type: "radio", + options: [ + { name: t`Text`, value: "text" }, + { name: t`Link`, value: "link" }, + ], + }, + { + name: "has_field_values", + title: t`Filtering on this field`, + info: t`When this field is used in a filter, what should people use to enter the value they want to filter on?`, + type: "select", + options: has_field_values_options, + }, +]; + +function DatasetFieldMetadataSidebar({ field }) { + const initialValues = useMemo( + () => ({ + display_name: field?.display_name, + description: field?.description, + semantic_type: field?.semantic_type, + visibility_type: "normal", + display_as: "text", + has_field_values: "search", + }), + [field], + ); + + return ( + <SidebarContent> + <PaddedContent> + {field && ( + <RootForm + fields={FORM_FIELDS} + initialValues={initialValues} + overwriteOnInitialValuesChange + > + {({ Form, FormField }) => ( + <Form> + <FormField name="display_name" /> + <FormField name="description" /> + <FormField name="semantic_type" /> + <Divider /> + <FormField name="visibility_type" /> + <FormField name="display_as" /> + <FormField name="has_field_values" /> + </Form> + )} + </RootForm> + )} + </PaddedContent> + </SidebarContent> + ); +} + +DatasetFieldMetadataSidebar.propTypes = propTypes; + +export default DatasetFieldMetadataSidebar; diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.styled.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.styled.jsx new file mode 100644 index 0000000000000000000000000000000000000000..151adfd1d08b80ea93fbdb76278012d280c50424 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.styled.jsx @@ -0,0 +1,13 @@ +import styled from "styled-components"; +import { color } from "metabase/lib/colors"; + +export const PaddedContent = styled.div` + padding: 24px; +`; + +export const Divider = styled.div` + height: 1px; + width: 100%; + background-color: ${color("bg-medium")}; + margin-bottom: 21px; +`; diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/index.js b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/index.js new file mode 100644 index 0000000000000000000000000000000000000000..951653c81b96ba660ac5cd96eac16a926c21af19 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/index.js @@ -0,0 +1 @@ +export { default } from "./DatasetFieldMetadataSidebar"; diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetQueryEditor.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetQueryEditor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ebfcbc0461433f589b3b46f7a9289a78f029232d --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetQueryEditor.jsx @@ -0,0 +1,81 @@ +import React, { useMemo, useState } from "react"; +import PropTypes from "prop-types"; +import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor"; +import { isReducedMotionPreferred } from "metabase/lib/dom"; +import ResizableNotebook from "./ResizableNotebook"; + +const SMOOTH_RESIZE_STYLE = { transition: "height 0.25s" }; + +const propTypes = { + question: PropTypes.object.isRequired, + isActive: PropTypes.bool.isRequired, // if QB mode is set to "query" + height: PropTypes.number.isRequired, +}; + +function DatasetQueryEditor({ question: dataset, isActive, height, ...props }) { + const [isResizing, setResizing] = useState(false); + + const resizableBoxProps = useMemo(() => { + // Disables resizing by removing a handle in "metadata" mode + const resizeHandles = isActive ? ["s"] : []; + + // The editor can change its size in two cases: + // 1. By manually resizing the window with a handle + // 2. Automatically when editor mode is changed between "query" and "metadata" + // For the 2nd case, we're smoothing the resize effect by adding a `transition` style + // For the 1st case, we need to make sure it's not included, so resizing doesn't lag + const style = + isResizing || isReducedMotionPreferred() + ? undefined + : SMOOTH_RESIZE_STYLE; + + const resizableBoxProps = { + height, + resizeHandles, + onResizeStart: () => setResizing(true), + onResizeStop: () => setResizing(false), + style, + }; + + if (!isActive) { + // Overwrites native query editor's resizable area constraints, + // so the automatic "close" animation doesn't get stuck + resizableBoxProps.minConstraints = [0, 0]; + } + + return resizableBoxProps; + }, [height, isResizing, isActive]); + + return dataset.isNative() ? ( + <NativeQueryEditor + {...props} + question={dataset} + isInitiallyOpen + hasTopBar={isActive} + hasEditingSidebar={isActive} + hasParametersList={false} + resizableBoxProps={resizableBoxProps} + // We need to rerun the query after saving changes or canceling edits + // By default, NativeQueryEditor cancels an active query on unmount, + // which can also cancel the expected query rerun + // (see https://github.com/metabase/metabase/issues/19180) + cancelQueryOnLeave={false} + /> + ) : ( + <ResizableNotebook + {...props} + question={dataset} + isResizing={isResizing} + resizableBoxProps={resizableBoxProps} + /> + ); +} + +DatasetQueryEditor.propTypes = propTypes; + +export default React.memo( + DatasetQueryEditor, + // should prevent the editor from re-rendering in "metadata" mode + // when it's completely covered with the results table + (prevProps, nextProps) => prevProps.height === 0 && nextProps.height === 0, +); diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.styled.tsx b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..203261b1bfdb7da7608598adea259ab2c28ccf12 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.styled.tsx @@ -0,0 +1,68 @@ +import styled, { css } from "styled-components"; +import { alpha, darken, color } from "metabase/lib/colors"; + +export const TabBar = styled.ul` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 16px; +`; + +function getActiveTabColor() { + return darken("nav"); +} + +function getInactiveTabColor() { + const active = getActiveTabColor(); + return alpha(active, 0.3); +} + +const inactiveTabCSS = css` + border-color: ${getInactiveTabColor()}; + + :hover { + background-color: ${getInactiveTabColor()}; + } +`; + +const activeTabCSS = css` + background-color: ${getActiveTabColor()}; + border-color: ${getActiveTabColor()}; +`; + +export const Tab = styled.label<{ selected: boolean }>` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 6px 12px; + + color: ${color("text-white")}; + font-weight: bold; + + border: 2px solid; + border-radius: 8px; + cursor: pointer; + + transition: all 0.3s; + + .Icon { + margin-right: 10px; + } + + ${props => (props.selected ? activeTabCSS : inactiveTabCSS)}; +`; + +export const RadioInput = styled.input.attrs({ type: "radio" })` + cursor: inherit; + position: absolute; + opacity: 0; + width: 0; + height: 0; + top: 0; + left: 0; + margin: 0; + padding: 0; + z-index: 1; +`; diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.tsx b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5e8b9edf5a1f73d71deb5d0dd6cc9ab3e7fdf942 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import _ from "underscore"; + +import Icon from "metabase/components/Icon"; +import { TabBar, Tab, RadioInput } from "./EditorTabs.styled"; + +type Props = { + currentTab: string; + options: { + id: string; + name: string; + icon: string; + }[]; + onChange: (optionId: string) => void; +}; + +function EditorTabs({ currentTab, options, onChange, ...props }: Props) { + const inputId = "editor-tabs"; + + return ( + <TabBar {...props}> + {options.map(option => { + const selected = currentTab === option.id; + const id = `${inputId}-${option.id}`; + const labelId = `${id}-label`; + return ( + <li key={option.id}> + <Tab id={labelId} htmlFor={id} selected={selected}> + <Icon name={option.icon} /> + <RadioInput + id={id} + name={inputId} + value={option.id} + checked={selected} + onChange={() => { + onChange(option.id); + }} + aria-labelledby={labelId} + /> + <span data-testid={`${id}-name`}>{option.name}</span> + </Tab> + </li> + ); + })} + </TabBar> + ); +} + +export default EditorTabs; diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/index.ts b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..82ed4e893b9ad9b5b2b94412eefe236111abaf2f --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/index.ts @@ -0,0 +1 @@ +export { default } from "./EditorTabs"; diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/ResizableNotebook/ResizableNotebook.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/ResizableNotebook/ResizableNotebook.jsx index 37dedd9214de7196486c197af8c5360905ebe415..88becd3bcef85916512dc1ed686d6cabfe006f1f 100644 --- a/frontend/src/metabase/query_builder/components/DatasetEditor/ResizableNotebook/ResizableNotebook.jsx +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/ResizableNotebook/ResizableNotebook.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React from "react"; import PropTypes from "prop-types"; import { ResizableBox } from "react-resizable"; @@ -7,24 +7,25 @@ import Notebook from "metabase/query_builder/components/notebook/Notebook"; import { NotebookContainer, Handle } from "./ResizableNotebook.styled"; const propTypes = { - height: PropTypes.number.isRequired, + isResizing: PropTypes.bool.isRequired, + resizableBoxProps: PropTypes.object.isRequired, onResizeStop: PropTypes.func.isRequired, }; -function ResizableNotebook({ height, onResizeStop, ...notebookProps }) { - const [isResizing, setResizing] = useState(false); +function ResizableNotebook({ + isResizing, + onResizeStop, + resizableBoxProps, + ...notebookProps +}) { return ( <ResizableBox className="border-top flex" axis="y" - resizeHandles={["s"]} - height={height} handle={<Handle />} - onResizeStart={() => { - setResizing(true); - }} + {...resizableBoxProps} onResizeStop={(...args) => { - setResizing(false); + resizableBoxProps.onResizeStop(...args); onResizeStop(...args); }} > diff --git a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx index db3f06a3c1ab62b0d0963abb3b680aa7503fe532..77a8981b94e0e7d91c1a55db8b63aaf68ecd89aa 100644 --- a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx +++ b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx @@ -411,6 +411,9 @@ export default class NativeQueryEditor extends Component { isNativeEditorOpen, openSnippetModalWithSelectedText, hasParametersList = true, + hasTopBar = true, + hasEditingSidebar = true, + resizableBoxProps = {}, } = this.props; const parameters = query.question().parameters(); @@ -423,32 +426,34 @@ export default class NativeQueryEditor extends Component { return ( <div className="NativeQueryEditor bg-light full"> - <div className="flex align-center" style={{ minHeight: 55 }}> - <DataSourceSelectors - isNativeEditorOpen={isNativeEditorOpen} - query={query} - readOnly={readOnly} - setDatabaseId={this.setDatabaseId} - setTableId={this.setTableId} - /> - {hasParametersList && ( - <SyncedParametersList - className="mt1" - parameters={parameters} - setParameterValue={setParameterValue} - setParameterIndex={this.setParameterIndex} - isEditing - commitImmediately - /> - )} - {query.hasWritePermission() && ( - <VisibilityToggler - isOpen={isNativeEditorOpen} + {hasTopBar && ( + <div className="flex align-center" style={{ minHeight: 55 }}> + <DataSourceSelectors + isNativeEditorOpen={isNativeEditorOpen} + query={query} readOnly={readOnly} - toggleEditor={this.toggleEditor} + setDatabaseId={this.setDatabaseId} + setTableId={this.setTableId} /> - )} - </div> + {hasParametersList && ( + <SyncedParametersList + className="mt1" + parameters={parameters} + setParameterValue={setParameterValue} + setParameterIndex={this.setParameterIndex} + isEditing + commitImmediately + /> + )} + {query.hasWritePermission() && ( + <VisibilityToggler + isOpen={isNativeEditorOpen} + readOnly={readOnly} + toggleEditor={this.toggleEditor} + /> + )} + </div> + )} <ResizableBox ref={this.resizeBox} className={cx("border-top flex ", { hide: !isNativeEditorOpen })} @@ -456,11 +461,13 @@ export default class NativeQueryEditor extends Component { minConstraints={[Infinity, getEditorLineHeight(MIN_HEIGHT_LINES)]} axis="y" handle={dragHandle} + resizeHandles={["s"]} + {...resizableBoxProps} onResizeStop={(e, data) => { this.props.handleResize(); + resizableBoxProps?.onResizeStop(e, data); this._editor.resize(); }} - resizeHandles={["s"]} > <div className="flex-full" id="id_sql" ref={this.editor} /> @@ -485,7 +492,7 @@ export default class NativeQueryEditor extends Component { closeModal={this.props.closeSnippetModal} /> )} - {!readOnly && ( + {hasEditingSidebar && !readOnly && ( <NativeQueryEditorSidebar runQuery={this.runQuery} {...this.props} diff --git a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx index 5095c87db0c09388147736fb14c2a5b625cd11e2..d18f875074f2addbaf019bd2c8315c4c93cfda60 100644 --- a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx +++ b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx @@ -2,6 +2,7 @@ import React, { Component } from "react"; import { t, jt } from "ttag"; import cx from "classnames"; +import _ from "underscore"; import ErrorMessage from "metabase/components/ErrorMessage"; import Visualization from "metabase/visualizations/components/Visualization"; @@ -10,6 +11,12 @@ import { CreateAlertModalContent } from "metabase/query_builder/components/Alert import Modal from "metabase/components/Modal"; import { ALERT_TYPE_ROWS } from "metabase-lib/lib/Alert"; +const ALLOWED_VISUALIZATION_PROPS = [ + // Table Interactive + "tableHeaderHeight", + "renderTableHeaderWrapper", +]; + export default class VisualizationResult extends Component { state = { showCreateAlertModal: false, @@ -27,7 +34,7 @@ export default class VisualizationResult extends Component { const { question, isDirty, - isVisualizationClickable, + queryBuilderMode, navigateToNewCardInsideQB, result, rawSeries, @@ -77,6 +84,10 @@ export default class VisualizationResult extends Component { </div> ); } else { + const vizSpecificProps = _.pick( + this.props, + ...ALLOWED_VISUALIZATION_PROPS, + ); return ( <Visualization className={className} @@ -84,15 +95,17 @@ export default class VisualizationResult extends Component { onChangeCardAndRun={navigateToNewCardInsideQB} isEditing={true} isQueryBuilder={true} + queryBuilderMode={queryBuilderMode} showTitle={false} - isClickable={isVisualizationClickable} metadata={question.metadata()} + handleVisualizationClick={this.props.handleVisualizationClick} onOpenChartSettings={this.props.onOpenChartSettings} onUpdateWarnings={this.props.onUpdateWarnings} onUpdateVisualizationSettings={ this.props.onUpdateVisualizationSettings } query={this.props.query} + {...vizSpecificProps} /> ); } diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/DatasetManagementSection/DatasetManagementSection.jsx b/frontend/src/metabase/query_builder/components/view/sidebars/DatasetManagementSection/DatasetManagementSection.jsx index 072d2297fa4b22e196e0a191f666b905d0d07d86..1257c1acec10a8a574fafd58057b7be6fe543138 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/DatasetManagementSection/DatasetManagementSection.jsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/DatasetManagementSection/DatasetManagementSection.jsx @@ -36,7 +36,15 @@ function DatasetManagementSection({ turnDatasetIntoQuestion, }) { const onEditQueryDefinitionClick = () => { - setQueryBuilderMode("dataset"); + setQueryBuilderMode("dataset", { + datasetEditorTab: "query", + }); + }; + + const onCustomizeMetadataClick = () => { + setQueryBuilderMode("dataset", { + datasetEditorTab: "metadata", + }); }; return ( @@ -48,7 +56,10 @@ function DatasetManagementSection({ onClick={onEditQueryDefinitionClick} >{t`Edit query definition`}</Button> <Row> - <Button icon="label">{t`Customize metadata`}</Button> + <Button + icon="label" + onClick={onCustomizeMetadataClick} + >{t`Customize Metadata`}</Button> <MetadataIndicatorContainer> <DatasetMetadataStrengthIndicator dataset={dataset} /> </MetadataIndicatorContainer> diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js index 31ac842d5d04e270556f869b79fa919cfd9e01c2..625e933834921375babe7c7914e5d681e73a4c2c 100644 --- a/frontend/src/metabase/query_builder/reducers.js +++ b/frontend/src/metabase/query_builder/reducers.js @@ -68,6 +68,7 @@ const DEFAULT_UI_CONTROLS = { isShowingRawTable: false, // table/viz toggle queryBuilderMode: false, // "view" | "notebook" | "dataset" snippetCollectionId: null, + datasetEditorTab: "query", // "query" / "metadata" }; const UI_CONTROLS_SIDEBAR_DEFAULTS = { diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index 12f07d23edd287889cc9861b637db56a9dad6e1a..98911ce8866539f9e1e4cc768992fee7c5aefb76 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -171,6 +171,11 @@ export const getQueryBuilderMode = createSelector( uiControls => uiControls.queryBuilderMode, ); +export const getDatasetEditorTab = createSelector( + [getUiControls], + uiControls => uiControls.datasetEditorTab, +); + export const getOriginalQuestion = createSelector( [getMetadata, getOriginalCard], (metadata, card) => metadata && card && new Question(card, metadata), diff --git a/frontend/src/metabase/query_builder/utils.js b/frontend/src/metabase/query_builder/utils.js index f06f0783537f0fcb6e2b4e6be746654c54060a4d..613a74d85f3d9846009ea8ea51e7a573d2e22fcf 100644 --- a/frontend/src/metabase/query_builder/utils.js +++ b/frontend/src/metabase/query_builder/utils.js @@ -7,23 +7,31 @@ import NativeQuery from "metabase-lib/lib/queries/NativeQuery"; export function getQueryBuilderModeFromLocation(location) { const { pathname } = location; if (pathname.endsWith("/notebook")) { - return "notebook"; + return { + mode: "notebook", + }; } - if (pathname.endsWith("/query")) { - return "dataset"; + if (pathname.endsWith("/query") || pathname.endsWith("/metadata")) { + return { + mode: "dataset", + datasetEditorTab: pathname.endsWith("/query") ? "query" : "metadata", + }; } - return "view"; + return { + mode: "view", + }; } export function getPathNameFromQueryBuilderMode({ pathname, queryBuilderMode, + datasetEditorTab = "query", }) { if (queryBuilderMode === "view") { return pathname; } if (queryBuilderMode === "dataset") { - return `${pathname}/query`; + return `${pathname}/${datasetEditorTab}`; } return `${pathname}/${queryBuilderMode}`; } diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 095c058696d73a83a20470a6c97a649684718a15..76e4574cd2b20ce966a5a8f1b9fd8b9ab164503c 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -242,6 +242,7 @@ export const getRoutes = store => ( <Route path=":slug" component={QueryBuilder} /> <Route path=":slug/notebook" component={QueryBuilder} /> <Route path=":slug/query" component={QueryBuilder} /> + <Route path=":slug/metadata" component={QueryBuilder} /> </Route> <Route path="browse" component={BrowseApp}> diff --git a/frontend/src/metabase/visualizations/components/TableInteractive.jsx b/frontend/src/metabase/visualizations/components/TableInteractive.jsx index 17fb4ab939a3a4eec3df35180d3356a92928c1fe..d772dbada7716bc835441692d9b4382e3fd3045f 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive.jsx +++ b/frontend/src/metabase/visualizations/components/TableInteractive.jsx @@ -134,7 +134,14 @@ export default class TableInteractive extends Component { } shouldComponentUpdate(nextProps, nextState) { - const PROP_KEYS = ["width", "height", "settings", "data", "clicked"]; + const PROP_KEYS = [ + "width", + "height", + "settings", + "data", + "clicked", + "renderTableHeaderWrapper", + ]; // compare specific props and state to determine if we should re-render return ( !_.isEqual( diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index 4d1fc1c7dab20da27a10acbae13a956664fd3526..84a38b537cb1addc9cda4642773a63547ce884f2 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -65,7 +65,6 @@ export default class Visualization extends React.PureComponent { isEditing: false, isSettings: false, isQueryBuilder: false, - isClickable: true, onUpdateVisualizationSettings: () => {}, // prefer passing in a function that doesn't cause the application to reload onChangeLocation: location => { @@ -184,12 +183,17 @@ export default class Visualization extends React.PureComponent { if (!metadata || !card) { return; } + const { isQueryBuilder, queryBuilderMode } = this.props; const question = new Question(card, metadata); // Datasets in QB should behave as raw tables opened in simple mode // composeDataset replaces the dataset_query with a clean query using the dataset as a source table // Ideally, this logic should happen somewhere else - return question.isDataset() ? question.composeDataset() : question; + return question.isDataset() && + isQueryBuilder && + queryBuilderMode !== "dataset" + ? question.composeDataset() + : question; } getClickActions(clicked) { @@ -214,8 +218,8 @@ export default class Visualization extends React.PureComponent { } visualizationIsClickable = clicked => { - const { onChangeCardAndRun, isClickable } = this.props; - if (!onChangeCardAndRun || !isClickable) { + const { onChangeCardAndRun } = this.props; + if (!onChangeCardAndRun) { return false; } try { @@ -227,6 +231,8 @@ export default class Visualization extends React.PureComponent { }; handleVisualizationClick = clicked => { + const { handleVisualizationClick } = this.props; + if (clicked) { MetabaseAnalytics.trackStructEvent( "Actions", @@ -237,6 +243,11 @@ export default class Visualization extends React.PureComponent { ); } + if (typeof handleVisualizationClick === "function") { + handleVisualizationClick(clicked); + return; + } + if ( performDefaultAction(this.getClickActions(clicked), { dispatch: this.props.dispatch,