Skip to content
Snippets Groups Projects
Commit 31b38be1 authored by Anton Kulyk's avatar Anton Kulyk
Browse files

Add tab-key navigation for metadata editor (#19379)

* Forward form field ref

* Focus "display_name" when selecting a new field

* Hide query editor in metadata mode

This will remove it from the "tab-flow"

* Pass tabIndex prop to form text inputs

* Add basic tab flow for metadata editor

* Allow passing more props to SelectButton

* Allow passing props to AccordionList's search input

* Use numbers for tab indexes

* Fix SelectButton's props type

* Add special type picker to tab flow

* Fix initial field focusing

* Add "tab" icon

* Add basic TabHintToast component

* Show tab hint toast in metadata editor

* Export wrapped CustomFormField

* Forward SelectButton ref

* Add react-virtualized scrollToColumn prop to Table

* Don't open SemanticTypePicker on focus

* Open SemanticTypePicker on "Enter" key press

* Improve tabbing experience

* automatically scroll the InteractiveTable while tabbing through columns
* focus the first column on hitting "Enter" on the last column

* Remove failed experiment

It was my last hope to combine both props, but no lucj

* Animate sidebar when focused field changes

* Minor rename

* Fix mapped field picker component

* Pass visibility callbacks to trigger elements

* Handle focus for mapped field picker

* Pass searchPlaceholder to Select

* Pass triggerable's onClose prop to Select

* Add onClose prop to DataSelector

* Use Select from special type picker

* Handle focus for MappedFieldPicker

* Add :focus style for essential select inputs

* Fix jump from last to first column on tab key
parent df7fecae
No related branches found
No related tags found
No related merge requests found
Showing
with 483 additions and 282 deletions
......@@ -87,6 +87,7 @@ export default class AccordionList extends Component {
searchCaseInsensitive: PropTypes.bool,
searchFuzzy: PropTypes.bool,
searchPlaceholder: PropTypes.string,
searchInputProps: PropTypes.object,
hideEmptySectionsInSearch: PropTypes.bool,
itemTestId: PropTypes.string,
......@@ -494,6 +495,7 @@ const AccordionListCell = ({
searchText,
onChangeSearchText,
searchPlaceholder,
searchInputProps,
showItemArrows,
itemTestId,
getItemClassName,
......@@ -563,6 +565,7 @@ const AccordionListCell = ({
value: searchText,
onChange: onChangeSearchText,
placeholder: searchPlaceholder,
...searchInputProps,
};
content =
typeof renderSearchSection === "function" ? (
......
import styled, { css } from "styled-components";
import { color, darken } from "metabase/lib/colors";
import { forwardRefToInnerRef } from "metabase/styled-components/utils";
export interface InputProps {
hasError?: boolean;
......@@ -14,7 +15,7 @@ export const InputRoot = styled.div<InputProps>`
width: ${props => (props.fullWidth ? "100%" : "")};
`;
export const InputField = styled.input<InputProps>`
export const InputField = forwardRefToInnerRef(styled.input<InputProps>`
font-family: inherit;
font-weight: 700;
font-size: 1rem;
......@@ -47,7 +48,7 @@ export const InputField = styled.input<InputProps>`
css`
width: 100%;
`}
`;
`);
export const InputIconContainer = styled.div`
display: flex;
......
......@@ -10,13 +10,10 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
helperText?: ReactNode;
}
const Input = ({
className,
error,
fullWidth,
helperText,
...rest
}: InputProps) => {
const Input = forwardRef(function Input(
{ className, error, fullWidth, helperText, ...rest }: InputProps,
ref,
) {
return (
<InputRoot className={className} fullWidth={fullWidth}>
<InputField
......@@ -24,6 +21,7 @@ const Input = ({
hasError={error}
hasTooltip={Boolean(helperText)}
fullWidth={fullWidth}
ref={ref}
/>
{helperText && (
<Tooltip tooltip={helperText} placement="right" offset={[0, 24]}>
......@@ -32,7 +30,7 @@ const Input = ({
)}
</InputRoot>
);
};
});
const InputHelpContent = forwardRef(function InputHelpContent(props, ref: any) {
return (
......
......@@ -37,6 +37,7 @@ export default class Select extends Component {
// PopoverWithTrigger props
isInitiallyOpen: PropTypes.bool,
triggerElement: PropTypes.any,
onClose: PropTypes.func,
// SelectButton props
buttonProps: PropTypes.object,
......@@ -44,6 +45,7 @@ export default class Select extends Component {
// AccordianList props
searchProp: PropTypes.string,
searchCaseInsensitive: PropTypes.bool,
searchPlaceholder: PropTypes.string,
searchFuzzy: PropTypes.bool,
optionNameFn: PropTypes.func,
......@@ -173,8 +175,10 @@ export default class Select extends Component {
placeholder,
searchProp,
searchCaseInsensitive,
searchPlaceholder,
searchFuzzy,
isInitiallyOpen,
onClose,
} = this.props;
const sections = this._getSections();
......@@ -206,6 +210,7 @@ export default class Select extends Component {
</SelectButton>
)
}
onClose={onClose}
triggerClasses={cx("flex", className)}
isInitiallyOpen={isInitiallyOpen}
verticalAttachments={["top", "bottom"]}
......@@ -228,6 +233,7 @@ export default class Select extends Component {
searchProp={searchProp}
searchCaseInsensitive={searchCaseInsensitive}
searchFuzzy={searchFuzzy}
searchPlaceholder={searchPlaceholder}
/>
</PopoverWithTrigger>
);
......
import React from "react";
import React, { forwardRef, HTMLAttributes } from "react";
import cx from "classnames";
import Icon from "metabase/components/Icon";
type Props = {
className?: string;
style?: React.CSSProperties;
type Props = HTMLAttributes<HTMLDivElement> & {
hasValue?: boolean;
children: React.ReactNode;
onClick?: () => void;
};
const SelectButton = ({
className,
style,
children,
hasValue = true,
onClick,
}: Props) => (
<div
onClick={onClick}
style={style}
className={cx(className, "AdminSelect flex align-center", {
"text-medium": !hasValue,
})}
>
<span className="AdminSelect-content mr1">{children}</span>
<Icon
className="AdminSelect-chevron flex-align-right"
name="chevrondown"
size={12}
/>
</div>
);
const SelectButton = forwardRef<HTMLDivElement, Props>(function SelectButton(
{ className, children, hasValue, ...props }: Props,
ref,
) {
return (
<div
{...props}
className={cx(className, "AdminSelect flex align-center", {
"text-medium": !hasValue,
})}
ref={ref}
>
<span className="AdminSelect-content mr1">{children}</span>
<Icon
className="AdminSelect-chevron flex-align-right"
name="chevrondown"
size={12}
/>
</div>
);
});
export default SelectButton;
......@@ -33,17 +33,17 @@ export default ComposedComponent =>
closeOnObscuredTrigger: false,
};
open() {
open = () => {
this.toggle(true);
}
};
close() {
close = () => {
this.toggle(false);
}
};
toggle(isOpen = !this.state.isOpen) {
toggle = (isOpen = !this.state.isOpen) => {
this.setState({ isOpen });
}
};
onClose(e) {
// don't close if clicked the actual trigger, it will toggle
......@@ -153,7 +153,11 @@ export default ComposedComponent =>
style={triggerStyle}
>
{typeof triggerElement === "function"
? triggerElement({ isTriggeredComponentOpen: isOpen })
? triggerElement({
isTriggeredComponentOpen: isOpen,
open: this.open,
close: this.close,
})
: triggerElement}
<ComposedComponent
{...this.props}
......
......@@ -110,7 +110,7 @@ Form.contextTypes = {
style: PropTypes.object,
};
export class CustomFormField extends React.Component {
class RawCustomFormField extends React.Component {
static contextTypes = {
fields: PropTypes.object,
formFieldsByName: PropTypes.object,
......@@ -142,7 +142,7 @@ export class CustomFormField extends React.Component {
}
}
render() {
const { name } = this.props;
const { name, innerRef } = this.props;
const { fields, formFieldsByName, values, onChangeField } = this.context;
const field = getIn(fields, name.split("."));
......@@ -166,12 +166,16 @@ export class CustomFormField extends React.Component {
return (
<FormField {...props}>
<Widget {...props} />
<Widget {...props} ref={innerRef} />
</FormField>
);
}
}
export const CustomFormField = React.forwardRef((props, ref) => (
<RawCustomFormField {...props} innerRef={ref} />
));
export const CustomFormSubmit = (
{ children, ...props },
{
......
/* eslint-disable react/prop-types */
import React from "react";
import React, { forwardRef } from "react";
import { PLUGIN_FORM_WIDGETS } from "metabase/plugins";
......@@ -48,9 +48,12 @@ function getWidgetComponent(formField) {
return formField.type || FormInputWidget;
}
const FormWidget = ({ field, formField, ...props }) => {
const FormWidget = forwardRef(function FormWidget(
{ field, formField, ...props },
ref,
) {
const Widget = getWidgetComponent(formField);
return <Widget field={field} {...formField} {...props} />;
};
return <Widget field={field} {...formField} {...props} ref={ref} />;
});
export default FormWidget;
import React from "react";
import React, { forwardRef } from "react";
import PropTypes from "prop-types";
import { formDomOnlyProps } from "metabase/lib/redux";
import Input from "metabase/components/Input/Input";
......@@ -13,28 +13,37 @@ const propTypes = {
readOnly: PropTypes.bool,
autoFocus: PropTypes.bool,
helperText: PropTypes.node,
tabIndex: PropTypes.string,
};
const FormInputWidget = ({
type = "text",
placeholder,
field,
readOnly,
autoFocus,
helperText,
}) => (
<Input
{...formDomOnlyProps(field)}
type={type}
placeholder={placeholder}
aria-labelledby={`${field.name}-label`}
readOnly={readOnly}
autoFocus={autoFocus}
error={field.visited && !field.active && field.error != null}
helperText={helperText}
fullWidth
/>
);
const FormInputWidget = forwardRef(function FormInputWidget(
{
type = "text",
placeholder,
field,
readOnly,
autoFocus,
helperText,
tabIndex,
},
ref,
) {
return (
<Input
{...formDomOnlyProps(field)}
type={type}
placeholder={placeholder}
aria-labelledby={`${field.name}-label`}
readOnly={readOnly}
autoFocus={autoFocus}
error={field.visited && !field.active && field.error != null}
helperText={helperText}
fullWidth
tabIndex={tabIndex}
ref={ref}
/>
);
});
FormInputWidget.propTypes = propTypes;
......
......@@ -12,6 +12,7 @@ const FormTextAreaWidget = ({
rows,
autoFocus,
helperText,
tabIndex,
}) => (
<>
<textarea
......@@ -20,6 +21,7 @@ const FormTextAreaWidget = ({
rows={rows}
placeholder={placeholder}
aria-labelledby={`${field.name}-label`}
tabIndex={tabIndex}
{...formDomOnlyProps(field)}
/>
{helperText && (
......
......@@ -461,6 +461,13 @@ export const ICON_PATHS: Record<string, any> = {
},
sun:
"M18.2857143,27.1999586 L18.2857143,29.7130168 C18.2857143,30.9760827 17.2711661,32 16,32 C14.7376349,32 13.7142857,30.9797942 13.7142857,29.7130168 L13.7142857,27.1999586 C14.4528227,27.3498737 15.2172209,27.4285714 16,27.4285714 C16.7827791,27.4285714 17.5471773,27.3498737 18.2857143,27.1999586 Z M13.7142857,4.80004141 L13.7142857,2.28698322 C13.7142857,1.02391726 14.7288339,0 16,0 C17.2623651,0 18.2857143,1.02020582 18.2857143,2.28698322 L18.2857143,4.80004141 C17.5471773,4.65012631 16.7827791,4.57142857 16,4.57142857 C15.2172209,4.57142857 14.4528227,4.65012631 13.7142857,4.80004141 Z M10.5518048,26.0488463 L8.93640145,27.9740091 C8.1245183,28.9415738 6.68916799,29.0738009 5.71539825,28.2567111 C4.74837044,27.4452784 4.62021518,26.0059593 5.43448399,25.0355515 L7.05102836,23.1090289 C8.00526005,24.3086326 9.1956215,25.3120077 10.5518048,26.0488463 Z M21.4481952,5.95115366 L23.0635986,4.02599087 C23.8754817,3.05842622 25.310832,2.92619908 26.2846018,3.74328891 C27.2516296,4.55472158 27.3797848,5.99404073 26.565516,6.96444852 L24.9489716,8.89097108 C23.9947399,7.69136735 22.8043785,6.68799226 21.4481952,5.95115366 Z M7.05102836,8.89097108 L5.43448399,6.96444852 C4.62260085,5.99688386 4.7416285,4.56037874 5.71539825,3.74328891 C6.68242605,2.93185624 8.12213263,3.05558308 8.93640145,4.02599087 L10.5518048,5.95115366 C9.1956215,6.68799226 8.00526005,7.69136735 7.05102836,8.89097108 Z M24.9489716,23.1090289 L26.565516,25.0355515 C27.3773992,26.0031161 27.2583715,27.4396213 26.2846018,28.2567111 C25.317574,29.0681438 23.8778674,28.9444169 23.0635986,27.9740091 L21.4481952,26.0488463 C22.8043785,25.3120077 23.9947399,24.3086326 24.9489716,23.1090289 Z M27.1999586,13.7142857 L29.7130168,13.7142857 C30.9760827,13.7142857 32,14.7288339 32,16 C32,17.2623651 30.9797942,18.2857143 29.7130168,18.2857143 L27.1999586,18.2857143 C27.3498737,17.5471773 27.4285714,16.7827791 27.4285714,16 C27.4285714,15.2172209 27.3498737,14.4528227 27.1999586,13.7142857 Z M4.80004141,18.2857143 L2.28698322,18.2857143 C1.02391726,18.2857143 2.7533531e-14,17.2711661 2.84217094e-14,16 C2.84217094e-14,14.7376349 1.02020582,13.7142857 2.28698322,13.7142857 L4.80004141,13.7142857 C4.65012631,14.4528227 4.57142857,15.2172209 4.57142857,16 C4.57142857,16.7827791 4.65012631,17.5471773 4.80004141,18.2857143 Z M16,22.8571429 C19.7870954,22.8571429 22.8571429,19.7870954 22.8571429,16 C22.8571429,12.2129046 19.7870954,9.14285714 16,9.14285714 C12.2129046,9.14285714 9.14285714,12.2129046 9.14285714,16 C9.14285714,19.7870954 12.2129046,22.8571429 16,22.8571429 Z",
tab: {
path:
"M7.98328 11.5098C7.68166 11.8115 7.68166 12.3005 7.98328 12.6021C8.28491 12.9037 8.77394 12.9037 9.07557 12.6021L13.0919 8.58583C13.3935 8.2842 13.3935 7.79517 13.0919 7.49354L9.07557 3.47725C8.77394 3.17562 8.28491 3.17562 7.98328 3.47725C7.68166 3.77887 7.68166 4.26791 7.98328 4.56953L10.681 7.26729L1.50711 7.26729C1.08055 7.26729 0.73475 7.61309 0.73475 8.03966C0.73475 8.46622 1.08055 8.81202 1.50711 8.81202L10.6811 8.81202L7.98328 11.5098ZM14.3141 14.0641C14.3141 14.7545 14.8738 15.3141 15.5641 15.3141C16.2545 15.3141 16.8141 14.7545 16.8141 14.0641L16.8141 2.01524C16.8141 1.32489 16.2545 0.765242 15.5641 0.765242C14.8738 0.765242 14.3141 1.32489 14.3141 2.01524L14.3141 14.0641Z",
attrs: {
viewBox: "0 0 16 14",
},
},
table:
"M11.077 11.077h9.846v9.846h-9.846v-9.846zm11.077 11.077H32V32h-9.846v-9.846zm-11.077 0h9.846V32h-9.846v-9.846zM0 22.154h9.846V32H0v-9.846zM0 0h9.846v9.846H0V0zm0 11.077h9.846v9.846H0v-9.846zM22.154 0H32v9.846h-9.846V0zm0 11.077H32v9.846h-9.846v-9.846zM11.077 0h9.846v9.846h-9.846V0z",
table2:
......
......@@ -140,14 +140,6 @@ export const SchemaTableAndFieldDataSelector = props => (
/>
);
export const FieldSelector = props => (
<DataSelector
steps={[TABLE_STEP, FIELD_STEP]}
getTriggerElementContent={FieldTriggerContent}
{...props}
/>
);
const DatabaseTriggerContent = ({ selectedDatabase }) =>
selectedDatabase ? (
<span className="text-wrap text-grey no-decoration">
......@@ -798,7 +790,7 @@ export class UnconnectedDataSelector extends Component {
await this.nextStep({ selectedFieldId: field && field.id });
};
getTriggerElement() {
getTriggerElement = triggerProps => {
const {
className,
style,
......@@ -823,6 +815,7 @@ export class UnconnectedDataSelector extends Component {
selectedDatabase,
selectedTable,
selectedField,
...triggerProps,
})}
{!this.props.readOnly && hasTriggerExpandControl && (
<Icon
......@@ -833,7 +826,7 @@ export class UnconnectedDataSelector extends Component {
)}
</span>
);
}
};
getTriggerClasses() {
if (this.props.triggerClasses) {
......@@ -972,6 +965,7 @@ export class UnconnectedDataSelector extends Component {
handleClose = () => {
this.setState({ searchText: "" });
this.props?.onClose();
};
getSearchInputPlaceholder = () => {
......@@ -1032,7 +1026,7 @@ export class UnconnectedDataSelector extends Component {
ref={this.popover}
isInitiallyOpen={this.props.isInitiallyOpen}
containerClassName={this.props.containerClassName}
triggerElement={this.getTriggerElement()}
triggerElement={this.getTriggerElement}
triggerClasses={this.getTriggerClasses()}
horizontalAttachments={["center", "left", "right"]}
hasArrow={this.props.hasArrow}
......
......@@ -20,10 +20,14 @@ import { setDatasetEditorTab } from "metabase/query_builder/actions";
import { getDatasetEditorTab } from "metabase/query_builder/selectors";
import { isSameField } from "metabase/lib/query/field_ref";
import { usePrevious } from "metabase/hooks/use-previous";
import { useToggle } from "metabase/hooks/use-toggle";
import { EDITOR_TAB_INDEXES } from "./constants";
import DatasetFieldMetadataSidebar from "./DatasetFieldMetadataSidebar";
import DatasetQueryEditor from "./DatasetQueryEditor";
import EditorTabs from "./EditorTabs";
import { TabHintToast } from "./TabHintToast";
import {
Root,
......@@ -32,11 +36,13 @@ import {
QueryEditorContainer,
TableHeaderColumnName,
TableContainer,
TabHintToastContainer,
} from "./DatasetEditor.styled";
const propTypes = {
question: PropTypes.object.isRequired,
datasetEditorTab: PropTypes.oneOf(["query", "metadata"]).isRequired,
result: PropTypes.object,
height: PropTypes.number,
setQueryBuilderMode: PropTypes.func.isRequired,
setDatasetEditorTab: PropTypes.func.isRequired,
......@@ -64,7 +70,10 @@ function mapStateToProps(state) {
const mapDispatchToProps = { setDatasetEditorTab };
function getSidebar(props, { datasetEditorTab, focusedField }) {
function getSidebar(
props,
{ datasetEditorTab, focusedField, focusedFieldIndex, focusFirstField },
) {
const {
question: dataset,
isShowingTemplateTagsEditor,
......@@ -76,8 +85,16 @@ function getSidebar(props, { datasetEditorTab, focusedField }) {
} = props;
if (datasetEditorTab === "metadata") {
const isLastField =
focusedField &&
focusedFieldIndex === dataset.getResultMetadata().length - 1;
return (
<DatasetFieldMetadataSidebar dataset={dataset} field={focusedField} />
<DatasetFieldMetadataSidebar
dataset={dataset}
field={focusedField}
isLastField={isLastField}
handleFirstFieldFocus={focusFirstField}
/>
);
}
......@@ -98,10 +115,19 @@ function getSidebar(props, { datasetEditorTab, focusedField }) {
return null;
}
function getColumnTabIndex(columnIndex, focusedFieldIndex) {
return columnIndex === focusedFieldIndex
? EDITOR_TAB_INDEXES.FOCUSED_FIELD
: columnIndex > focusedFieldIndex
? EDITOR_TAB_INDEXES.NEXT_FIELDS
: EDITOR_TAB_INDEXES.PREVIOUS_FIELDS;
}
function DatasetEditor(props) {
const {
question: dataset,
datasetEditorTab,
result,
height,
setQueryBuilderMode,
setDatasetEditorTab,
......@@ -119,12 +145,33 @@ function DatasetEditor(props) {
const [focusedField, setFocusedField] = useState();
const focusFirstField = useCallback(() => {
const [firstField] = dataset.getResultMetadata() || [];
setFocusedField(firstField);
}, [dataset]);
useEffect(() => {
const resultMetadata = dataset.getResultMetadata();
if (!focusedField && resultMetadata?.length > 0) {
setFocusedField(resultMetadata[0]);
// Focused field has to be set once the query is completed and the result is rendered
// Visualization render can remove the focus
const hasQueryResults = !!result;
if (!focusedField && hasQueryResults) {
focusFirstField();
}
}, [dataset, focusedField]);
}, [result, focusedField, focusFirstField]);
const [
isTabHintVisible,
{ turnOn: showTabHint, turnOff: hideTabHint },
] = useToggle(false);
useEffect(() => {
let timeoutId;
if (result) {
timeoutId = setTimeout(() => showTabHint(), 500);
}
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [result]);
const onChangeEditorTab = useCallback(
tab => {
......@@ -144,7 +191,15 @@ function DatasetEditor(props) {
setQueryBuilderMode("view");
}, [dataset, onSave, setQueryBuilderMode]);
const sidebar = getSidebar(props, { datasetEditorTab, focusedField });
const handleColumnSelect = useCallback(
column => {
const field = dataset
.getResultMetadata()
.find(f => isSameField(column?.field_ref, f?.field_ref));
setFocusedField(field);
},
[dataset],
);
const handleTableElementClick = useCallback(
({ element, ...clickedObject }) => {
......@@ -152,27 +207,50 @@ function DatasetEditor(props) {
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);
handleColumnSelect(clickedObject.column);
}
},
[dataset],
[handleColumnSelect],
);
const focusedFieldIndex = useMemo(() => {
const fields = dataset.getResultMetadata();
return fields.findIndex(f =>
isSameField(focusedField?.field_ref, f?.field_ref),
);
}, [dataset, focusedField]);
const previousFocusedFieldIndex = usePrevious(focusedFieldIndex);
// This value together with focusedFieldIndex is used to
// horizontally scroll the InteractiveTable to the focused column
// (via react-virtualized's "scrollToColumn" prop)
const scrollToColumnModifier = useMemo(() => {
// Normally the modifier is either 1 or -1 and added to focusedFieldIndex,
// so it's either the previous or the next column is visible
// (depending on if we're tabbing forward or backwards)
// But when the first field is selected, it's important to keep "scrollToColumn" 0
// So when you hit "Tab" while the very last column is focused,
// it'd jump exactly to the beginning of the table
if (focusedFieldIndex === 0) {
return 0;
}
const isGoingForward = focusedFieldIndex >= previousFocusedFieldIndex;
return isGoingForward ? 1 : -1;
}, [focusedFieldIndex, previousFocusedFieldIndex]);
const renderSelectableTableColumnHeader = useCallback(
(element, column) => (
(element, column, columnIndex) => (
<TableHeaderColumnName
tabIndex={getColumnTabIndex(columnIndex, focusedFieldIndex)}
onFocus={() => handleColumnSelect(column)}
isSelected={isSameField(column?.field_ref, focusedField?.field_ref)}
>
<Icon name="three_dots" size={14} />
<span>{column.display_name}</span>
</TableHeaderColumnName>
),
[focusedField],
[focusedField, focusedFieldIndex, handleColumnSelect],
);
const renderTableHeaderWrapper = useMemo(
......@@ -183,6 +261,13 @@ function DatasetEditor(props) {
[datasetEditorTab, renderSelectableTableColumnHeader],
);
const sidebar = getSidebar(props, {
datasetEditorTab,
focusedField,
focusedFieldIndex,
focusFirstField,
});
return (
<React.Fragment>
<DatasetEditBar
......@@ -235,8 +320,14 @@ function DatasetEditor(props) {
handleVisualizationClick={handleTableElementClick}
tableHeaderHeight={isEditingMetadata && TABLE_HEADER_HEIGHT}
renderTableHeaderWrapper={renderTableHeaderWrapper}
scrollToColumn={focusedFieldIndex + scrollToColumnModifier}
/>
</DebouncedFrame>
<TabHintToastContainer
isVisible={isEditingMetadata && isTabHintVisible}
>
<TabHintToast onClose={hideTabHint} />
</TabHintToastContainer>
</TableContainer>
</MainContainer>
<ViewSidebar side="right" isOpen={!!sidebar}>
......
......@@ -3,6 +3,19 @@ import EditBar from "metabase/components/EditBar";
import { color } from "metabase/lib/colors";
import { breakpointMinSmall, space } from "metabase/styled-components/theme";
export const TabHintToastContainer = styled.div`
position: fixed;
bottom: 16px;
left: 24px;
transform: translateY(200%);
transition: all 0.4s;
${props =>
props.isVisible &&
css`
transform: translateY(0);
`}
`;
export const DatasetEditBar = styled(EditBar)`
background-color: ${color("nav")};
`;
......
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { connect } from "react-redux";
import PropTypes from "prop-types";
import { t } from "ttag";
......@@ -14,8 +20,10 @@ import {
has_field_values_options,
} from "metabase/lib/core";
import { keyForColumn } from "metabase/lib/dataset";
import { isSameField } from "metabase/lib/query/field_ref";
import RootForm from "metabase/containers/Form";
import { usePrevious } from "metabase/hooks/use-previous";
import SidebarContent from "metabase/query_builder/components/SidebarContent";
import ColumnSettings, {
......@@ -25,9 +33,11 @@ import { getGlobalSettingsForColumn } from "metabase/visualizations/lib/settings
import { updateCardVisualizationSettings } from "metabase/query_builder/actions";
import { EDITOR_TAB_INDEXES } from "../constants";
import MappedFieldPicker from "./MappedFieldPicker";
import SemanticTypePicker from "./SemanticTypePicker";
import {
AnimatableContent,
MainFormContainer,
SecondaryFormContainer,
FormTabsContainer,
......@@ -38,7 +48,9 @@ import {
const propTypes = {
dataset: PropTypes.object.isRequired,
field: PropTypes.instanceOf(Field),
isLastField: PropTypes.bool.isRequired,
IDFields: PropTypes.array.isRequired,
handleFirstFieldFocus: PropTypes.func.isRequired,
updateCardVisualizationSettings: PropTypes.func.isRequired,
};
......@@ -61,31 +73,6 @@ function getVisibilityTypeName(visibilityType) {
return visibilityType.name;
}
function getFieldSemanticTypeSections() {
const types = [
...field_semantic_types,
{
id: null,
name: t`No special type`,
section: t`Other`,
},
];
const groupedBySection = _.groupBy(types, "section");
return Object.entries(groupedBySection).map(entry => {
const [name, items] = entry;
return {
name,
items: items.map(item => ({
value: item.id,
name: item.name,
description: item.description,
})),
};
});
}
function getFormFields({ dataset, IDFields }) {
const visibilityTypeOptions = field_visibility_types
.filter(type => type.id !== "sensitive")
......@@ -102,7 +89,14 @@ function getFormFields({ dataset, IDFields }) {
return (
<SemanticTypePicker
{...formFieldProps}
sections={getFieldSemanticTypeSections()}
options={[
...field_semantic_types,
{
id: null,
name: t`No special type`,
section: t`Other`,
},
]}
IDFields={IDFields}
/>
);
......@@ -164,9 +158,27 @@ const TAB_OPTIONS = [
function DatasetFieldMetadataSidebar({
dataset,
field,
isLastField,
IDFields,
handleFirstFieldFocus,
updateCardVisualizationSettings,
}) {
const displayNameInputRef = useRef();
const [shouldAnimateFieldChange, setShouldAnimateFieldChange] = useState(
false,
);
const previousField = usePrevious(field);
useEffect(() => {
if (field && !isSameField(field?.field_ref, previousField?.field_ref)) {
setShouldAnimateFieldChange(true);
// setTimeout is required as form fields are rerendered pretty frequently
setTimeout(() => {
displayNameInputRef.current.select();
});
}
}, [field, previousField]);
const initialValues = useMemo(() => {
const values = {
display_name: field?.display_name,
......@@ -242,57 +254,93 @@ function DatasetFieldMetadataSidebar({
}
}, [tab, hasColumnFormattingOptions]);
const onLastEssentialFieldKeyDown = useCallback(
e => {
const isNextFieldAction = !e.shiftKey && e.key === "Tab";
if (isNextFieldAction && isLastField) {
e.preventDefault();
handleFirstFieldFocus();
}
},
[isLastField, handleFirstFieldFocus],
);
const onFieldChangeAnimationEnd = useCallback(() => {
setShouldAnimateFieldChange(false);
}, []);
return (
<SidebarContent>
{field && (
<RootForm
fields={formFields}
initialValues={initialValues}
overwriteOnInitialValuesChange
>
{({ Form, FormField }) => (
<Form>
<MainFormContainer>
<FormField name="display_name" />
<FormField name="description" />
{dataset.isNative() && <FormField name="id" />}
<FormField name="semantic_type" />
</MainFormContainer>
{hasColumnFormattingOptions && (
<FormTabsContainer>
<Radio
value={tab}
options={TAB_OPTIONS}
onChange={setTab}
variant="underlined"
py={1}
<AnimatableContent
animated={shouldAnimateFieldChange}
onAnimationEnd={onFieldChangeAnimationEnd}
>
{field && (
<RootForm
fields={formFields}
initialValues={initialValues}
overwriteOnInitialValuesChange
>
{({ Form, FormField }) => (
<Form>
<MainFormContainer>
<FormField
name="display_name"
ref={displayNameInputRef}
tabIndex={EDITOR_TAB_INDEXES.ESSENTIAL_FORM_FIELD}
/>
<FormField
name="description"
tabIndex={EDITOR_TAB_INDEXES.ESSENTIAL_FORM_FIELD}
/>
</FormTabsContainer>
)}
<Divider />
<SecondaryFormContainer>
{tab === TAB.SETTINGS ? (
<React.Fragment>
<FormField name="visibility_type" />
<ViewAsFieldContainer>
<ColumnSettings
{...columnSettingsProps}
allowlist={VIEW_AS_RELATED_FORMATTING_OPTIONS}
/>
</ViewAsFieldContainer>
<FormField name="has_field_values" />
</React.Fragment>
) : (
<ColumnSettings
{...columnSettingsProps}
denylist={HIDDEN_COLUMN_FORMATTING_OPTIONS}
{dataset.isNative() && (
<FormField
name="id"
tabIndex={EDITOR_TAB_INDEXES.ESSENTIAL_FORM_FIELD}
/>
)}
<FormField
name="semantic_type"
tabIndex={EDITOR_TAB_INDEXES.ESSENTIAL_FORM_FIELD}
onKeyDown={onLastEssentialFieldKeyDown}
/>
</MainFormContainer>
{hasColumnFormattingOptions && (
<FormTabsContainer>
<Radio
value={tab}
options={TAB_OPTIONS}
onChange={setTab}
variant="underlined"
py={1}
/>
</FormTabsContainer>
)}
</SecondaryFormContainer>
</Form>
)}
</RootForm>
)}
<Divider />
<SecondaryFormContainer>
{tab === TAB.SETTINGS ? (
<React.Fragment>
<FormField name="visibility_type" />
<ViewAsFieldContainer>
<ColumnSettings
{...columnSettingsProps}
allowlist={VIEW_AS_RELATED_FORMATTING_OPTIONS}
/>
</ViewAsFieldContainer>
<FormField name="has_field_values" />
</React.Fragment>
) : (
<ColumnSettings
{...columnSettingsProps}
denylist={HIDDEN_COLUMN_FORMATTING_OPTIONS}
/>
)}
</SecondaryFormContainer>
</Form>
)}
</RootForm>
)}
</AnimatableContent>
</SidebarContent>
);
}
......
import styled from "styled-components";
import styled, { css, keyframes } from "styled-components";
import Radio from "metabase/components/Radio";
import { color } from "metabase/lib/colors";
const slideInOutAnimation = keyframes`
0% { transform: translateY(0%); opacity: 1; }
50% { transform: translateY(1%); opacity: 0.5; }
100% { transform: translateY(2%); opacity: 0; }
`;
export const AnimatableContent = styled.div`
${props =>
props.animated &&
css`
animation-name: ${slideInOutAnimation};
animation-duration: 0.15s;
animation-iteration-count: 2;
animation-direction: alternate;
animation-timing-function: ease-in;
`}
`;
const CONTENT_PADDING = "24px";
const FormContainer = styled.div`
......@@ -11,6 +29,12 @@ const FormContainer = styled.div`
.AdminSelect {
color: ${color("text-dark")};
transition: border 0.3s;
outline: none;
}
.AdminSelect:focus {
border-color: ${color("brand")};
}
`;
......
import styled from "styled-components";
import SelectButton from "metabase/components/SelectButton";
export const StyledSelectButton = styled(SelectButton)`
import { forwardRefToInnerRef } from "metabase/styled-components/utils";
export const StyledSelectButton = forwardRefToInnerRef(styled(SelectButton)`
width: 100%;
`;
`);
import React from "react";
import React, { useCallback, useRef } from "react";
import { t } from "ttag";
import _ from "underscore";
import { FieldSelector } from "metabase/query_builder/components/DataSelector";
import { SchemaTableAndFieldDataSelector } from "metabase/query_builder/components/DataSelector";
import Question from "metabase-lib/lib/Question";
import Field from "metabase-lib/lib/metadata/Field";
import { StyledSelectButton } from "./MappedFieldPicker.styled";
type FieldObject = {
display_name: string;
table: {
display_name: string;
};
};
type CollapsedPickerProps = {
selectedField?: Field;
isTriggeredComponentOpen: boolean;
open: () => void;
close: () => void;
};
function MappedFieldPickerTrigger({ selectedField }: CollapsedPickerProps) {
const label = selectedField
? selectedField.displayName({ includeTable: true })
: t`None`;
return (
<StyledSelectButton hasValue={!!selectedField}>{label}</StyledSelectButton>
);
}
type MappedFieldPickerProps = {
dataset: Question;
tabIndex?: number;
};
function MappedFieldPicker({ dataset }: MappedFieldPickerProps) {
function MappedFieldPicker({ dataset, tabIndex }: MappedFieldPickerProps) {
const selectButtonRef = useRef<HTMLDivElement>();
const focusSelectButton = useCallback(() => {
selectButtonRef.current?.focus();
}, []);
const onFieldChange = useCallback(fieldId => {
selectButtonRef.current?.focus();
}, []);
const renderTriggerElement = useCallback(
({ selectedField, open }: CollapsedPickerProps) => {
const label = selectedField
? selectedField.displayName({ includeTable: true })
: t`None`;
return (
<StyledSelectButton
hasValue={!!selectedField}
tabIndex={tabIndex}
onKeyUp={e => {
if (e.key === "Enter") {
open();
}
}}
ref={selectButtonRef}
>
{label}
</StyledSelectButton>
);
},
[tabIndex],
);
return (
<FieldSelector
<SchemaTableAndFieldDataSelector
className="flex flex-full justify-center align-center"
selectedDatabaseId={dataset.databaseId()}
getTriggerElementContent={MappedFieldPickerTrigger}
getTriggerElementContent={renderTriggerElement}
hasTriggerExpandControl={false}
triggerTabIndex={tabIndex}
setFieldFn={onFieldChange}
onClose={focusSelectButton}
/>
);
}
......
import React, { useCallback, useMemo } from "react";
import React, { useCallback, useMemo, useRef } from "react";
import PropTypes from "prop-types";
import { t } from "ttag";
import AccordionList from "metabase/components/AccordionList";
import SelectButton from "metabase/components/SelectButton";
import Select from "metabase/components/Select";
import { isCurrency, isFK } from "metabase/lib/schema_metadata";
import { useToggle } from "metabase/hooks/use-toggle";
import CurrencyPicker from "./CurrencyPicker";
import FKTargetPicker from "./FKTargetPicker";
import {
CloseButton,
SearchSectionContainer,
StyledSelectButton,
ExtraSelectContainer,
} from "./SemanticTypePicker.styled";
const sectionItemShape = PropTypes.shape({
name: PropTypes.string.isRequired,
value: PropTypes.any,
description: PropTypes.string,
});
const sectionShape = PropTypes.shape({
name: PropTypes.string.isRequired,
items: PropTypes.arrayOf(sectionItemShape).isRequired,
});
const propTypes = {
field: PropTypes.shape({
value: PropTypes.any,
field_ref: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
}).isRequired,
sections: PropTypes.arrayOf(sectionShape).isRequired,
options: PropTypes.array.isRequired,
IDFields: PropTypes.array.isRequired, // list of PK / FK fields in dataset DB
tabIndex: PropTypes.string,
onKeyDown: PropTypes.func,
};
function SemanticTypePicker({ field, sections, IDFields }) {
const [
isPickerOpen,
{ turnOn: openPicker, turnOff: closePicker },
] = useToggle(false);
function SemanticTypePicker({ field, options, IDFields, tabIndex, onKeyDown }) {
const selectButtonRef = useRef();
const focusSelectButton = useCallback(() => {
selectButtonRef.current?.focus();
}, []);
const onChange = useCallback(
item => {
field.onChange(item.value);
closePicker();
field.onChange(item.id);
selectButtonRef.current?.focus();
},
[field, closePicker],
[field],
);
const checkIsItemSelected = useCallback(item => item.value === field.value, [
field,
]);
const pickerLabel = useMemo(() => {
const items = sections.flatMap(section => section.items);
const item = items.find(item => item.value === field.value);
const item = options.find(item => item.id === field.value);
return item?.name ?? t`None`;
}, [field, sections]);
const renderSearchSection = useCallback(
searchInput => (
<SearchSectionContainer>
{searchInput}
<CloseButton onClick={closePicker} />
</SearchSectionContainer>
),
[closePicker],
);
}, [field, options]);
const renderExtraSelect = useCallback(() => {
const pseudoField = { semantic_type: field.value };
......@@ -96,37 +71,43 @@ function SemanticTypePicker({ field, sections, IDFields }) {
return null;
}, [field, IDFields]);
if (isPickerOpen) {
return (
<React.Fragment>
<AccordionList
className="MB-Select text-brand"
sections={sections}
alwaysExpanded
itemIsSelected={checkIsItemSelected}
onChange={onChange}
searchable
searchFuzzy={false}
searchProp="name"
searchPlaceholder={t`Search for a special type`}
hideEmptySectionsInSearch
renderSearchSection={renderSearchSection}
maxHeight={350}
/>
{renderExtraSelect()}
</React.Fragment>
);
}
const renderSelectButton = useCallback(
({ open }) => {
const handleKeyUp = e => {
if (e.key === "Enter") {
open();
}
};
return (
<StyledSelectButton
hasValue={!!field.value}
onKeyUp={handleKeyUp}
onKeyDown={onKeyDown}
tabIndex={tabIndex}
ref={selectButtonRef}
>
{pickerLabel}
</StyledSelectButton>
);
},
[field, tabIndex, pickerLabel, onKeyDown],
);
return (
<React.Fragment>
<SelectButton
className="cursor-pointer"
hasValue={!!field.value}
onClick={openPicker}
>
{pickerLabel}
</SelectButton>
<Select
value={field.value}
options={options}
onChange={onChange}
optionValueFn={o => o.id}
optionSectionFn={o => o.section}
placeholder={t`Select a semantic type`}
searchProp="name"
searchPlaceholder={t`Search for a special type`}
triggerElement={renderSelectButton}
onClose={focusSelectButton}
/>
{renderExtraSelect()}
</React.Fragment>
);
......
import styled from "styled-components";
import Button from "metabase/components/Button";
import SelectButton from "metabase/components/SelectButton";
import { space } from "metabase/styled-components/theme";
import { forwardRefToInnerRef } from "metabase/styled-components/utils";
export const SearchSectionContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`;
export const CloseButton = styled(Button).attrs({
icon: "close",
onlyIcon: true,
})`
margin-left: ${space(1)};
`;
export const StyledSelectButton = forwardRefToInnerRef(styled(SelectButton)`
width: 100%;
`);
export const ExtraSelectContainer = styled.div`
margin-top: 1em;
......
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