diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.styled.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f032c8e52cef664ad95038213fd3f67eafbde04c --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.styled.tsx @@ -0,0 +1,52 @@ +import styled from "@emotion/styled"; +import Button from "metabase/core/components/Button"; +import Input from "metabase/core/components/Input"; + +import { color } from "metabase/lib/colors"; + +export const QuestionCacheSectionRoot = styled.div` + display: flex; + flex-direction: column; + + ${Button.Root} { + padding: 0; + } + + ${Button.Content} { + justify-content: start; + } +`; + +export const Text = styled.span` + font-weight: 700; + font-size: 0.875rem; + line-height: 1rem; + margin: 0.5rem 0rem; +`; + +export const CachePopover = styled.div` + padding: 1.5rem; + display: flex; + flex-direction: column; + align-items: end; + + ${Text} { + margin-top: 0; + margin-bottom: 1.5rem; + } + + ${Input.Field} { + width: 40px; + height: 32px; + font-size: 0.875rem; + line-height: 1rem; + padding: 0.625rem; + margin: 0 0.5rem; + + border: 1px solid ${color("border")}; + } + + ${Button.Root} { + width: 120px; + } +`; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5e70d54ae60409f9232d3730a094c328947822a7 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.tsx @@ -0,0 +1,77 @@ +import React, { useCallback, useState } from "react"; +import { t } from "ttag"; + +import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger"; +import Button from "metabase/core/components/Button"; +import ActionButton from "metabase/components/ActionButton"; +import NumericInput from "metabase/core/components/NumericInput"; + +import Question from "metabase-lib/lib/Question"; +import { color } from "metabase/lib/colors"; + +import { normalizeCacheTTL } from "../../utils"; + +import { + Text, + QuestionCacheSectionRoot, + CachePopover, +} from "./QuestionCacheSection.styled"; + +interface QuestionCacheSectionProps { + question: Question; + onSave: (cache_ttl: number | null) => Promise<Question>; +} + +export const QuestionCacheSection = ({ + question, + onSave, +}: QuestionCacheSectionProps) => { + const [cacheTTL, setCacheTTL] = useState<number | null>(question.cacheTTL()); + + const handleChange = useCallback( + number => { + setCacheTTL(normalizeCacheTTL(number)); + }, + [setCacheTTL], + ); + + const handleSave = useCallback(async () => { + return await onSave(cacheTTL); + }, [onSave, cacheTTL]); + + return ( + <QuestionCacheSectionRoot> + <TippyPopoverWithTrigger + key="extra-actions-menu" + placement="bottom-start" + renderTrigger={({ onClick, visible }) => ( + <Button + borderless + color={color("brand")} + onClick={onClick} + iconRight={visible ? "chevronup" : "chevrondown"} + > + {t`Cache Configuration`} + </Button> + )} + popoverContent={ + <CachePopover> + <Text> + {t`Cache results for`} + <NumericInput + placeholder="24" + value={cacheTTL || ""} + onChange={handleChange} + /> + {t`hours`} + </Text> + <ActionButton + primary + actionFn={handleSave} + >{t`Save changes`}</ActionButton> + </CachePopover> + } + /> + </QuestionCacheSectionRoot> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.js b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.js new file mode 100644 index 0000000000000000000000000000000000000000..4a9bc9fc8c5ecfafa8c0b432e8dca6e4ff165b78 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.js @@ -0,0 +1 @@ +export * from "./QuestionCacheSection"; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/index.js b/enterprise/frontend/src/metabase-enterprise/caching/index.js index 10e3c9694be8d8b3ebd09448497d98ba949b7a6d..4ff9b9c3dec62fc1b34cc53a4e0166eb97e9b050 100644 --- a/enterprise/frontend/src/metabase-enterprise/caching/index.js +++ b/enterprise/frontend/src/metabase-enterprise/caching/index.js @@ -6,6 +6,8 @@ import Link from "metabase/core/components/Link"; import { CacheTTLField } from "./components/CacheTTLField"; import { DatabaseCacheTTLField } from "./components/DatabaseCacheTTLField"; import { QuestionCacheTTLField } from "./components/QuestionCacheTTLField"; +import { QuestionCacheSection } from "./components/QuestionCacheSection"; + import { getQuestionsImplicitCacheTTL, validateCacheTTL, @@ -49,4 +51,6 @@ if (hasPremiumFeature("advanced_config")) { PLUGIN_FORM_WIDGETS.questionCacheTTL = QuestionCacheTTLField; PLUGIN_CACHING.getQuestionsImplicitCacheTTL = getQuestionsImplicitCacheTTL; + PLUGIN_CACHING.QuestionCacheSection = QuestionCacheSection; + PLUGIN_CACHING.isEnabled = () => true; } diff --git a/enterprise/frontend/src/metabase-enterprise/caching/utils.js b/enterprise/frontend/src/metabase-enterprise/caching/utils.js index 525ff74b75e1b5e744b354cf50a383047fdee0a9..0186b40ac14f300bd6173cd27440f95aabe185ba 100644 --- a/enterprise/frontend/src/metabase-enterprise/caching/utils.js +++ b/enterprise/frontend/src/metabase-enterprise/caching/utils.js @@ -52,5 +52,5 @@ export function validateCacheTTL(value) { } export function normalizeCacheTTL(value) { - return value === 0 ? null : value; + return value === 0 || value === undefined ? null : value; } diff --git a/frontend/src/metabase-lib/lib/Question.ts b/frontend/src/metabase-lib/lib/Question.ts index 32de0f573f9f48ff7b004dde055ac98aee8d6813..e5125faa1c33b47de273830f5924087af1a29175 100644 --- a/frontend/src/metabase-lib/lib/Question.ts +++ b/frontend/src/metabase-lib/lib/Question.ts @@ -284,6 +284,14 @@ class QuestionInner { return this.setCard(assoc(this.card(), "display", display)); } + cacheTTL(): number | null { + return this._card?.cache_ttl; + } + + setCacheTTL(cache) { + return this.setCard(assoc(this.card(), "cache_ttl", cache)); + } + /** * returns whether this question is a model * @returns boolean @@ -861,10 +869,14 @@ class QuestionInner { return this.setCard(card); } - description(): string | null | undefined { + description(): string | null { return this._card && this._card.description; } + setDescription(description) { + return this.setCard(assoc(this.card(), "description", description)); + } + lastEditInfo() { return this._card && this._card["last-edit-info"]; } diff --git a/frontend/src/metabase-types/types/Card.ts b/frontend/src/metabase-types/types/Card.ts index a49e8f7e60ac837effb6ec450645d5a8f4a1dce3..56a729116418858a7ae3be84ea8d30bdf359d583 100644 --- a/frontend/src/metabase-types/types/Card.ts +++ b/frontend/src/metabase-types/types/Card.ts @@ -22,11 +22,12 @@ export type UnsavedCard<Query = DatasetQuery> = { export type SavedCard<Query = DatasetQuery> = UnsavedCard<Query> & { id: CardId; name?: string; - description?: string; + description?: string | null; dataset?: boolean; can_write: boolean; public_uuid: string; archived?: boolean; + cache_ttl?: number | null; }; export type Card<Query = DatasetQuery> = SavedCard<Query> | UnsavedCard<Query>; diff --git a/frontend/src/metabase/lib/keyboard.js b/frontend/src/metabase/lib/keyboard.js index 0cbc6a402aeec4d8aa2bbea9f2448490b75b505c..5a189d24c515a9edae34b5a866b4151afbe24014 100644 --- a/frontend/src/metabase/lib/keyboard.js +++ b/frontend/src/metabase/lib/keyboard.js @@ -13,3 +13,4 @@ export const KEY_COMMA = ","; export const KEYCODE_FORWARD_SLASH = 191; export const KEY_ESCAPE = "Escape"; +export const KEY_ENTER = "Enter"; diff --git a/frontend/src/metabase/lib/settings.ts b/frontend/src/metabase/lib/settings.ts index 5e141951bc2aa20bddea44b33ba3983aa4697856..f15a27ccdd54e67640c910bb73afcbfa00bd78f2 100644 --- a/frontend/src/metabase/lib/settings.ts +++ b/frontend/src/metabase/lib/settings.ts @@ -96,7 +96,8 @@ export type SettingName = | "premium-embedding-token" | "metabase-store-managed" | "application-font" - | "available-fonts"; + | "available-fonts" + | "enable-query-caching"; type SettingsMap = Record<SettingName, any>; // provides access to Metabase application settings diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index 3716aa15f9b735c48d84cf3b51b09e9f710b3ec1..2c6a1c851761b39d98ec8c9b019171371c676171 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -128,7 +128,9 @@ export const PLUGIN_CACHING = { dashboardCacheTTLFormField: null, databaseCacheTTLFormField: null, questionCacheTTLFormField: null, - getQuestionsImplicitCacheTTL: () => null, + getQuestionsImplicitCacheTTL: (question?: any) => null, + QuestionCacheSection: PluginPlaceholder, + isEnabled: () => false, }; export const PLUGIN_REDUCERS: { applicationPermissionsPlugin: any } = { diff --git a/frontend/src/metabase/query_builder/components/EditableText/EditableText.stories.tsx b/frontend/src/metabase/query_builder/components/EditableText/EditableText.stories.tsx index cf96f5de8d0ce4f28a419cca805a7977c469c181..26bf85ca73161a7a059039060e2159da7e77cf10 100644 --- a/frontend/src/metabase/query_builder/components/EditableText/EditableText.stories.tsx +++ b/frontend/src/metabase/query_builder/components/EditableText/EditableText.stories.tsx @@ -10,7 +10,8 @@ export default { const Template: ComponentStory<typeof EditableText> = args => { const [{ initialValue }, updateArgs] = useArgs(); - const handleChange = (value: string) => updateArgs({ initialValue: value }); + const handleChange = (value: string | null | undefined) => + updateArgs({ initialValue: value }); console.log(initialValue); diff --git a/frontend/src/metabase/query_builder/components/EditableText/EditableText.styled.tsx b/frontend/src/metabase/query_builder/components/EditableText/EditableText.styled.tsx index 499408f3e81e997979e4b7d536b56b599b679422..e5c86366b38dd8947b65f17e752b52996cb39076 100644 --- a/frontend/src/metabase/query_builder/components/EditableText/EditableText.styled.tsx +++ b/frontend/src/metabase/query_builder/components/EditableText/EditableText.styled.tsx @@ -2,7 +2,7 @@ import styled from "@emotion/styled"; import { color } from "metabase/lib/colors"; import { css } from "@emotion/react"; -const sharedStyle = css` +export const SharedStyles = css` border: 1px solid transparent; border-radius: 0.25rem; padding: 0.5rem; @@ -13,15 +13,19 @@ const sharedStyle = css` color: ${color("text-dark")}; `; -export const EditableTextRoot = styled.div` +interface EditableTextRootProps { + value: string | null; +} + +export const EditableTextRoot = styled.div<EditableTextRootProps>` display: grid; - max-width: 300px; + max-width: 500px; - &::after { - content: attr(data-replicated-value) " "; + &:after { + content: "${props => props.value?.replace(/\n/g, "\\00000a")} "; white-space: pre-wrap; visibility: hidden; - ${sharedStyle} + ${SharedStyles} } `; @@ -39,5 +43,5 @@ export const EditableTextArea = styled.textarea` cursor: text; } - ${sharedStyle} + ${SharedStyles} `; diff --git a/frontend/src/metabase/query_builder/components/EditableText/EditableText.tsx b/frontend/src/metabase/query_builder/components/EditableText/EditableText.tsx index 32634d0093df767d47d4d9499a544019c14b0a8c..7a7c01d3dbb65e4750153e47eb70f11374b5a8be 100644 --- a/frontend/src/metabase/query_builder/components/EditableText/EditableText.tsx +++ b/frontend/src/metabase/query_builder/components/EditableText/EditableText.tsx @@ -1,26 +1,41 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; +import { KEY_ESCAPE, KEY_ENTER } from "metabase/lib/keyboard"; -import { EditableTextRoot, EditableTextArea } from "./EditableText.styled"; - -import { KEY_ESCAPE } from "metabase/lib/keyboard"; - -type TEXT = string | null | undefined; +import { + EditableTextRoot, + EditableTextArea, + SharedStyles, +} from "./EditableText.styled"; interface EditableTextProps { - initialValue: TEXT; - onChange?: (val: string) => void; + initialValue: string | null; + onChange?: (val: string | null) => void; + submitOnEnter?: boolean; + "data-testid"?: string; + placeholder?: string; } -const EditableText = ({ initialValue, onChange }: EditableTextProps) => { - const [value, setValue] = useState<TEXT>(initialValue); +const EditableText = ({ + initialValue, + onChange, + submitOnEnter, + "data-testid": dataTestid, + placeholder, +}: EditableTextProps) => { + const [value, setValue] = useState<string | null>(initialValue); + const textArea = useRef<HTMLTextAreaElement>(null); const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { setValue(e.target.value); }; const handleBlur = (e: React.ChangeEvent<HTMLTextAreaElement>) => { - if (onChange) { - onChange(e.target.value); + const { + target: { value }, + } = e; + + if (onChange && value !== initialValue) { + onChange(value); } }; @@ -28,19 +43,31 @@ const EditableText = ({ initialValue, onChange }: EditableTextProps) => { if (e.key === KEY_ESCAPE) { setValue(initialValue); } + if (e.key === KEY_ENTER && submitOnEnter) { + textArea.current?.blur(); + e.preventDefault(); + } }; return ( - <EditableTextRoot data-replicated-value={value}> + <EditableTextRoot value={value}> <EditableTextArea - placeholder="Description" - value={value || undefined} + placeholder={placeholder} + value={value || ""} onChange={handleChange} onBlur={handleBlur} onKeyDown={handleKeyDown} + rows={1} + cols={1} + ref={textArea} + data-testid={dataTestid} /> </EditableTextRoot> ); }; -export default EditableText; +export default Object.assign(EditableText, { + SharedStyles, + Root: EditableTextRoot, + TextArea: EditableTextArea, +}); diff --git a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx index 1e7ff1d39a46d28f4b57627f4a371d511c0487d5..5b9d16eb9d2598b051a311550813bd6f87987f60 100644 --- a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx +++ b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx @@ -1,35 +1,51 @@ import React from "react"; +import { t } from "ttag"; import PropTypes from "prop-types"; import { PLUGIN_MODERATION } from "metabase/plugins"; -import { HeaderButton } from "./SavedQuestionHeaderButton.styled"; -export default SavedQuestionHeaderButton; +import { color } from "metabase/lib/colors"; + +import EditableText from "../EditableText"; +import { + HeaderRoot, + HeaderReviewIcon, +} from "./SavedQuestionHeaderButton.styled"; SavedQuestionHeaderButton.propTypes = { className: PropTypes.string, question: PropTypes.object.isRequired, - onClick: PropTypes.func.isRequired, - isActive: PropTypes.bool.isRequired, + onSave: PropTypes.func, }; -function SavedQuestionHeaderButton({ className, question, onClick, isActive }) { +const ICON_SIZE = 16; + +function SavedQuestionHeaderButton({ className, question, onSave }) { const { name: reviewIconName, color: reviewIconColor, } = PLUGIN_MODERATION.getStatusIconForQuestion(question); return ( - <HeaderButton - className={className} - onClick={onClick} - icon={reviewIconName} - leftIconColor={reviewIconColor} - isActive={isActive} - iconSize={20} - data-testid="saved-question-header-button" - > - {question.displayName()} - </HeaderButton> + <HeaderRoot> + <EditableText + initialValue={question.displayName()} + onChange={onSave} + submitOnEnter + placeholder={t`A nice title`} + data-testid="saved-question-header-title" + /> + {reviewIconName && ( + <HeaderReviewIcon + name={reviewIconName} + color={color(reviewIconColor)} + size={ICON_SIZE} + /> + )} + </HeaderRoot> ); } + +export default Object.assign(SavedQuestionHeaderButton, { + Root: HeaderRoot, +}); diff --git a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx index c60e65ee432e67548680bf2b68611c19c4e61163..471d21bf6e890c4d510d2dbbd130b16bfa3140da 100644 --- a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx +++ b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx @@ -1,17 +1,20 @@ import styled from "@emotion/styled"; -import { color } from "metabase/lib/colors"; -import Button from "metabase/core/components/Button"; +import EditableText from "../EditableText/EditableText"; +import Icon from "metabase/components/Icon"; -export const HeaderButton = styled(Button)` - font-size: 1.25rem; - border: none; - padding: 0.25rem 0.25rem; - color: ${props => (props.isActive ? color("brand") : "unset")}; - background-color: ${props => (props.isActive ? color("bg-light") : "unset")}; - text-align: left; - - .Icon:not(.Icon-chevrondown) { - color: ${props => color(props.leftIconColor)}; +export const HeaderRoot = styled.div` + ${EditableText.Root}:after, ${EditableText.TextArea} { + font-size: 1.25rem; + font-weight: 700; + line-height: 1.5rem; + padding: 0.25rem; } + + display: flex; + align-items: center; +`; + +export const HeaderReviewIcon = styled(Icon)` + padding-left: 0.25rem; `; diff --git a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.unit.spec.js b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.unit.spec.js index 51902b40436b5928fef0c75d1b4de6b5ada1cb76..10f062b4b2178ba042b242631544e2c475cae341 100644 --- a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.unit.spec.js +++ b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.unit.spec.js @@ -1,26 +1,23 @@ import React from "react"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import SavedQuestionHeaderButton from "./SavedQuestionHeaderButton"; describe("SavedQuestionHeaderButton", () => { - let onClick; + let onSave; let question; let componentContainer; beforeEach(() => { - onClick = jest.fn(); + onSave = jest.fn(); question = { displayName: () => "foo", getModerationReviews: () => [], }; const { container } = render( - <SavedQuestionHeaderButton - question={question} - onClick={onClick} - isActive={false} - />, + <SavedQuestionHeaderButton question={question} onSave={onSave} />, ); componentContainer = container; @@ -30,16 +27,17 @@ describe("SavedQuestionHeaderButton", () => { expect(screen.getByText("foo")).toBeInTheDocument(); }); - it("is clickable", () => { - screen.getByText("foo").click(); - expect(onClick).toHaveBeenCalled(); + it("is updateable", () => { + const title = screen.getByTestId("saved-question-header-title"); + userEvent.type(title, "1"); + title.blur(); + + expect(onSave).toHaveBeenCalled(); }); describe("when the question does not have a latest moderation review", () => { it("should contain no additional icons", () => { - expect( - componentContainer.querySelector(".Icon:not(.Icon-chevrondown)"), - ).toEqual(null); + expect(componentContainer.querySelector(".Icon")).toEqual(null); }); }); @@ -54,20 +52,14 @@ describe("SavedQuestionHeaderButton", () => { }; const { container } = render( - <SavedQuestionHeaderButton - question={question} - onClick={onClick} - isActive={false} - />, + <SavedQuestionHeaderButton question={question} onSave={onSave} />, ); componentContainer = container; }); it("should have an additional icon to signify the question's moderation status", () => { - expect( - componentContainer.querySelector(".Icon:not(.Icon-chevrondown)"), - ).toBeDefined(); + expect(componentContainer.querySelector(".Icon")).toBeDefined(); }); }); }); diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx index b17d42d0b898235aa358e651c0ac12049e1122b8..c5d8142ff62d9b145e5c9a0a1e41884b675b8300 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useCallback } from "react"; +import React, { useEffect, useCallback, useState } from "react"; import PropTypes from "prop-types"; import { t } from "ttag"; import cx from "classnames"; @@ -14,6 +14,7 @@ import ViewButton from "metabase/query_builder/components/view/ViewButton"; import { usePrevious } from "metabase/hooks/use-previous"; import { useToggle } from "metabase/hooks/use-toggle"; +import { useOnMount } from "metabase/hooks/use-on-mount"; import { MODAL_TYPES } from "metabase/query_builder/constants"; import SavedQuestionHeaderButton from "metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton"; @@ -36,7 +37,6 @@ import QuestionActions from "../QuestionActions"; import NativeQueryButton from "./NativeQueryButton"; import { AdHocViewHeading, - DatasetHeaderButtonContainer, SaveButton, SavedQuestionHeaderButtonContainer, ViewHeaderMainLeftContentContainer, @@ -45,6 +45,7 @@ import { ViewSubHeaderRoot, StyledLastEditInfoLabel, StyledQuestionDataSource, + SavedQuestionLeftSideRoot, } from "./ViewHeader.styled"; const viewTitleHeaderPropTypes = { @@ -129,9 +130,7 @@ export function ViewTitleHeader(props) { style={style} data-testid="qb-header" > - {isDataset ? ( - <DatasetLeftSide {...props} /> - ) : isSaved ? ( + {isSaved ? ( <SavedQuestionLeftSide {...props} /> ) : ( <AhHocQuestionLeftSide @@ -168,7 +167,7 @@ SavedQuestionLeftSide.propTypes = { isAdditionalInfoVisible: PropTypes.bool, isShowingQuestionDetailsSidebar: PropTypes.bool, onOpenQuestionInfo: PropTypes.func.isRequired, - onOpenModal: PropTypes.func.isRequired, + onSave: PropTypes.func, }; function SavedQuestionLeftSide(props) { @@ -176,33 +175,45 @@ function SavedQuestionLeftSide(props) { question, isObjectDetail, isAdditionalInfoVisible, - isShowingQuestionDetailsSidebar, onOpenQuestionInfo, - onOpenModal, + onSave, } = props; + const [showSubHeader, setShowSubHeader] = useState(true); + + useOnMount(() => { + const timerId = setTimeout(() => { + setShowSubHeader(false); + }, 4000); + return () => clearTimeout(timerId); + }); + const hasLastEditInfo = question.lastEditInfo() != null; - const onHeaderClick = useCallback(() => { - onOpenModal(MODAL_TYPES.EDIT); - }, [onOpenModal]); + const onHeaderChange = useCallback( + name => { + if (name !== question.displayName()) { + onSave({ + ...question.card(), + name, + }); + } + }, + [question, onSave], + ); return ( - <div> + <SavedQuestionLeftSideRoot + data-testid="qb-header-left-side" + showSubHeader={showSubHeader} + > <ViewHeaderMainLeftContentContainer> <SavedQuestionHeaderButtonContainer> <SavedQuestionHeaderButton question={question} - isActive={isShowingQuestionDetailsSidebar} - onClick={onHeaderClick} + onSave={onHeaderChange} /> </SavedQuestionHeaderButtonContainer> - {hasLastEditInfo && isAdditionalInfoVisible && ( - <StyledLastEditInfoLabel - item={question.card()} - onClick={onOpenQuestionInfo} - /> - )} </ViewHeaderMainLeftContentContainer> {isAdditionalInfoVisible && ( <ViewHeaderLeftSubHeading> @@ -213,9 +224,15 @@ function SavedQuestionLeftSide(props) { subHead /> )} + {hasLastEditInfo && isAdditionalInfoVisible && ( + <StyledLastEditInfoLabel + item={question.card()} + onClick={onOpenQuestionInfo} + /> + )} </ViewHeaderLeftSubHeading> )} - </div> + </SavedQuestionLeftSideRoot> ); } @@ -271,55 +288,6 @@ function AhHocQuestionLeftSide(props) { ); } -DatasetLeftSide.propTypes = { - question: PropTypes.object.isRequired, - isAdditionalInfoVisible: PropTypes.bool, - isShowingQuestionDetailsSidebar: PropTypes.bool, - onOpenModal: PropTypes.func.isRequired, -}; - -function DatasetLeftSide(props) { - const { - question, - isAdditionalInfoVisible, - isShowingQuestionDetailsSidebar, - onOpenModal, - } = props; - - const onHeaderClick = useCallback(() => { - onOpenModal(MODAL_TYPES.EDIT); - }, [onOpenModal]); - - return ( - <div> - <ViewHeaderMainLeftContentContainer> - <AdHocViewHeading> - <HeadBreadcrumbs - divider="/" - parts={[ - ...(isAdditionalInfoVisible - ? [ - <DatasetCollectionBadge - key="collection" - dataset={question} - />, - ] - : []), - <DatasetHeaderButtonContainer key="dataset-header-button"> - <SavedQuestionHeaderButton - question={question} - isActive={isShowingQuestionDetailsSidebar} - onClick={onHeaderClick} - /> - </DatasetHeaderButtonContainer>, - ]} - /> - </AdHocViewHeading> - </ViewHeaderMainLeftContentContainer> - </div> - ); -} - DatasetCollectionBadge.propTypes = { dataset: PropTypes.object.isRequired, }; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx index 6d561e0e646dfd32f0bd2b410c0a94c1e68ba847..6d66f899476dd9d8f867011484b4868fa713d141 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx @@ -8,6 +8,7 @@ import { color, alpha } from "metabase/lib/colors"; import { breakpointMaxSmall, space } from "metabase/styled-components/theme"; import ViewSection, { ViewSubHeading, ViewHeading } from "./ViewSection"; import QuestionDataSource from "./QuestionDataSource"; +import SavedQuestionHeaderButton from "../SavedQuestionHeaderButton/SavedQuestionHeaderButton"; export const ViewHeaderContainer = styled(ViewSection)` border-bottom: 1px solid ${color("border")}; @@ -54,11 +55,6 @@ export const SavedQuestionHeaderButtonContainer = styled.div` right: 0.38rem; `; -export const DatasetHeaderButtonContainer = styled.div` - position: relative; - right: 0.3rem; -`; - export const HeaderButton = styled(Button)` font-size: 0.875rem; background-color: ${({ active, color = getDefaultColor() }) => @@ -123,7 +119,6 @@ export const StyledLastEditInfoLabel = styled(LastEditInfoLabel)` `; export const StyledQuestionDataSource = styled(QuestionDataSource)` - margin-bottom: 0.5rem; padding-right: 1rem; ${breakpointMaxSmall} { @@ -131,3 +126,27 @@ export const StyledQuestionDataSource = styled(QuestionDataSource)` padding-right: 0; } `; + +export const SavedQuestionLeftSideRoot = styled.div` + ${SavedQuestionHeaderButton.Root} { + transition: all 400ms ease; + position: relative; + top: ${props => (props.showSubHeader ? "0" : "10px")}; + } + + ${ViewHeaderLeftSubHeading} { + opacity: ${props => (props.showSubHeader ? "1" : "0")}; + transition: all 400ms ease; + } + + &:hover, + &:focus-within { + ${SavedQuestionHeaderButton.Root} { + top: 0px; + } + + ${ViewHeaderLeftSubHeading} { + opacity: 1; + } + } +`; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.unit.spec.js b/frontend/src/metabase/query_builder/components/view/ViewHeader.unit.spec.js index b8b563556fa7bb2f1659e7bf6aab9b464ca020ad..67bdfb825c65c58edbed657d33683f42627c0116 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.unit.spec.js +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.unit.spec.js @@ -1,5 +1,6 @@ import React from "react"; import xhrMock from "xhr-mock"; +import userEvent from "@testing-library/user-event"; import { fireEvent, renderWithProviders, screen } from "__support__/ui"; import { SAMPLE_DATABASE, @@ -107,6 +108,7 @@ function setup({ onCloseFilter: jest.fn(), onEditSummary: jest.fn(), onCloseSummary: jest.fn(), + onSave: jest.fn(), }; renderWithProviders( @@ -356,9 +358,11 @@ describe("ViewHeader", () => { }); it("opens details sidebar on question name click", () => { - const { onOpenModal } = setup({ question }); - fireEvent.click(screen.getByText(question.displayName())); - expect(onOpenModal).toHaveBeenCalled(); + const { onSave } = setup({ question }); + const title = screen.getByTestId("saved-question-header-title"); + userEvent.type(title, "New Title"); + fireEvent.blur(title); + expect(onSave).toHaveBeenCalled(); }); it("shows bookmark and action buttons", () => { diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar.tsx index b76bafc37e29e99f6f593dcb8e82564d9c1c42fa..daed291534026f153f6bd2f177ce3e55c19a5e0b 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar.tsx @@ -1,37 +1,57 @@ import React from "react"; -import { PLUGIN_MODERATION, PLUGIN_MODEL_PERSISTENCE } from "metabase/plugins"; +import { + PLUGIN_MODERATION, + PLUGIN_MODEL_PERSISTENCE, + PLUGIN_CACHING, +} from "metabase/plugins"; + +import MetabaseSettings from "metabase/lib/settings"; -import EditableText from "../../EditableText"; import QuestionActivityTimeline from "metabase/query_builder/components/QuestionActivityTimeline"; + import Question from "metabase-lib/lib/Question"; import { Card } from "metabase-types/types/Card"; +import EditableText from "../../EditableText"; import { Root, ContentSection } from "./QuestionInfoSidebar.styled"; -interface Props { +interface QuestionInfoSidebarProps { question: Question; - onSave: (card: Card) => void; + onSave: (card: Card) => Promise<Question>; } -export const QuestionInfoSidebar = ({ question, onSave }: Props) => { +export const QuestionInfoSidebar = ({ + question, + onSave, +}: QuestionInfoSidebarProps) => { const description = question.description(); const isDataset = question.isDataset(); const isPersisted = isDataset && question.isPersisted(); - const handleSave = (description: string) => { + const showCaching = + PLUGIN_CACHING.isEnabled() && MetabaseSettings.get("enable-query-caching"); + + const handleSave = (description: string | null) => { if (question.description() !== description) { - onSave({ - ...question.card(), - description, - }); + onSave(question.setDescription(description).card()); + } + }; + + const handleUpdateCacheTTL = (cache_ttl: number | undefined) => { + if (question.cacheTTL() !== cache_ttl) { + return onSave(question.setCacheTTL(cache_ttl).card()); } }; return ( <Root> <ContentSection> - <EditableText initialValue={description} onChange={handleSave} /> + <EditableText + initialValue={description} + onChange={handleSave} + placeholder="Description" + /> <PLUGIN_MODERATION.QuestionModerationSection question={question} /> </ContentSection> @@ -42,6 +62,15 @@ export const QuestionInfoSidebar = ({ question, onSave }: Props) => { /> </ContentSection> )} + + {showCaching && ( + <ContentSection extraPadding> + <PLUGIN_CACHING.QuestionCacheSection + question={question} + onSave={handleUpdateCacheTTL} + /> + </ContentSection> + )} <ContentSection extraPadding> <QuestionActivityTimeline question={question} /> </ContentSection> diff --git a/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js b/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js index 6d688d386439be87fd797acb3039ccde777f727e..e7608fc41619bef927e3b36f648f57e464e34217 100644 --- a/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js +++ b/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js @@ -55,6 +55,7 @@ describe("scenarios > embedding > full app", () => { visitQuestionUrl({ url: "/question/1" }); cy.findByTestId("qb-header").should("be.visible"); + cy.findByTestId("qb-header-left-side").realHover(); cy.findByText(/Edited/).should("be.visible"); cy.icon("refresh").should("be.visible"); diff --git a/frontend/test/metabase/scenarios/models/models.cy.spec.js b/frontend/test/metabase/scenarios/models/models.cy.spec.js index cdc289025884eb6d999d98cc153b0341b1bdce92..e3222f1a0c0d03462d7f7584fae819470f1ffe70 100644 --- a/frontend/test/metabase/scenarios/models/models.cy.spec.js +++ b/frontend/test/metabase/scenarios/models/models.cy.spec.js @@ -28,7 +28,6 @@ import { selectDimensionOptionFromSidebar, saveQuestionBasedOnModel, assertIsQuestion, - openDetailsSidebar, } from "./helpers/e2e-models-helpers"; const { PRODUCTS } = SAMPLE_DATABASE; @@ -376,22 +375,21 @@ describe("scenarios > models", () => { cy.visit("/model/1"); cy.wait("@dataset"); - openDetailsSidebar(); - modal().within(() => { - cy.findByLabelText("Name") - .clear() - .type("M1"); - cy.findByLabelText("Description") - .clear() - .type("foo"); - cy.button("Save").click(); - }); + cy.findByTestId("saved-question-header-title") + .clear() + .type("M1") + .blur(); cy.wait("@updateCard"); questionInfoButton().click(); - cy.findByText("M1"); - cy.findByText("foo"); + cy.findByPlaceholderText("Description") + .type("foo") + .blur(); + cy.wait("@updateCard"); + + cy.findByDisplayValue("M1"); + cy.findByDisplayValue("foo"); }); }); diff --git a/frontend/test/metabase/scenarios/organization/moderation-question.cy.spec.js b/frontend/test/metabase/scenarios/organization/moderation-question.cy.spec.js index b7aa67a2350308d529e944a239a41315c722aaa6..0046d3b326f6973ebfabc89cbb351a734044a512 100644 --- a/frontend/test/metabase/scenarios/organization/moderation-question.cy.spec.js +++ b/frontend/test/metabase/scenarios/organization/moderation-question.cy.spec.js @@ -42,7 +42,7 @@ describeEE("scenarios > saved question moderation", () => { cy.findByText("Verify this question").should("be.visible"); - cy.findByTestId("saved-question-header-button").within(() => { + cy.findByTestId("qb-header-left-side").within(() => { cy.icon("verified").should("not.exist"); }); diff --git a/frontend/test/metabase/scenarios/question/caching.cy.spec.js b/frontend/test/metabase/scenarios/question/caching.cy.spec.js index e5490208726980ce275509ea73a8f1068942efb8..186b5257e351c43f81d6c9d1c34572fb47e64645 100644 --- a/frontend/test/metabase/scenarios/question/caching.cy.spec.js +++ b/frontend/test/metabase/scenarios/question/caching.cy.spec.js @@ -2,8 +2,10 @@ import { restore, describeEE, mockSessionProperty, - modal, visitQuestion, + questionInfoButton, + rightSidebar, + popover, } from "__support__/e2e/cypress"; describeEE("scenarios > question > caching", () => { @@ -17,40 +19,50 @@ describeEE("scenarios > question > caching", () => { cy.intercept("PUT", "/api/card/1").as("updateQuestion"); visitQuestion(1); - openEditingModalForm(); - modal().within(() => { - cy.findByText("More options").click(); + questionInfoButton().click(); + + rightSidebar().within(() => { + cy.findByText("Cache Configuration").click(); + }); + + popover().within(() => { cy.findByPlaceholderText("24") .clear() .type("48") .blur(); - cy.button("Save").click(); + cy.button("Save changes").click(); }); cy.wait("@updateQuestion"); + cy.button(/Saved/); cy.reload(); - openEditingModalForm(); - modal().within(() => { - cy.findByText("More options").click(); + questionInfoButton().click(); + + rightSidebar().within(() => { + cy.findByText("Cache Configuration").click(); + }); + + popover().within(() => { cy.findByDisplayValue("48") .clear() .type("0") .blur(); - cy.button("Save").click(); + cy.button("Save changes").click(); }); cy.wait("@updateQuestion"); + cy.button(/Saved/); cy.reload(); - openEditingModalForm(); - modal().within(() => { - cy.findByText("More options").click(); + questionInfoButton().click(); + + rightSidebar().within(() => { + cy.findByText("Cache Configuration").click(); + }); + + popover().within(() => { cy.findByPlaceholderText("24"); }); }); }); - -function openEditingModalForm() { - cy.findByTestId("saved-question-header-button").click(); -} diff --git a/frontend/test/metabase/scenarios/question/question-management.cy.spec.js b/frontend/test/metabase/scenarios/question/question-management.cy.spec.js index a6d69cb62c163367e60fd5223d7c941226817c8f..336e1dacd6157335a46cecc8167598a287a7919f 100644 --- a/frontend/test/metabase/scenarios/question/question-management.cy.spec.js +++ b/frontend/test/metabase/scenarios/question/question-management.cy.spec.js @@ -36,11 +36,10 @@ describe("managing question from the question's details sidebar", () => { it("should be able to edit question details (metabase#11719-1)", () => { // cy.skipOn(user === "nodata"); - cy.findByTestId("saved-question-header-button").click(); - cy.findByLabelText("Name") + cy.findByTestId("saved-question-header-title") .click() - .type("1"); - clickButton("Save"); + .type("1") + .blur(); assertOnRequest("updateQuestion"); cy.findByText("Orders1"); }); @@ -50,9 +49,10 @@ describe("managing question from the question's details sidebar", () => { questionInfoButton().click(); - cy.findByPlaceholderText("Description").type("foo", { delay: 0 }); + cy.findByPlaceholderText("Description") + .type("foo", { delay: 0 }) + .blur(); - cy.findByPlaceholderText("Description").blur(); assertOnRequest("updateQuestion"); cy.findByText("foo"); @@ -174,5 +174,4 @@ function assertOnRequest(xhr_alias) { cy.findByText("Sorry, you don’t have permission to see that.").should( "not.exist", ); - cy.get(".Modal").should("not.exist"); } diff --git a/frontend/test/metabase/scenarios/smoketest/admin_setup.cy.spec.js b/frontend/test/metabase/scenarios/smoketest/admin_setup.cy.spec.js index d4ef9b9b1d81ba599ec3b2c262d7b756e44b85ee..772e79101b2e130040fd0f95f23fb1cf783aa2a7 100644 --- a/frontend/test/metabase/scenarios/smoketest/admin_setup.cy.spec.js +++ b/frontend/test/metabase/scenarios/smoketest/admin_setup.cy.spec.js @@ -2,7 +2,6 @@ import { browse, popover, restore, - modal, openPeopleTable, visualize, navigationSidebar, @@ -10,6 +9,7 @@ import { visitQuestion, } from "__support__/e2e/cypress"; import { USERS } from "__support__/e2e/cypress_data"; +import { questionInfoButton } from "../../../__support__/e2e/helpers/e2e-ui-elements-helpers"; const { admin, normal } = USERS; @@ -44,6 +44,7 @@ describe("smoketest > admin_setup", () => { }); it("should rename a question and description as admin", () => { + cy.intercept("PUT", "/api/card/3").as("updateCard"); cy.visit("/"); cy.findByText("Our analytics").click(); @@ -57,12 +58,18 @@ describe("smoketest > admin_setup", () => { cy.findByText("Settings"); - cy.findByTestId("saved-question-header-button").click(); - cy.findByLabelText("Name") + cy.findByTestId("saved-question-header-title") .clear() - .type("Test Question"); - cy.findByLabelText("Description").type("Testing question description"); - cy.findByText("Save").click(); + .type("Test Question") + .blur(); + cy.wait("@updateCard"); + + questionInfoButton().click(); + cy.findByPlaceholderText("Description") + .clear() + .type("Testing question description") + .blur(); + cy.wait("@updateCard"); }); it("should rename a table and add a description as admin", () => { @@ -304,14 +311,11 @@ describe("smoketest > admin_setup", () => { ); cy.findByText("Test Question").click(); - cy.findByTestId("saved-question-header-button").click(); - - cy.findByText("Edit question"); - modal().within(() => { - cy.findByText("Testing question description"); - }); - cy.findByText("Cancel").click(); + questionInfoButton().click(); + cy.findByDisplayValue("Test Question"); + cy.findByDisplayValue("Testing question description"); + questionInfoButton().click(); // Check column names and visiblity