diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTimeInput/CacheTimeInput.styled.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTimeInput/CacheTimeInput.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..252b972d8af7a364321124713c4f4cd474d55d8f --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTimeInput/CacheTimeInput.styled.tsx @@ -0,0 +1,18 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import NumericInput from "metabase/core/components/NumericInput"; + +export const TimeInputRoot = styled.div` + display: flex; + align-items: center; + gap: 0.625rem; +`; + +export const TimeInput = styled(NumericInput)` + width: 3.125rem; + text-align: center; +`; + +export const TimeInputMessage = styled.div` + color: ${color("text-dark")}; +`; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTimeInput/CacheTimeInput.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTimeInput/CacheTimeInput.tsx new file mode 100644 index 0000000000000000000000000000000000000000..976acfdfb08a5194e42cd6bc18444a4431078511 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTimeInput/CacheTimeInput.tsx @@ -0,0 +1,53 @@ +import React, { FocusEvent, useCallback } from "react"; +import { t } from "ttag"; +import { + TimeInputMessage, + TimeInputRoot, + TimeInput, +} from "./CacheTimeInput.styled"; + +export interface CacheTimeInputProps { + id?: string; + name?: string; + value?: number; + message?: string; + error?: boolean; + onChange?: (value?: number) => void; + onBlur?: (event: FocusEvent<HTMLInputElement>) => void; +} + +const CacheTimeInput = ({ + id, + name, + value, + message, + error, + onChange, + onBlur, +}: CacheTimeInputProps): JSX.Element => { + const handleChange = useCallback( + (value?: number) => { + onChange?.(value !== 0 ? value : undefined); + }, + [onChange], + ); + + return ( + <TimeInputRoot> + {message && <TimeInputMessage>{message}</TimeInputMessage>} + <TimeInput + id={id} + name={name} + value={value} + placeholder="24" + error={error} + fullWidth + onChange={handleChange} + onBlur={onBlur} + /> + <TimeInputMessage>{t`hours`}</TimeInputMessage> + </TimeInputRoot> + ); +}; + +export default CacheTimeInput; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTimeInput/index.ts b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTimeInput/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3abbcacd3318592608e36398923c76dee9a8d798 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTimeInput/index.ts @@ -0,0 +1 @@ +export { default } from "./CacheTimeInput"; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeField/DatabaseCacheTimeField.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeField/DatabaseCacheTimeField.tsx new file mode 100644 index 0000000000000000000000000000000000000000..01a242eeb335ead26b962ebbd88b94603cff2c10 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeField/DatabaseCacheTimeField.tsx @@ -0,0 +1,58 @@ +import React, { useCallback } from "react"; +import { useField, useFormikContext } from "formik"; +import { jt, t } from "ttag"; +import { useUniqueId } from "metabase/hooks/use-unique-id"; +import Link from "metabase/core/components/Link/Link"; +import FormField from "metabase/core/components/FormField"; +import { DatabaseValues } from "metabase/databases/types"; +import DatabaseCacheTimeInput from "../DatabaseCacheTimeInput"; + +const FIELD = "cache_ttl"; +const SECTION = "advanced-options"; + +const DatabaseCacheTimeField = () => { + const id = useUniqueId(); + const [{ value, onBlur }, { error, touched }, { setValue }] = useField(FIELD); + const { values } = useFormikContext<DatabaseValues>(); + + const handleChange = useCallback( + (value?: number) => setValue(value != null ? value : null), + [setValue], + ); + + if (!values.details[SECTION]) { + return null; + } + + return ( + <FormField + title={t`Default result cache duration`} + description={<DatabaseCacheTimeDescription />} + htmlFor={id} + error={touched ? error : undefined} + > + <DatabaseCacheTimeInput + id={id} + name={FIELD} + value={value ?? undefined} + error={touched && error != null} + onChange={handleChange} + onBlur={onBlur} + /> + </FormField> + ); +}; + +const DatabaseCacheTimeDescription = (): JSX.Element => { + return ( + <div> + {jt`How long to keep question results. By default, Metabase will use the value you supply on the ${( + <Link key="link" to="/admin/settings/caching"> + {t`cache settings page`} + </Link> + )}, but if this database has other factors that influence the freshness of data, it could make sense to set a custom duration. You can also choose custom durations on individual questions or dashboards to help improve performance.`} + </div> + ); +}; + +export default DatabaseCacheTimeField; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeField/index.ts b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeField/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4f08ab04fcac0e7ca46c1a4eb5131ad64546cef --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeField/index.ts @@ -0,0 +1 @@ +export { default } from "./DatabaseCacheTimeField"; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeInput/DatabaseCacheTimeInput.styled.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeInput/DatabaseCacheTimeInput.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ab712c3863241e561bd3a9f06dd68cc30e8edc3 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeInput/DatabaseCacheTimeInput.styled.tsx @@ -0,0 +1,7 @@ +import styled from "@emotion/styled"; + +export const TimeInputRoot = styled.div` + display: flex; + align-items: center; + gap: 1rem; +`; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeInput/DatabaseCacheTimeInput.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeInput/DatabaseCacheTimeInput.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a2e935a74190a6c450342ea058c6d485c8f60589 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeInput/DatabaseCacheTimeInput.tsx @@ -0,0 +1,62 @@ +import React, { FocusEvent, useCallback, useState } from "react"; +import { t } from "ttag"; +import Select, { SelectChangeEvent } from "metabase/core/components/Select"; +import CacheTimeInput from "../CacheTimeInput"; +import { TimeInputRoot } from "./DatabaseCacheTimeInput.styled"; + +const CACHE_OPTIONS = [ + { name: t`Use instance default (TTL)`, value: false }, + { name: t`Custom`, value: true }, +]; + +const DEFAULT_CACHE_TIME = 24; + +export interface DatabaseCacheTimeInputProps { + id?: string; + name?: string; + value?: number; + error?: boolean; + onChange?: (value?: number) => void; + onBlur?: (event: FocusEvent<HTMLInputElement>) => void; +} + +const DatabaseCacheTimeInput = ({ + id, + name, + value, + error, + onChange, + onBlur, +}: DatabaseCacheTimeInputProps): JSX.Element => { + const [isCustom, setIsCustom] = useState(value != null); + + const handleChange = useCallback( + ({ target: { value: isCustom } }: SelectChangeEvent<boolean>) => { + setIsCustom(isCustom); + onChange?.(isCustom ? DEFAULT_CACHE_TIME : undefined); + }, + [onChange], + ); + + return ( + <TimeInputRoot> + <Select + value={isCustom} + options={CACHE_OPTIONS} + onChange={handleChange} + /> + {isCustom && ( + <CacheTimeInput + id={id} + name={name} + value={value} + error={error} + onChange={onChange} + onBlur={onBlur} + /> + )} + </TimeInputRoot> + ); +}; + +export default DatabaseCacheTimeInput; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeInput/index.ts b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeInput/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac2d66252da102b5ca3bcbce91f01a9a587c31de --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTimeInput/index.ts @@ -0,0 +1 @@ +export { default } from "./DatabaseCacheTimeInput"; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/index.js b/enterprise/frontend/src/metabase-enterprise/caching/index.js index a1c91a17b244bdb4a90fc74d27d740e1d267e588..599c95b2b4b4b1e845bc2dc1cda34ce8d9193602 100644 --- a/enterprise/frontend/src/metabase-enterprise/caching/index.js +++ b/enterprise/frontend/src/metabase-enterprise/caching/index.js @@ -5,6 +5,7 @@ import { PLUGIN_CACHING, PLUGIN_FORM_WIDGETS } from "metabase/plugins"; import Link from "metabase/core/components/Link"; import CacheTTLField from "./components/CacheTTLField"; import DatabaseCacheTTLField from "./components/DatabaseCacheTTLField"; +import DatabaseCacheTimeField from "./components/DatabaseCacheTimeField"; import QuestionCacheTTLField from "./components/QuestionCacheTTLField"; import QuestionCacheSection from "./components/QuestionCacheSection"; import DashboardCacheSection from "./components/DashboardCacheSection"; @@ -52,7 +53,8 @@ if (hasPremiumFeature("advanced_config")) { PLUGIN_FORM_WIDGETS.questionCacheTTL = QuestionCacheTTLField; PLUGIN_CACHING.getQuestionsImplicitCacheTTL = getQuestionsImplicitCacheTTL; - PLUGIN_CACHING.QuestionCacheSection = QuestionCacheSection; + PLUGIN_CACHING.DatabaseCacheTimeField = DatabaseCacheTimeField; PLUGIN_CACHING.DashboardCacheSection = DashboardCacheSection; + PLUGIN_CACHING.QuestionCacheSection = QuestionCacheSection; PLUGIN_CACHING.isEnabled = () => true; } diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index 36da17d91d83088b5b36dea0cfc610eef187bbbf..63fb8fe33daa7046f53d141e52252261e3bfae07 100644 --- a/frontend/src/metabase-types/api/mocks/settings.ts +++ b/frontend/src/metabase-types/api/mocks/settings.ts @@ -116,6 +116,7 @@ export const createMockSettings = (opts?: Partial<Settings>): Settings => ({ "email-configured?": false, "enable-embedding": false, "enable-nested-queries": true, + "enable-query-caching": undefined, "enable-public-sharing": false, "enable-xrays": false, "experimental-enable-actions": false, diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index a84ae0f84cb00215ba983160d7767b5a2875f0fe..2430fbc821a1869709e172bc547e1d8fd3357938 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -145,6 +145,7 @@ export interface Settings { "embedding-secret-key"?: string; "enable-embedding": boolean; "enable-nested-queries": boolean; + "enable-query-caching"?: boolean; "enable-public-sharing": boolean; "enable-xrays": boolean; "experimental-enable-actions": boolean; diff --git a/frontend/src/metabase-types/store/mocks/setup.ts b/frontend/src/metabase-types/store/mocks/setup.ts index 08284538d1baeb17f55f1967211456dc50c8a07e..551d13c2c02d694b889c7cc46d31607eae677244 100644 --- a/frontend/src/metabase-types/store/mocks/setup.ts +++ b/frontend/src/metabase-types/store/mocks/setup.ts @@ -41,6 +41,7 @@ export const createMockDatabaseInfo = ( schedules: {}, auto_run_queries: false, refingerprint: false, + cache_ttl: null, is_sample: false, is_full_sync: false, is_on_demand: false, diff --git a/frontend/src/metabase-types/store/setup.ts b/frontend/src/metabase-types/store/setup.ts index e28a057403feea3bccce5c3f55ba06b2bb083ca2..51b4ac9a0936de45311e7614845d3f968c55533a 100644 --- a/frontend/src/metabase-types/store/setup.ts +++ b/frontend/src/metabase-types/store/setup.ts @@ -27,6 +27,7 @@ export interface DatabaseInfo { schedules: DatabaseSchedules; auto_run_queries: boolean; refingerprint: boolean; + cache_ttl: number | null; is_sample: boolean; is_full_sync: boolean; is_on_demand: boolean; diff --git a/frontend/src/metabase/core/components/FormField/FormField.styled.tsx b/frontend/src/metabase/core/components/FormField/FormField.styled.tsx index 8bd6dc89d8d317c2b9d01f68955ae9f054ecdd50..5fad9837eefc13c640ccd519d8266960e4d3edcf 100644 --- a/frontend/src/metabase/core/components/FormField/FormField.styled.tsx +++ b/frontend/src/metabase/core/components/FormField/FormField.styled.tsx @@ -3,31 +3,6 @@ import { color } from "metabase/lib/colors"; import Icon from "metabase/components/Icon"; import { FieldAlignment, FieldOrientation } from "./types"; -export const FieldLabelError = styled.span` - color: ${color("error")}; -`; - -export interface FieldRootProps { - orientation: FieldOrientation; - hasError: boolean; -} - -export const FieldRoot = styled.div<FieldRootProps>` - display: ${props => props.orientation === "horizontal" && "flex"}; - justify-content: ${props => - props.orientation === "horizontal" && "space-between"}; - color: ${props => (props.hasError ? color("error") : color("text-medium"))}; - margin-bottom: 1.25rem; - - &:focus-within { - color: ${color("text-medium")}; - - ${FieldLabelError} { - display: none; - } - } -`; - export interface FormCaptionProps { alignment: FieldAlignment; orientation: FieldOrientation; @@ -44,8 +19,13 @@ export const FieldCaption = styled.div<FormCaptionProps>` "0.5rem"}; `; -export const FieldLabel = styled.label` +export interface FieldLabelProps { + hasError: boolean; +} + +export const FieldLabel = styled.label<FieldLabelProps>` display: block; + color: ${props => (props.hasError ? color("error") : color("text-medium"))}; font-size: 0.77rem; font-weight: 900; `; @@ -56,7 +36,12 @@ export const FieldLabelContainer = styled.div` margin-bottom: 0.5em; `; +export const FieldLabelError = styled.span` + color: ${color("error")}; +`; + export const FieldDescription = styled.div` + color: ${color("text-medium")}; margin-bottom: 0.5rem; `; @@ -77,3 +62,24 @@ export const FieldInfoLabel = styled.div` margin-left: auto; cursor: default; `; + +export interface FieldRootProps { + orientation: FieldOrientation; +} + +export const FieldRoot = styled.div<FieldRootProps>` + display: ${props => props.orientation === "horizontal" && "flex"}; + justify-content: ${props => + props.orientation === "horizontal" && "space-between"}; + margin-bottom: 1.25rem; + + &:focus-within { + ${FieldLabel} { + color: ${color("text-medium")}; + } + + ${FieldLabelError} { + display: none; + } + } +`; diff --git a/frontend/src/metabase/core/components/FormField/FormField.tsx b/frontend/src/metabase/core/components/FormField/FormField.tsx index 9e45d57a9138b1739ad7cee4f1bc20264671213c..2f918f049d782b17638b61efb81a09e8dcffed78 100644 --- a/frontend/src/metabase/core/components/FormField/FormField.tsx +++ b/frontend/src/metabase/core/components/FormField/FormField.tsx @@ -41,18 +41,13 @@ const FormField = forwardRef(function FormField( const hasError = Boolean(error); return ( - <FieldRoot - {...props} - ref={ref} - orientation={orientation} - hasError={hasError} - > + <FieldRoot {...props} ref={ref} orientation={orientation}> {alignment === "start" && children} {(title || description) && ( <FieldCaption alignment={alignment} orientation={orientation}> <FieldLabelContainer> {title && ( - <FieldLabel htmlFor={htmlFor}> + <FieldLabel hasError={hasError} htmlFor={htmlFor}> {title} {hasError && <FieldLabelError>: {error}</FieldLabelError>} </FieldLabel> diff --git a/frontend/src/metabase/core/utils/errors/errors.ts b/frontend/src/metabase/core/utils/errors/errors.ts index 3f44aa8bb40da88f268b6fec7c57b6df2dd377f3..a3d08c04245e5988b37411ab747c4402406cd418 100644 --- a/frontend/src/metabase/core/utils/errors/errors.ts +++ b/frontend/src/metabase/core/utils/errors/errors.ts @@ -7,3 +7,5 @@ export const email = () => t`must be a valid email address`; export const maxLength = ({ max }: MaxLengthParams) => t`must be ${max} characters or less`; + +export const positive = () => t`must be a positive integer value`; diff --git a/frontend/src/metabase/core/utils/errors/index.ts b/frontend/src/metabase/core/utils/errors/index.ts index 6312645f3add95b09ca44cdbc2f60f40ab8eb536..1938ef1bcff33643d47eee33012c379656fb71ba 100644 --- a/frontend/src/metabase/core/utils/errors/index.ts +++ b/frontend/src/metabase/core/utils/errors/index.ts @@ -1 +1 @@ -export { required, email, maxLength } from "./errors"; +export { required, email, maxLength, positive } from "./errors"; diff --git a/frontend/src/metabase/databases/components/DatabaseForm/DatabaseForm.tsx b/frontend/src/metabase/databases/components/DatabaseForm/DatabaseForm.tsx index 3013bb1e69f9d59999a5e7a2c2c381c970049188..7df82a128be77e06d1f3b2c89e8ab246d4ad6506 100644 --- a/frontend/src/metabase/databases/components/DatabaseForm/DatabaseForm.tsx +++ b/frontend/src/metabase/databases/components/DatabaseForm/DatabaseForm.tsx @@ -7,6 +7,7 @@ import FormProvider from "metabase/core/components/FormProvider"; import FormFooter from "metabase/core/components/FormFooter"; import FormSubmitButton from "metabase/core/components/FormSubmitButton"; import FormErrorMessage from "metabase/core/components/FormErrorMessage"; +import { PLUGIN_CACHING } from "metabase/plugins"; import { Engine } from "metabase-types/api"; import { DatabaseValues } from "../../types"; import { getDefaultEngineKey } from "../../utils/engine"; @@ -22,6 +23,7 @@ export interface DatabaseFormProps { initialValues?: DatabaseValues; isHosted?: boolean; isAdvanced?: boolean; + isCachingEnabled?: boolean; onSubmit: (values: DatabaseValues) => void; onEngineChange?: (engineKey: string | undefined) => void; onCancel?: () => void; @@ -32,6 +34,7 @@ const DatabaseForm = ({ initialValues: initialData, isHosted = false, isAdvanced = false, + isCachingEnabled = false, onSubmit, onCancel, onEngineChange, @@ -72,6 +75,7 @@ const DatabaseForm = ({ engines={engines} isHosted={isHosted} isAdvanced={isAdvanced} + isCachingEnabled={isCachingEnabled} onEngineChange={handleEngineChange} onCancel={onCancel} /> @@ -85,6 +89,7 @@ interface DatabaseFormBodyProps { engines: Record<string, Engine>; isHosted: boolean; isAdvanced: boolean; + isCachingEnabled: boolean; onEngineChange: (engineKey: string | undefined) => void; onCancel?: () => void; } @@ -95,6 +100,7 @@ const DatabaseFormBody = ({ engines, isHosted, isAdvanced, + isCachingEnabled, onEngineChange, onCancel, }: DatabaseFormBodyProps): JSX.Element => { @@ -122,6 +128,7 @@ const DatabaseFormBody = ({ {fields.map(field => ( <DatabaseDetailField key={field.name} field={field} /> ))} + {isCachingEnabled && <PLUGIN_CACHING.DatabaseCacheTimeField />} <DatabaseFormFooter isAdvanced={isAdvanced} onCancel={onCancel} /> </Form> ); diff --git a/frontend/src/metabase/databases/containers/DatabaseForm/DatabaseForm.tsx b/frontend/src/metabase/databases/containers/DatabaseForm/DatabaseForm.tsx index 988b47982a66acda587b9ab1c7b64268192fd9c3..9ac817c781d81ed5324c93276c179a8b91e8a7c9 100644 --- a/frontend/src/metabase/databases/containers/DatabaseForm/DatabaseForm.tsx +++ b/frontend/src/metabase/databases/containers/DatabaseForm/DatabaseForm.tsx @@ -3,13 +3,14 @@ import { getSetting } from "metabase/selectors/settings"; import { State } from "metabase-types/store"; import DatabaseForm, { DatabaseFormProps } from "../../components/DatabaseForm"; -type DatabaseFormStateKeys = "engines" | "isHosted"; +type DatabaseFormStateKeys = "engines" | "isHosted" | "isCachingEnabled"; type DatabaseFormOwnProps = Omit<DatabaseFormProps, DatabaseFormStateKeys>; type DatabaseFormStateProps = Pick<DatabaseFormProps, DatabaseFormStateKeys>; const mapStateToProps = (state: State) => ({ engines: getSetting(state, "engines"), isHosted: getSetting(state, "is-hosted?"), + isCachingEnabled: getSetting(state, "enable-query-caching"), }); export default connect< diff --git a/frontend/src/metabase/databases/types.ts b/frontend/src/metabase/databases/types.ts index c2a49942ffae6e5a4f0663307c76b666b553a368..4cd8c417bd818dbe4a3d865604320ec7fef35c48 100644 --- a/frontend/src/metabase/databases/types.ts +++ b/frontend/src/metabase/databases/types.ts @@ -14,6 +14,7 @@ export interface DatabaseValues { schedules: DatabaseSchedules; auto_run_queries: boolean; refingerprint: boolean; + cache_ttl: number | null; is_sample: boolean; is_full_sync: boolean; is_on_demand: boolean; diff --git a/frontend/src/metabase/databases/utils/schema.ts b/frontend/src/metabase/databases/utils/schema.ts index d3c28f8706f93cc2cad1fcac887b865e19f2df7f..43c36e9dea4359b97e4f4986ab85d3d46c57aba4 100644 --- a/frontend/src/metabase/databases/utils/schema.ts +++ b/frontend/src/metabase/databases/utils/schema.ts @@ -23,6 +23,7 @@ export const getValidationSchema = ( }), auto_run_queries: Yup.boolean().default(true), refingerprint: Yup.boolean().default(false), + cache_ttl: Yup.number().nullable().default(null).positive(Errors.positive), is_sample: Yup.boolean().default(false), is_full_sync: Yup.boolean().default(true), is_on_demand: Yup.boolean().default(false), diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index 43e5ddc483d6466daa5471c9356adf4ada4857e9..92a768a04016773a3de1674703b2305e02ed4e26 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -163,6 +163,7 @@ export const PLUGIN_CACHING = { getQuestionsImplicitCacheTTL: (question?: any) => null, QuestionCacheSection: PluginPlaceholder, DashboardCacheSection: PluginPlaceholder, + DatabaseCacheTimeField: PluginPlaceholder, isEnabled: () => false, };