Compare commits

...

2 Commits

Author SHA1 Message Date
Raphael Krut-Landau
6416344f3b improve loading wrapper 2024-05-16 17:54:02 -04:00
Raphael Krut-Landau
9520b47372 Squashed 2024-05-16 17:54:02 -04:00
24 changed files with 423 additions and 182 deletions

View File

@@ -16,6 +16,10 @@ describeEE("scenarios > dashboard > caching", () => {
cy.request("PUT", "/api/setting/enable-query-caching", { value: true });
});
/**
* @note There is a similar test for the cache config form that appears in the question sidebar.
* It's in the Cypress describe block labeled "scenarios > question > caching"
*/
it("can configure cache for a dashboard, on an enterprise instance", () => {
cy.intercept("PUT", "/api/cache").as("putCacheConfig");
visitDashboard(ORDERS_DASHBOARD_ID);

View File

@@ -1,12 +1,11 @@
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
import {
restore,
describeEE,
visitQuestion,
questionInfoButton,
restore,
rightSidebar,
popover,
setTokenFeatures,
visitQuestion,
} from "e2e/support/helpers";
describeEE("scenarios > question > caching", () => {
@@ -17,48 +16,45 @@ describeEE("scenarios > question > caching", () => {
cy.request("PUT", "/api/setting/enable-query-caching", { value: true });
});
it("can set cache ttl for a saved question", { tags: "@flaky" }, () => {
cy.intercept("PUT", `/api/card/${ORDERS_QUESTION_ID}`).as("updateQuestion");
/**
* @note There is a similar test for the cache config form that appears in the dashboard sidebar.
* It's in the Cypress describe block labeled "scenarios > dashboard > caching"
*/
it("can configure cache for a question, on an enterprise instance", () => {
cy.intercept("PUT", "/api/cache").as("putCacheConfig");
visitQuestion(ORDERS_QUESTION_ID);
questionInfoButton().click();
rightSidebar().within(() => {
cy.findByText("Cache Configuration").click();
});
popover().within(() => {
cy.findByPlaceholderText("24").clear().type("48").blur();
cy.button("Save changes").click();
});
cy.wait("@updateQuestion");
cy.button(/Saved/);
cy.reload();
questionInfoButton().click();
rightSidebar().within(() => {
cy.findByText("Cache Configuration").click();
});
popover().within(() => {
cy.findByDisplayValue("48").clear().type("0").blur();
cy.button("Save changes").click();
});
cy.wait("@updateQuestion");
cy.button(/Saved/);
cy.reload();
questionInfoButton().click();
rightSidebar().within(() => {
cy.findByText("Cache Configuration").click();
});
popover().within(() => {
cy.findByPlaceholderText("24");
cy.findByText(/Caching policy/).within(() => {
cy.findByRole("button", { name: /Use default/ }).click();
});
cy.findByRole("heading", { name: /Caching settings/ }).click();
cy.findByRole("radio", { name: /Duration/ }).click();
cy.findByLabelText("Cache results for this many hours").type("48");
cy.findByRole("button", { name: /Save changes/ }).click();
cy.wait("@putCacheConfig");
cy.findByText(/Caching policy/).within(() => {
cy.log(
"Check that the newly chosen cache invalidation policy - Duration - is now visible in the sidebar",
);
const durationButton = cy.findByRole("button", { name: /Duration/ });
durationButton.should("be.visible");
cy.log("Open the cache invalidation policy configuration form again");
durationButton.click();
});
cy.findByRole("radio", { name: /Adaptive/ }).click();
cy.findByLabelText(/Minimum query duration/).type("999");
cy.findByRole("button", { name: /Save changes/ }).click();
cy.wait("@putCacheConfig");
cy.findByText(/Caching policy/).within(() => {
cy.log(
"Check that the newly chosen cache invalidation policy - Adaptive - is now visible in the sidebar",
);
const policyToken = cy.findByRole("button", { name: /Adaptive/ });
policyToken.should("be.visible");
});
});
});
});

View File

@@ -1,19 +1,20 @@
import type { Dispatch, SetStateAction } from "react";
import { t } from "ttag";
import { getRelativeTime } from "metabase/lib/time";
import { Stack, Text } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
import CacheSection from "../CacheSection";
import { SidebarCacheSection } from "../SidebarCacheSection";
export interface QuestionCacheSectionProps {
question: Question;
onSave: (cache_ttl: number | null) => Promise<Question>;
setPage: Dispatch<SetStateAction<"default" | "caching">>;
}
const QuestionCacheSection = ({
question,
onSave,
setPage,
}: QuestionCacheSectionProps) => {
const canWrite = question.canWrite();
const cacheTimestamp = question.lastQueryStart();
@@ -27,7 +28,11 @@ const QuestionCacheSection = ({
</Text>
)}
{canWrite && (
<CacheSection initialCacheTTL={question.cacheTTL()} onSave={onSave} />
<SidebarCacheSection
model="question"
item={question}
setPage={setPage}
/>
)}
</Stack>
);

View File

@@ -22,7 +22,7 @@ const setup = ({ card = createMockCard() }: SetupOpts) => {
const question = checkNotNull(metadata.question(card.id));
renderWithProviders(
<QuestionCacheSection question={question} onSave={jest.fn()} />,
<QuestionCacheSection question={question} setPage={jest.fn()} />,
{ storeInitialState: state },
);
};
@@ -59,7 +59,7 @@ describe("QuestionCacheSection", () => {
}),
});
expect(screen.getByText("Cache Configuration")).toBeInTheDocument();
expect(screen.getByText("Caching policy")).toBeInTheDocument();
expect(screen.queryByText(/Question last cached/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,21 @@
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import { Group } from "metabase/ui";
export const SidebarCacheFormBody = styled(Group)`
display: flex;
flex-flow: column nowrap;
height: 100%;
.form-buttons-group {
border-top: 1px solid ${color("border")};
position: sticky;
bottom: 0;
}
.strategy-form-box {
border-bottom: 0 !important;
}
.strategy-form-submit-button {
flex-grow: 1;
}
`;

View File

@@ -0,0 +1,110 @@
import { useCallback, useMemo } from "react";
import { withRouter } from "react-router";
import { t } from "ttag";
import _ from "underscore";
import { StrategyForm } from "metabase/admin/performance/components/StrategyForm";
import { useCacheConfigs } from "metabase/admin/performance/hooks/useCacheConfigs";
import { useConfirmIfFormIsDirty } from "metabase/admin/performance/hooks/useConfirmIfFormIsDirty";
import { useSaveStrategy } from "metabase/admin/performance/hooks/useSaveStrategy";
import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper";
import { color } from "metabase/lib/colors";
import type { SidebarCacheFormProps } from "metabase/plugins";
import { Button, Flex, Icon, Title } from "metabase/ui";
import type { Strategy } from "metabase-types/api";
import { SidebarCacheFormBody } from "./SidebarCacheForm.styled";
import { getItemId, getItemName } from "./utils";
const SidebarCacheForm_Base = ({
item,
model,
setPage,
...groupProps
}: SidebarCacheFormProps) => {
const configurableModels = useMemo(() => [model], [model]);
const id: number = getItemId(model, item);
const { configs, setConfigs, loading, error } = useCacheConfigs({
configurableModels,
id,
});
const { savedStrategy, filteredConfigs } = useMemo(() => {
const targetConfig = _.findWhere(configs, { model_id: id });
const savedStrategy = targetConfig?.strategy;
const filteredConfigs = _.compact([targetConfig]);
return { savedStrategy, filteredConfigs };
}, [configs, id]);
const saveStrategy = useSaveStrategy(id, filteredConfigs, setConfigs, model);
const saveAndCloseSidebar = useCallback(
async (values: Strategy) => {
await saveStrategy(values);
setPage("default");
},
[saveStrategy, setPage],
);
const closeSidebar = useCallback(async () => {
setPage("default");
}, [setPage]);
const {
askBeforeDiscardingChanges,
confirmationModal,
isStrategyFormDirty,
setIsStrategyFormDirty,
} = useConfirmIfFormIsDirty();
const goBack = () => setPage("default");
const headingId = `${model}-sidebar-caching-settings-heading`;
return (
<SidebarCacheFormBody
align="flex-start"
spacing="md"
aria-labelledby={headingId}
{...groupProps}
>
<Flex align="center">
<BackButton
onClick={() => {
isStrategyFormDirty ? askBeforeDiscardingChanges(goBack) : goBack();
}}
/>
<Title order={2} id={headingId}>
Caching settings
</Title>
</Flex>
<DelayedLoadingAndErrorWrapper loading={loading} error={error}>
<StrategyForm
targetId={id}
targetModel={model}
targetName={getItemName(model, item)}
setIsDirty={setIsStrategyFormDirty}
saveStrategy={saveAndCloseSidebar}
savedStrategy={savedStrategy}
shouldAllowInvalidation
shouldShowName={false}
onReset={closeSidebar}
buttonLabels={{ save: t`Save`, discard: t`Cancel` }}
/>
</DelayedLoadingAndErrorWrapper>
{confirmationModal}
</SidebarCacheFormBody>
);
};
export const SidebarCacheForm = withRouter(SidebarCacheForm_Base);
const BackButton = ({ onClick }: { onClick: () => void }) => (
<Button
lh={0}
style={{ marginInlineStart: ".5rem" }}
variant="subtle"
onClick={onClick}
>
<Icon name="chevronleft" color={color("text-dark")} />
</Button>
);

View File

@@ -0,0 +1,17 @@
import styled from "@emotion/styled";
import type { AnchorHTMLAttributes } from "react";
import { color } from "metabase/lib/colors";
import type { AnchorProps } from "metabase/ui";
import { Anchor } from "metabase/ui";
export const FormLauncher = styled(Anchor)<
AnchorProps & AnchorHTMLAttributes<HTMLAnchorElement>
>`
font-weight: bold;
&:hover,
&:active {
color: ${color("brand")};
text-decoration: none;
}
`;

View File

@@ -0,0 +1,47 @@
import { useMemo } from "react";
import { t } from "ttag";
import _ from "underscore";
import { useCacheConfigs } from "metabase/admin/performance/hooks/useCacheConfigs";
import { getShortStrategyLabel } from "metabase/admin/performance/strategies";
import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper";
import type { SidebarCacheSectionProps } from "metabase/plugins";
import { Flex } from "metabase/ui";
import { FormLauncher } from "./SidebarCacheSection.styled";
import { getItemId } from "./utils";
/** Displays the current cache invalidation strategy and provides a button that opens the cache configuration form */
export const SidebarCacheSection = ({
item,
model,
setPage,
}: SidebarCacheSectionProps) => {
const id = useMemo(() => getItemId(model, item), [model, item]);
const configurableModels = useMemo(() => [model], [model]);
const { configs, loading, error } = useCacheConfigs({
configurableModels,
id,
});
const targetConfig = useMemo(() => {
const id = getItemId(model, item);
return _.findWhere(configs, { model, model_id: id });
}, [configs, model, item]);
const savedStrategy = targetConfig?.strategy;
const shortStrategyLabel =
getShortStrategyLabel(savedStrategy, model) || t`Use default`;
return (
<DelayedLoadingAndErrorWrapper delay={0} loading={loading} error={error}>
<Flex align="center" justify="space-between">
{t`Caching policy`}
<FormLauncher onClick={() => setPage("caching")}>
{shortStrategyLabel}
</FormLauncher>
</Flex>
</DelayedLoadingAndErrorWrapper>
);
};

View File

@@ -1,9 +1,20 @@
import type { Dashboard } from "metabase-types/api";
import { t } from "ttag";
export const getDashboardId = (dashboard: Dashboard): number => {
if (typeof dashboard.id === "string") {
throw new Error("This dashboard has an invalid id");
}
const dashboardId: number = dashboard.id;
return dashboardId;
};
import type Question from "metabase-lib/v1/Question";
import type { CacheableDashboard, CacheableModel } from "metabase-types/api";
export const getItemId = (
model: CacheableModel,
item: CacheableDashboard | Question,
) =>
model === "dashboard"
? (item as CacheableDashboard).id
: (item as Question).id();
export const getItemName = (
model: CacheableModel,
item: CacheableDashboard | Question,
) =>
model === "dashboard"
? (item as CacheableDashboard).name
: (item as Question).displayName() ?? t`Untitled question`;

View File

@@ -9,6 +9,8 @@ import { GranularControlsExplanation } from "./components/GranularControlsExplan
import { InvalidateNowButton } from "./components/InvalidateNowButton";
import QuestionCacheSection from "./components/QuestionCacheSection";
import QuestionCacheTTLField from "./components/QuestionCacheTTLField";
import { SidebarCacheForm } from "./components/SidebarCacheForm";
import { SidebarCacheSection } from "./components/SidebarCacheSection";
import { StrategyFormLauncherPanel } from "./components/StrategyFormLauncherPanel";
import {
getQuestionsImplicitCacheTTL,
@@ -38,4 +40,6 @@ if (hasPremiumFeature("cache_granular_controls")) {
PLUGIN_CACHING.GranularControlsExplanation = GranularControlsExplanation;
PLUGIN_CACHING.InvalidateNowButton = InvalidateNowButton;
PLUGIN_CACHING.DashboardStrategySidebar = DashboardStrategySidebar;
PLUGIN_CACHING.SidebarCacheSection = SidebarCacheSection;
PLUGIN_CACHING.SidebarCacheForm = SidebarCacheForm;
}

View File

@@ -1,3 +1,4 @@
/** 'Model' as in 'type of object' */
export type CacheableModel = "root" | "database" | "dashboard" | "question";
export type StrategyType =

View File

@@ -51,6 +51,11 @@ import { cronToScheduleSettings, scheduleSettingsToCron } from "../utils";
import { LoaderInButton } from "./StrategyForm.styled";
interface ButtonLabels {
save: string;
discard: string;
}
export const StrategyForm = ({
targetId,
targetModel,
@@ -62,6 +67,10 @@ export const StrategyForm = ({
shouldShowName = true,
formStyle = {},
onReset,
buttonLabels = {
save: t`Save changes`,
discard: t`Discard changes`,
},
}: {
targetId: number | null;
targetModel: CacheableModel;
@@ -73,6 +82,7 @@ export const StrategyForm = ({
shouldShowName?: boolean;
formStyle?: React.CSSProperties;
onReset?: () => void;
buttonLabels?: ButtonLabels;
}) => {
const defaultStrategy: Strategy = {
type: targetId === rootId ? "nocache" : "inherit",
@@ -95,6 +105,7 @@ export const StrategyForm = ({
shouldAllowInvalidation={shouldAllowInvalidation}
shouldShowName={shouldShowName}
formStyle={formStyle}
buttonLabels={buttonLabels}
/>
</FormProvider>
);
@@ -108,6 +119,7 @@ const StrategyFormBody = ({
shouldAllowInvalidation,
shouldShowName = true,
formStyle = {},
buttonLabels,
}: {
targetId: number | null;
targetModel: CacheableModel;
@@ -116,6 +128,7 @@ const StrategyFormBody = ({
shouldAllowInvalidation: boolean;
shouldShowName?: boolean;
formStyle?: React.CSSProperties;
buttonLabels: ButtonLabels;
}) => {
const { dirty, values, setFieldValue } = useFormikContext<Strategy>();
const { setStatus } = useFormContext();
@@ -224,6 +237,7 @@ const StrategyFormBody = ({
targetModel={targetModel}
targetName={targetName}
shouldAllowInvalidation={shouldAllowInvalidation}
buttonLabels={buttonLabels}
/>
</Form>
</div>
@@ -249,6 +263,7 @@ type FormButtonsProps = {
targetModel: CacheableModel;
shouldAllowInvalidation: boolean;
targetName?: string;
buttonLabels: ButtonLabels;
};
const FormButtons = ({
@@ -256,6 +271,7 @@ const FormButtons = ({
targetModel,
shouldAllowInvalidation,
targetName,
buttonLabels,
}: FormButtonsProps) => {
const { dirty } = useFormikContext<Strategy>();
@@ -270,7 +286,11 @@ const FormButtons = ({
if (isSavingPossible) {
return (
<FormButtonsGroup>
<SaveAndDiscardButtons dirty={dirty} isFormPending={isFormPending} />
<SaveAndDiscardButtons
dirty={dirty}
isFormPending={isFormPending}
buttonLabels={buttonLabels}
/>
</FormButtonsGroup>
);
}
@@ -329,20 +349,21 @@ const ScheduleStrategyFormFields = () => {
const SaveAndDiscardButtons = ({
dirty,
isFormPending,
buttonLabels,
}: {
dirty: boolean;
isFormPending: boolean;
buttonLabels: ButtonLabels;
}) => {
return (
<>
<Button
disabled={!dirty || isFormPending}
type="reset"
>{t`Discard changes`}</Button>
<Button disabled={!dirty || isFormPending} type="reset">
{buttonLabels.discard}
</Button>
<FormSubmitButton
miw="10rem"
h="40px"
label={t`Save changes`}
label={buttonLabels.save}
successLabel={
<Group spacing="xs">
<Icon name="check" /> {t`Saved`}
@@ -351,6 +372,7 @@ const SaveAndDiscardButtons = ({
activeLabel={<LoaderInButton size="1rem" />}
variant="filled"
data-testid="strategy-form-submit-button"
className="strategy-form-submit-button"
/>
</>
);

View File

@@ -8,7 +8,8 @@ import { useConfirmation } from "metabase/hooks/use-confirmation";
import { useConfirmOnRouteLeave } from "./useConfirmOnRouteLeave";
export const useConfirmIfFormIsDirty = (
router: InjectedRouter,
/** If not specified, no confirmation will occur on route leave */
router?: InjectedRouter,
route?: Route,
) => {
const [isStrategyFormDirty, setIsStrategyFormDirty] = useState(false);

View File

@@ -11,7 +11,7 @@ export const useConfirmOnRouteLeave = ({
shouldConfirm,
confirm,
}: {
router: InjectedRouter;
router?: InjectedRouter;
route?: Route;
shouldConfirm: boolean;
confirm: (onConfirm: () => void) => void;
@@ -20,7 +20,7 @@ export const useConfirmOnRouteLeave = ({
const [nextLocation, setNextLocation] = useState<Location>();
useEffect(() => {
if (!route) {
if (!route || !router) {
return;
}
const removeLeaveHook = router.setRouteLeaveHook(route, location => {

View File

@@ -112,6 +112,8 @@ export const Strategies: Record<StrategyType, StrategyData> = {
switch (model) {
case "dashboard":
return t`Use default: each question will use its own policy or the database policy`;
case "question":
return t`Use default: use the database or dashboard policy`;
default:
return t`Use default`;
}

View File

@@ -3,6 +3,8 @@ import { useEffect, useState } from "react";
import { Transition } from "metabase/ui";
import type { LoadingSpinnerProps } from "../LoadingSpinner/LoadingSpinner";
import LoadingAndErrorWrapper from "./LoadingAndErrorWrapper";
export type LoadingAndErrorWrapperProps = {
@@ -11,9 +13,11 @@ export type LoadingAndErrorWrapperProps = {
loading: any;
noBackground?: boolean;
noWrapper?: boolean;
noPadding?: boolean;
children?: ReactNode;
style?: object;
showSpinner?: boolean;
spinnerProps?: LoadingSpinnerProps;
loadingMessages?: string[];
messageInterval?: number;
loadingScenes?: string[];
@@ -30,6 +34,7 @@ export const DelayedLoadingAndErrorWrapper = ({
loading,
delay = 300,
blankComponent = null,
spinnerProps,
children,
...props
}: {
@@ -63,7 +68,12 @@ export const DelayedLoadingAndErrorWrapper = ({
>
{styles => (
<div style={styles}>
<LoadingAndErrorWrapper error={error} loading={loading} {...props}>
<LoadingAndErrorWrapper
error={error}
loading={loading}
spinnerProps={spinnerProps}
{...props}
>
{children}
</LoadingAndErrorWrapper>
</div>

View File

@@ -19,6 +19,7 @@ export default class LoadingAndErrorWrapper extends Component {
loading: PropTypes.any,
noBackground: PropTypes.bool,
noWrapper: PropTypes.bool,
noPadding: PropTypes.bool,
children: PropTypes.any,
style: PropTypes.object,
showSpinner: PropTypes.bool,
@@ -27,6 +28,7 @@ export default class LoadingAndErrorWrapper extends Component {
loadingScenes: PropTypes.array,
renderError: PropTypes.func,
"data-testid": PropTypes.string,
spinnerProps: PropTypes.object,
};
static defaultProps = {
@@ -37,6 +39,7 @@ export default class LoadingAndErrorWrapper extends Component {
showSpinner: true,
loadingMessages: [t`Loading...`],
messageInterval: 6000,
noPadding: false,
};
renderError(contentClassName) {
@@ -114,6 +117,7 @@ export default class LoadingAndErrorWrapper extends Component {
loading,
error,
noBackground,
noPadding,
noWrapper,
showSpinner,
loadingMessages,
@@ -121,13 +125,13 @@ export default class LoadingAndErrorWrapper extends Component {
style,
className,
"data-testid": testId,
spinnerProps,
} = this.props;
const { messageIndex, sceneIndex } = this.state;
const contentClassName = cx(
CS.wrapper,
CS.py4,
CS.textBrand,
CS.textCentered,
CS.flexFull,
@@ -135,6 +139,7 @@ export default class LoadingAndErrorWrapper extends Component {
CS.flexColumn,
CS.layoutCentered,
{ [CS.bgWhite]: !noBackground },
{ [CS.py4]: !noPadding },
);
if (noWrapper && !error && !loading) {
@@ -152,10 +157,14 @@ export default class LoadingAndErrorWrapper extends Component {
) : loading ? (
<div className={contentClassName}>
{loadingScenes && loadingScenes[sceneIndex]}
{!loadingScenes && showSpinner && <LoadingSpinner />}
<h2 className={cx(CS.textNormal, CS.textLight, CS.mt1)}>
{loadingMessages[messageIndex]}
</h2>
{!loadingScenes && showSpinner && (
<LoadingSpinner {...spinnerProps} />
)}
{loadingMessages.length > 0 && (
<h2 className={cx(CS.textNormal, CS.textLight, CS.mt1)}>
{loadingMessages[messageIndex]}
</h2>
)}
</div>
) : (
this.getChildren()

View File

@@ -3,7 +3,7 @@ import { Icon } from "metabase/ui";
import { SpinnerIcon, SpinnerRoot } from "./LoadingSpinner.styled";
interface Props {
export interface LoadingSpinnerProps {
className?: string;
size?: number;
borderWidth?: number;
@@ -15,7 +15,7 @@ const BaseLoadingSpinner = ({
size = 32,
borderWidth = 4,
"data-testid": dataTestId,
}: Props) => (
}: LoadingSpinnerProps) => (
<SpinnerRoot
className={className}
data-testid={dataTestId ?? "loading-spinner"}

View File

@@ -1,53 +0,0 @@
import type { Dispatch, SetStateAction } from "react";
import { useMemo } from "react";
import { t } from "ttag";
import _ from "underscore";
import { useCacheConfigs } from "metabase/admin/performance/hooks/useCacheConfigs";
import { getShortStrategyLabel } from "metabase/admin/performance/strategies";
import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper";
import { Button, Flex } from "metabase/ui";
import type { CacheableDashboard, CacheableModel } from "metabase-types/api";
const configurableModels: CacheableModel[] = ["dashboard"];
type DashboardCacheSectionProps = {
dashboard: CacheableDashboard;
setPage: Dispatch<SetStateAction<"default" | "caching">>;
};
export const DashboardCacheSection = ({
dashboard,
setPage,
}: DashboardCacheSectionProps) => {
const { configs, loading, error } = useCacheConfigs({
configurableModels,
id: dashboard.id,
});
const targetConfig = useMemo(
() => _.findWhere(configs, { model_id: dashboard.id }),
[configs, dashboard.id],
);
const savedStrategy = targetConfig?.strategy;
const shortStrategyLabel =
getShortStrategyLabel(savedStrategy, "dashboard") || t`Use default`;
return (
<DelayedLoadingAndErrorWrapper loading={loading} error={error}>
<Flex align="center" justify="space-between">
{t`Caching policy`}
<Button
onClick={() => setPage("caching")}
variant="subtle"
radius={0}
p={0}
style={{ border: "none" }}
>
{shortStrategyLabel}
</Button>
</Flex>
</DelayedLoadingAndErrorWrapper>
);
};

View File

@@ -21,8 +21,6 @@ import { getUser } from "metabase/selectors/user";
import { Stack, Switch } from "metabase/ui";
import type { Dashboard } from "metabase-types/api";
import { DashboardCacheSection } from "../DashboardCacheSection/DashboardCacheSection";
import {
ContentSection,
DashboardInfoSidebarRoot,
@@ -137,7 +135,11 @@ const DashboardInfoSidebarBody = ({
onChange={e => handleToggleAutoApplyFilters(e.target.checked)}
/>
{showCaching && isCacheable && (
<DashboardCacheSection dashboard={dashboard} setPage={setPage} />
<PLUGIN_CACHING.SidebarCacheSection
model="dashboard"
item={dashboard}
setPage={setPage}
/>
)}
</Stack>
</ContentSection>

View File

@@ -12,11 +12,11 @@ import type { AnySchema } from "yup";
import noResultsSource from "assets/img/no_results.svg";
import { UNABLE_TO_CHANGE_ADMIN_PERMISSIONS } from "metabase/admin/permissions/constants/messages";
import {
type DataPermission,
type DatabaseEntityId,
type PermissionSubject,
DataPermissionValue,
type DatabaseEntityId,
type DataPermission,
type EntityId,
type PermissionSubject,
} from "metabase/admin/permissions/types";
import type { ADMIN_SETTINGS_SECTIONS } from "metabase/admin/settings/selectors";
import type {
@@ -27,11 +27,13 @@ import type {
import { getIconBase } from "metabase/lib/icon";
import PluginPlaceholder from "metabase/plugins/components/PluginPlaceholder";
import type { SearchFilterComponent } from "metabase/search/types";
import type { IconName, IconProps } from "metabase/ui";
import type { GroupProps, IconName, IconProps } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
import type Database from "metabase-lib/v1/metadata/Database";
import type {
Bookmark,
CacheableDashboard,
CacheableModel,
Collection,
CollectionAuthorityLevelConfig,
CollectionEssentials,
@@ -41,7 +43,6 @@ import type {
Group,
GroupPermissions,
GroupsPermissions,
CacheableModel,
Revision,
SearchResult,
User,
@@ -332,17 +333,31 @@ export type InvalidateNowButtonProps = {
targetName: string;
};
export type SidebarCacheSectionProps = {
item: CacheableDashboard | Question;
model: CacheableModel;
setPage: Dispatch<SetStateAction<"default" | "caching">>;
};
export type SidebarCacheFormProps = {
item: CacheableDashboard | Question;
model: CacheableModel;
setPage: (page: "default" | "caching") => void;
} & GroupProps;
export const PLUGIN_CACHING = {
cacheTTLFormField: null as any,
dashboardCacheTTLFormField: null,
questionCacheTTLFormField: null,
getQuestionsImplicitCacheTTL: (_question?: any) => null as number | null,
QuestionCacheSection: PluginPlaceholder as any,
DashboardCacheSection: PluginPlaceholder as any,
DatabaseCacheTimeField: PluginPlaceholder as any,
StrategyFormLauncherPanel: PluginPlaceholder as any,
GranularControlsExplanation: PluginPlaceholder as any,
DashboardStrategySidebar: PluginPlaceholder as any,
SidebarCacheSection:
PluginPlaceholder as ComponentType<SidebarCacheSectionProps>,
SidebarCacheForm: PluginPlaceholder as ComponentType<SidebarCacheFormProps>,
InvalidateNowButton:
PluginPlaceholder as ComponentType<InvalidateNowButtonProps>,
isEnabled: () => false,

View File

@@ -1,18 +1,20 @@
import { useState } from "react";
import { t } from "ttag";
import EditableText from "metabase/core/components/EditableText";
import Link from "metabase/core/components/Link";
import * as Urls from "metabase/lib/urls";
import { PLUGIN_MODERATION, PLUGIN_CACHING } from "metabase/plugins";
import { PLUGIN_CACHING, PLUGIN_MODERATION } from "metabase/plugins";
import { QuestionActivityTimeline } from "metabase/query_builder/components/QuestionActivityTimeline";
import { Stack } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
import ModelCacheManagementSection from "../ModelCacheManagementSection";
import {
Root,
ContentSection,
HeaderContainer,
Root,
} from "./QuestionInfoSidebar.styled";
interface QuestionInfoSidebarProps {
@@ -35,55 +37,68 @@ export const QuestionInfoSidebar = ({
}
};
const handleUpdateCacheTTL = (cache_ttl: number | undefined) => {
if (question.cacheTTL() !== cache_ttl) {
return onSave(question.setCacheTTL(cache_ttl));
}
};
const [page, setPage] = useState<"default" | "caching">("default");
return (
<Root>
<ContentSection>
<HeaderContainer>
<h3>{t`About`}</h3>
{question.type() === "model" && (
<Link
variant="brand"
to={Urls.modelDetail(question.card())}
>{t`Model details`}</Link>
<>
{page === "default" && (
<Root>
<ContentSection>
<HeaderContainer>
<h3>{t`About`}</h3>
{question.type() === "model" && (
<Link
variant="brand"
to={Urls.modelDetail(question.card())}
>{t`Model details`}</Link>
)}
</HeaderContainer>
<EditableText
initialValue={description}
placeholder={
!description && !canWrite
? t`No description`
: t`Add description`
}
isOptional
isMultiline
isMarkdown
isDisabled={!canWrite}
onChange={handleSave}
/>
<PLUGIN_MODERATION.QuestionModerationSection question={question} />
</ContentSection>
{question.type() === "model" && isPersisted && (
<ContentSection extraPadding>
<ModelCacheManagementSection model={question} />
</ContentSection>
)}
</HeaderContainer>
<EditableText
initialValue={description}
placeholder={
!description && !canWrite ? t`No description` : t`Add description`
}
isOptional
isMultiline
isMarkdown
isDisabled={!canWrite}
onChange={handleSave}
{hasCacheSection && (
<ContentSection extraPadding>
<Stack spacing="0.5rem">
<PLUGIN_CACHING.QuestionCacheSection
model="question"
item={question}
setPage={setPage}
/>
</Stack>
</ContentSection>
)}
<ContentSection extraPadding>
<QuestionActivityTimeline question={question} />
</ContentSection>
</Root>
)}
{page === "caching" && (
<PLUGIN_CACHING.SidebarCacheForm
item={question}
model="question"
setPage={setPage}
pt="md"
/>
<PLUGIN_MODERATION.QuestionModerationSection question={question} />
</ContentSection>
{question.type() === "model" && isPersisted && (
<ContentSection extraPadding>
<ModelCacheManagementSection model={question} />
</ContentSection>
)}
{hasCacheSection && (
<ContentSection extraPadding>
<PLUGIN_CACHING.QuestionCacheSection
question={question}
onSave={handleUpdateCacheTTL}
/>
</ContentSection>
)}
<ContentSection extraPadding>
<QuestionActivityTimeline question={question} />
</ContentSection>
</Root>
</>
);
};

View File

@@ -30,7 +30,7 @@ describe("QuestionInfoSidebar", () => {
"enable-query-caching": true,
});
await setupGranularCacheControls({ card, settings });
expect(screen.getByText("Cache Configuration")).toBeInTheDocument();
expect(screen.getByText("Caching policy")).toBeInTheDocument();
});
it("should not show caching controls if caching is disabled", async () => {
@@ -41,6 +41,6 @@ describe("QuestionInfoSidebar", () => {
"enable-query-caching": false,
});
await setupGranularCacheControls({ card, settings });
expect(screen.queryByText("Cache Configuration")).not.toBeInTheDocument();
expect(screen.queryByText("Cache policy")).not.toBeInTheDocument();
});
});

View File

@@ -6,6 +6,7 @@ import {
setupRevisionsEndpoints,
setupUsersEndpoints,
} from "__support__/server-mocks";
import { setupPerformanceEndpoints } from "__support__/server-mocks/performance";
import { mockSettings } from "__support__/settings";
import { createMockEntitiesState } from "__support__/store";
import { renderWithProviders, waitForLoaderToBeRemoved } from "__support__/ui";
@@ -37,6 +38,7 @@ export const setup = async ({
setupCardEndpoints(card);
setupUsersEndpoints([currentUser]);
setupRevisionsEndpoints([]);
setupPerformanceEndpoints([]);
const state = createMockState({
currentUser,