diff --git a/frontend/src/metabase/components/TokenField.jsx b/frontend/src/metabase/components/TokenField.jsx index 83f888c72eee2312b82c8bcda6232714e36bfb8a..3c161d1f8870506312e73a04339d39b041896eb6 100644 --- a/frontend/src/metabase/components/TokenField.jsx +++ b/frontend/src/metabase/components/TokenField.jsx @@ -452,6 +452,8 @@ export default class TokenField extends Component { } componentDidUpdate(prevProps: Props, prevState: State) { + const input = this.inputRef.current; + if ( prevState.selectedOptionValue !== this.state.selectedOptionValue && this.scrollElement != null @@ -461,13 +463,22 @@ export default class TokenField extends Component { element.scrollIntoView({ block: "nearest" }); } } + // if we added a value then scroll to the last item (the input) if (this.props.value.length > prevProps.value.length) { - const input = this.inputRef.current; if (input && isObscured(input)) { input.scrollIntoView({ block: "nearest" }); } } + + // We focus on the input here, and not on the input itself as a prop + // (say by passing prop autoFocus={isFocused}) + // because certain TokenFields will live in position: fixed containers. + // Autofocusing like that would make the page jump in scroll position. + // One example: parameter filters in dashboard pages. + if (this.state.isFocused) { + input.focus({ preventScroll: true }); + } } render() { @@ -575,7 +586,6 @@ export default class TokenField extends Component { size={10} placeholder={placeholder} value={inputValue} - autoFocus={isFocused} onKeyDown={this.onInputKeyDown} onChange={this.onInputChange} onFocus={this.onInputFocus} diff --git a/frontend/src/metabase/css/dashboard.css b/frontend/src/metabase/css/dashboard.css index 37a03c4f785a8bcf21b4c9a18c4a144139af41d7..fb232e14351c16c90cfab148965fd2d1cdc467f8 100644 --- a/frontend/src/metabase/css/dashboard.css +++ b/frontend/src/metabase/css/dashboard.css @@ -95,10 +95,6 @@ transition: background-color 1s linear, border 1s linear; } -.DashboardGrid { - margin-top: 1em; -} - .Dash--editing { margin-top: 1.5em; } diff --git a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx index 3a666925444c40fe4a3af553fb6af79ae8c0ef44..c620c85adb05f179ec268374c506f519b3381e9d 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx +++ b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx @@ -18,6 +18,9 @@ import { import DashboardGrid from "../DashboardGrid"; import ParametersWidget from "./ParametersWidget/ParametersWidget"; import DashboardEmptyState from "./DashboardEmptyState/DashboardEmptyState"; +import { updateParametersWidgetStickiness } from "./stickyParameters"; + +const SCROLL_THROTTLE_INTERVAL = 1000 / 24; // NOTE: move DashboardControls HoC to container @DashboardControls @@ -25,6 +28,7 @@ export default class Dashboard extends Component { state = { error: null, showAddQuestionSidebar: false, + isParametersWidgetSticky: false, }; static propTypes = { @@ -74,9 +78,27 @@ export default class Dashboard extends Component { isSharing: false, }; + constructor(props) { + super(props); + this.parametersWidgetRef = React.createRef(); + this.parametersAndCardsContainerRef = React.createRef(); + } + // NOTE: all of these lifecycle methods should be replaced with DashboardData HoC in container componentDidMount() { this.loadDashboard(this.props.dashboardId); + + const throttleParameterWidgetStickiness = _.throttle( + () => updateParametersWidgetStickiness(this), + SCROLL_THROTTLE_INTERVAL, + ); + + window.addEventListener("scroll", throttleParameterWidgetStickiness, { + passive: true, + }); + window.addEventListener("resize", throttleParameterWidgetStickiness, { + passive: true, + }); } UNSAFE_componentWillReceiveProps(nextProps) { @@ -92,6 +114,9 @@ export default class Dashboard extends Component { componentWillUnmount() { this.props.cancelFetchDashboardCardData(); + + window.removeEventListener("scroll", updateParametersWidgetStickiness); + window.removeEventListener("resize", updateParametersWidgetStickiness); } async loadDashboard(dashboardId) { @@ -169,7 +194,11 @@ export default class Dashboard extends Component { isSharing, } = this.props; - const { error, showAddQuestionSidebar } = this.state; + const { + error, + isParametersWidgetSticky, + showAddQuestionSidebar, + } = this.state; const shouldRenderAsNightMode = isNightMode && isFullscreen; const dashboardHasCards = dashboard => dashboard.ordered_cards.length > 0; @@ -209,9 +238,16 @@ export default class Dashboard extends Component { </HeaderContainer> <DashboardBody isEditingOrSharing={isEditing || isSharing}> - <ParametersAndCardsContainer> + <ParametersAndCardsContainer + innerRef={element => + (this.parametersAndCardsContainerRef = element) + } + > {!isFullscreen && parametersWidget && ( - <ParametersWidgetContainer> + <ParametersWidgetContainer + innerRef={element => (this.parametersWidgetRef = element)} + isSticky={isParametersWidgetSticky} + > {parametersWidget} </ParametersWidgetContainer> )} diff --git a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.styled.js b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.styled.js index 6d162daff3589ea3d0c203a34be7911932b65f41..d96e26fce335bf7676dc7ff446e7e2e72e2fea1a 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.styled.js +++ b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.styled.js @@ -75,8 +75,19 @@ export const ParametersAndCardsContainer = styled.div` export const ParametersWidgetContainer = styled(FullWidthContainer)` align-items: flex-start; background-color: ${color("bg-light")}; + border-bottom: 1px solid ${color("bg-light")}; display: flex; flex-direction: column; padding-top: ${space(2)}; padding-bottom: ${space(1)}; + z-index: 4; + + ${({ isSticky }) => + isSticky && + css` + border-bottom: 1px solid ${color("border")}; + position: fixed; + top: 0; + left: 0; + `} `; diff --git a/frontend/src/metabase/dashboard/components/Dashboard/stickyParameters.js b/frontend/src/metabase/dashboard/components/Dashboard/stickyParameters.js new file mode 100644 index 0000000000000000000000000000000000000000..d4b61176a22e4e7b2d304c3409de84da3cc70449 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/Dashboard/stickyParameters.js @@ -0,0 +1,48 @@ +export const updateParametersWidgetStickiness = dashboard => { + initializeWidgetOffsetTop(dashboard); + + const shouldBeSticky = checkIfParametersWidgetShouldBeSticky(dashboard); + + const shouldToggleStickiness = checkIfShouldToggleStickiness( + dashboard, + shouldBeSticky, + ); + + if (shouldToggleStickiness) { + updateParametersAndCardsContainerStyle(dashboard, shouldBeSticky); + + dashboard.setState({ + isParametersWidgetSticky: shouldBeSticky, + }); + } +}; + +const initializeWidgetOffsetTop = dashboard => { + if (!dashboard.state.parametersWidgetOffsetTop) { + dashboard.setState({ + parametersWidgetOffsetTop: dashboard.parametersWidgetRef.offsetTop, + }); + } +}; + +const checkIfShouldToggleStickiness = (dashboard, shouldBeSticky) => { + const { isParametersWidgetSticky } = dashboard.state; + + return shouldBeSticky !== isParametersWidgetSticky; +}; + +const checkIfParametersWidgetShouldBeSticky = dashboard => { + const offsetTop = + dashboard.state.parametersWidgetOffsetTop || + dashboard.parametersWidgetRef.offsetTop; + + return window.scrollY >= offsetTop; +}; + +const updateParametersAndCardsContainerStyle = (dashboard, shouldBeSticky) => { + const { offsetHeight } = dashboard.parametersWidgetRef; + + const paddingTop = shouldBeSticky ? offsetHeight + "px" : "0"; + + dashboard.parametersAndCardsContainerRef.style.paddingTop = paddingTop; +}; diff --git a/frontend/src/metabase/dashboard/components/Dashboard/stickyParameters.unit.spec.js b/frontend/src/metabase/dashboard/components/Dashboard/stickyParameters.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b7fb94f8538da786055bc76ab4b24fd05ae3652b --- /dev/null +++ b/frontend/src/metabase/dashboard/components/Dashboard/stickyParameters.unit.spec.js @@ -0,0 +1,95 @@ +import { updateParametersWidgetStickiness } from "./stickyParameters"; + +it("initializes parametersWidgetOffsetTop", () => { + const offsetTop = 100; + const setState = jest.fn(); + + const dashboard = { + parametersWidgetRef: { offsetTop }, + parametersAndCardsContainerRef: { style: {} }, + state: {}, + setState, + }; + + updateParametersWidgetStickiness(dashboard); + + expect(setState).toHaveBeenCalledWith({ + parametersWidgetOffsetTop: offsetTop, + }); +}); + +it("makes filters sticky with enough scrolling down", () => { + const offsetTop = 100; + const setState = jest.fn(); + + global.window.scrollY = offsetTop + 1; + + const dashboard = { + parametersWidgetRef: { offsetTop }, + parametersAndCardsContainerRef: { style: {} }, + state: {}, + setState, + }; + + updateParametersWidgetStickiness(dashboard); + + expect(setState).toHaveBeenCalledWith({ + isParametersWidgetSticky: true, + }); +}); + +it("makes filters unsticky with enough scrolling up", () => { + const offsetTop = 100; + const setState = jest.fn(); + + global.window.scrollY = offsetTop - 1; + + const dashboard = { + parametersWidgetRef: { offsetTop }, + parametersAndCardsContainerRef: { style: {} }, + state: {}, + setState, + }; + + updateParametersWidgetStickiness(dashboard); + + expect(setState).toHaveBeenCalledWith({ + isParametersWidgetSticky: false, + }); +}); + +it("keeps filters sticky with enough scrolling down and already sticky", () => { + const offsetTop = 100; + const setState = jest.fn(); + + global.window.scrollY = offsetTop + 1; + + const dashboard = { + parametersWidgetRef: { offsetTop }, + parametersAndCardsContainerRef: { style: {} }, + state: { isParametersWidgetSticky: true, parametersWidgetOffsetTop: 100 }, + setState, + }; + + updateParametersWidgetStickiness(dashboard); + + expect(setState).not.toHaveBeenCalled(); +}); + +it("keeps filters not sticky with enough scrolling up and already not sticky", () => { + const offsetTop = 100; + const setState = jest.fn(); + + global.window.scrollY = offsetTop - 1; + + const dashboard = { + parametersWidgetRef: { offsetTop }, + parametersAndCardsContainerRef: { style: {} }, + state: { isParametersWidgetSticky: false, parametersWidgetOffsetTop: 100 }, + setState, + }; + + updateParametersWidgetStickiness(dashboard); + + expect(setState).not.toHaveBeenCalled(); +}); diff --git a/frontend/src/metabase/parameters/components/ParameterWidget.jsx b/frontend/src/metabase/parameters/components/ParameterWidget.jsx index c2bf020f7ddcd3ae4b6fc6bf9e25a248ffa955ef..080c65497c06585c23ad230f9cd6352b3d4b6cf0 100644 --- a/frontend/src/metabase/parameters/components/ParameterWidget.jsx +++ b/frontend/src/metabase/parameters/components/ParameterWidget.jsx @@ -28,15 +28,21 @@ export default class ParameterWidget extends Component { }; renderPopover(value, setValue, placeholder, isFullscreen) { - const { parameter, editingParameter, commitImmediately } = this.props; - const isEditingParameter = !!( - editingParameter && editingParameter.id === parameter.id - ); + const { + dashboard, + parameter, + editingParameter, + commitImmediately, + parameters, + } = this.props; + + const isEditingParameter = editingParameter?.id === parameter.id; + return ( <ParameterValueWidget parameter={parameter} - parameters={this.props.parameters} - dashboard={this.props.dashboard} + parameters={parameters} + dashboard={dashboard} name={name} value={value} setValue={setValue} diff --git a/frontend/test/metabase-visual/dashboard/parameters-widget.cy.spec.js b/frontend/test/metabase-visual/dashboard/parameters-widget.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8c75997eb94d1e98bd36012a5283f6479f7b3f1c --- /dev/null +++ b/frontend/test/metabase-visual/dashboard/parameters-widget.cy.spec.js @@ -0,0 +1,51 @@ +import { restore } from "__support__/e2e/cypress"; +import { SAMPLE_DATASET } from "__support__/e2e/cypress_sample_dataset"; + +const { PRODUCTS_ID } = SAMPLE_DATASET; + +const questionDetails = { + query: { "source-table": PRODUCTS_ID }, +}; + +const filter = { + name: "Category", + slug: "category", + id: "ad1c877e", + type: "category", +}; + +const filters = new Array(12).fill(filter); + +describe("visual tests > dashboard > parameters widget", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + + cy.createQuestionAndDashboard({ questionDetails }).then( + ({ body: oldCard }) => { + const { dashboard_id } = oldCard; + + cy.request("PUT", `/api/dashboard/${dashboard_id}`, { + parameters: filters, + }); + + const updatedSize = { + sizeX: 12, + sizeY: 32, + }; + + cy.editDashboardCard(oldCard, updatedSize); + + cy.visit(`/dashboard/${dashboard_id}`); + }, + ); + }); + + it("is sticky in view mode", () => { + cy.findByText("test question"); + + cy.scrollTo(0, 264); + + cy.percySnapshot(); + }); +}); diff --git a/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js b/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js index 62b0d99a61d15e0e65db357d1e84cfb550ca42c3..56dc14d4584f469ecaf9606ccb1c00f476ec11be 100644 --- a/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js +++ b/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js @@ -216,14 +216,16 @@ describe("scenarios > dashboard", () => { }); it("should display column options for cross-filter (metabase#14473)", () => { - cy.createNativeQuestion({ + const questionDetails = { name: "14473", native: { query: "SELECT COUNT(*) FROM PRODUCTS", "template-tags": {} }, - }).then(({ body: { id: QUESTION_ID } }) => { - cy.createDashboard().then(({ body: { id: DASHBOARD_ID } }) => { + }; + + cy.createNativeQuestionAndDashboard({ questionDetails }).then( + ({ body: { dashboard_id } }) => { cy.log("Add 4 filters to the dashboard"); - cy.request("PUT", `/api/dashboard/${DASHBOARD_ID}`, { + cy.request("PUT", `/api/dashboard/${dashboard_id}`, { parameters: [ { name: "ID", slug: "id", id: "729b6456", type: "id" }, { name: "ID 1", slug: "id_1", id: "bb20f59e", type: "id" }, @@ -242,14 +244,9 @@ describe("scenarios > dashboard", () => { ], }); - cy.log("Add previously created question to the dashboard"); - cy.request("POST", `/api/dashboard/${DASHBOARD_ID}/cards`, { - cardId: QUESTION_ID, - }); - - cy.visit(`/dashboard/${DASHBOARD_ID}`); - }); - }); + cy.visit(`/dashboard/${dashboard_id}`); + }, + ); // Add cross-filter click behavior manually cy.icon("pencil").click(); @@ -460,6 +457,6 @@ function assertScrollBarExists() { const bodyWidth = $body[0].getBoundingClientRect().width; cy.window() .its("innerWidth") - .should("be.gt", bodyWidth); + .should("be.gte", bodyWidth); }); }