Skip to content
Snippets Groups Projects
Unverified Commit 111c47ee authored by Nick Fitzpatrick's avatar Nick Fitzpatrick Committed by GitHub
Browse files

Custom homepage (#30545)


* Adding admin settings

* updating homepage

* permissions

* Include custom homepage on current user

Response from `/api/user/current/` now always includes a key
`custom_homepage`. It is nil unless `custom-homepage` setting is true
and `custom-homepage-dashboard` is set to an id that is a valid
dashboard that the user can read. (note this includes archived
dashboards). In this circumstance, the response will be

```
  ...
  "custom_homepage": {
    "dashboard_id": 87
  },
  ...
```

* adding unit tests

* Homepage CTA

* cleanup and types

* e2e tests, small adustments

* lint

* type fix

* Editing Dashboard Reminder

* fixing unit test

* ensure custom homepage dashboard exists and is not archived

* handling dashboard archived scenario, use-dashboard-query

* polish

* Copy updates, removing personal collections from picker

* PR Feedback

* CI checks

* PR Feedback

* Adding default filter function

---------

Co-authored-by: default avatardan sutton <dan@dpsutton.com>
parent bf981596
No related merge requests found
Showing
with 296 additions and 17 deletions
import { popover, restore, visitDashboard } from "e2e/support/helpers";
import {
popover,
restore,
visitDashboard,
modal,
dashboardHeader,
} from "e2e/support/helpers";
describe("scenarios > home > homepage", () => {
beforeEach(() => {
......@@ -141,6 +147,73 @@ describe("scenarios > home > homepage", () => {
});
});
describe("scenarios > home > custom homepage", () => {
describe("setting custom homepage", () => {
beforeEach(() => {
restore();
cy.signInAsAdmin();
});
it("should give you the option to set a custom home page in settings", () => {
cy.visit("/admin/settings/general");
cy.findByTestId("custom-homepage-setting").findByRole("switch").click();
cy.findByTestId("custom-homepage-dashboard-setting")
.findByRole("button")
.click();
popover().findByText("Orders in a dashboard").click();
cy.findByRole("status").findByText("Saved");
cy.findByRole("navigation").findByText("Exit admin").click();
cy.location("pathname").should("equal", "/dashboard/1");
// Do a page refresh and test dashboard header
cy.visit("/");
cy.location("pathname").should("equal", "/dashboard/1");
dashboardHeader().within(() => {
cy.icon("pencil").click();
cy.findByText(/Remember that this dashboard is set as homepage/);
});
});
it("should give you the option to set a custom home page using home page CTA", () => {
cy.visit("/");
cy.get("main").findByText("Customize").click();
modal()
.findByText(/Select a dashboard/i)
.click();
//Ensure that personal collections have been removed
popover().contains("Your personal collection").should("not.exist");
popover().contains("All personal collections").should("not.exist");
popover().findByText("Orders in a dashboard").click();
modal().findByText("Save").click();
cy.location("pathname").should("equal", "/dashboard/1");
});
});
describe("custom homepage set", () => {
beforeEach(() => {
restore();
cy.signInAsAdmin();
cy.request("PUT", "/api/setting/custom-homepage", { value: true });
cy.request("PUT", "/api/setting/custom-homepage-dashboard", { value: 1 });
});
it("should redirect you if you do not have permissions for set dashboard", () => {
cy.signIn("nocollection");
cy.visit("/");
cy.location("pathname").should("equal", "/");
});
});
});
const pinItem = name => {
cy.findByText(name)
.closest("tr")
......
......@@ -137,6 +137,8 @@ export const createMockSettings = (opts?: Partial<Settings>): Settings => ({
"available-locales": null,
"cloud-gateway-ips": null,
"custom-formatting": {},
"custom-homepage": false,
"custom-homepage-dashboard": null,
"deprecation-notice-version": undefined,
"email-configured?": false,
"enable-embedding": false,
......
......@@ -5,6 +5,7 @@ export const createMockUser = (opts?: Partial<User>): User => ({
first_name: "Testy",
last_name: "Tableton",
common_name: `Testy Tableton`,
custom_homepage: null,
email: "user@metabase.test",
locale: null,
google_auth: false,
......
......@@ -177,6 +177,8 @@ export interface Settings {
"available-locales": LocaleData[] | null;
"cloud-gateway-ips": string[] | null;
"custom-formatting": FormattingSettings;
"custom-homepage": boolean;
"custom-homepage-dashboard": number | null;
"deprecation-notice-version"?: string;
"email-configured?": boolean;
"embedding-secret-key"?: string;
......
......@@ -26,6 +26,9 @@ export interface User extends BaseUser {
has_invited_second_user: boolean;
has_question_and_dashboard: boolean;
personal_collection_id: number;
custom_homepage: {
dashboard_id: number;
} | null;
}
// Used when hydrating `creator` property
......
......@@ -2,6 +2,7 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { Link } from "react-router";
import { bindActionCreators } from "@reduxjs/toolkit";
import { connect } from "react-redux";
import { t } from "ttag";
import _ from "underscore";
......@@ -40,11 +41,17 @@ const mapStateToProps = (state, props) => {
};
};
const mapDispatchToProps = {
initializeSettings,
updateSetting,
reloadSettings,
};
const mapDispatchToProps = dispatch => ({
...bindActionCreators(
{
initializeSettings,
updateSetting,
reloadSettings,
},
dispatch,
),
dispatch,
});
class SettingsEditorApp extends Component {
layout = null; // the reference to AdminLayout
......@@ -66,7 +73,8 @@ class SettingsEditorApp extends Component {
}
updateSetting = async (setting, newValue) => {
const { settingValues, updateSetting, reloadSettings } = this.props;
const { settingValues, updateSetting, reloadSettings, dispatch } =
this.props;
this.saveStatusRef.current.setSaving();
......@@ -101,6 +109,10 @@ class SettingsEditorApp extends Component {
await reloadSettings();
}
if (setting.postUpdateAction) {
await dispatch(setting.postUpdateAction());
}
this.saveStatusRef.current.setSaved();
const value = prepareAnalyticsValue(setting);
......
......@@ -4,11 +4,15 @@ import _ from "underscore";
import { createSelector } from "@reduxjs/toolkit";
import { t, jt } from "ttag";
import ExternalLink from "metabase/core/components/ExternalLink";
import MetabaseSettings from "metabase/lib/settings";
import { PersistedModelsApi, UtilApi } from "metabase/services";
import { PLUGIN_ADMIN_SETTINGS_UPDATES } from "metabase/plugins";
import { getUserIsAdmin } from "metabase/selectors/user";
import Breadcrumbs from "metabase/components/Breadcrumbs";
import { DashboardSelector } from "metabase/components/DashboardSelector";
import { refreshCurrentUser } from "metabase/redux/user";
import SettingCommaDelimitedInput from "./components/widgets/SettingCommaDelimitedInput";
import CustomGeoJSONWidget from "./components/widgets/CustomGeoJSONWidget";
import { UploadSettings } from "./components/UploadSettings";
......@@ -29,6 +33,7 @@ import FormattingWidget from "./components/widgets/FormattingWidget";
import FullAppEmbeddingLinkWidget from "./components/widgets/FullAppEmbeddingLinkWidget";
import ModelCachingScheduleWidget from "./components/widgets/ModelCachingScheduleWidget";
import SectionDivider from "./components/widgets/SectionDivider";
import SettingsUpdatesForm from "./components/SettingsUpdatesForm/SettingsUpdatesForm";
import SettingsEmailForm from "./components/SettingsEmailForm";
import SetupCheckList from "./setup/components/SetupCheckList";
......@@ -84,6 +89,24 @@ const SECTIONS = updateSectionsWithPlugins({
widget: SiteUrlWidget,
warningMessage: t`Only change this if you know what you're doing!`,
},
{
key: "custom-homepage",
display_name: t`Custom Homepage`,
type: "boolean",
postUpdateAction: refreshCurrentUser,
},
{
key: "custom-homepage-dashboard",
description: null,
getHidden: ({ "custom-homepage": customHomepage }) => !customHomepage,
widget: DashboardSelector,
postUpdateAction: refreshCurrentUser,
getProps: setting => ({
value: setting.value,
collectionFilter: collection =>
collection.personal_owner_id === null || collection.id === "root",
}),
},
{
key: "redirect-all-requests-to-https",
display_name: t`Redirect to HTTPS`,
......
export * from "./use-dashboard-query";
export * from "./use-database-id-field-list-query";
export * from "./use-database-list-query";
export * from "./use-database-query";
......
export * from "./use-dashboard-query";
import Dashboards from "metabase/entities/dashboards";
import {
useEntityQuery,
UseEntityQueryProps,
UseEntityQueryResult,
} from "metabase/common/hooks/use-entity-query";
import { DashboardId, Dashboard } from "metabase-types/api";
export const useDashboardQuery = (
props: UseEntityQueryProps<DashboardId, null>,
): UseEntityQueryResult<Dashboard> => {
return useEntityQuery(props, {
fetch: Dashboards.actions.fetch,
getObject: Dashboards.selectors.getObject,
getLoading: Dashboards.selectors.getLoading,
getError: Dashboards.selectors.getError,
});
};
import React from "react";
import { createMockDashboard } from "metabase-types/api/mocks";
import { setupDashboardEndpoints } from "__support__/server-mocks";
import {
renderWithProviders,
screen,
waitForElementToBeRemoved,
} from "__support__/ui";
import { useDashboardQuery } from "./use-dashboard-query";
const TEST_DASHBOARD = createMockDashboard();
const TestComponent = () => {
const { data, isLoading, error } = useDashboardQuery({
id: TEST_DASHBOARD.id,
});
if (isLoading) {
return <div>Loading...</div>;
} else if (error) {
return <div>Error</div>;
} else {
return <div>{data?.name}</div>;
}
};
const setup = () => {
setupDashboardEndpoints(TEST_DASHBOARD);
renderWithProviders(<TestComponent />);
};
describe("useDatabaseQuery", () => {
it("should be initially loading", () => {
setup();
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("should show data from the response", async () => {
setup();
await waitForElementToBeRemoved(() => screen.queryByText("Loading..."));
expect(screen.getByText(TEST_DASHBOARD.name)).toBeInTheDocument();
});
it("should return an error when it can't find a dashboard", async () => {
renderWithProviders(<TestComponent />);
await waitForElementToBeRemoved(() => screen.queryByText("Loading..."));
expect(screen.getByText("Error")).toBeInTheDocument();
});
});
......@@ -10,7 +10,10 @@ export default class AdminHeader extends Component {
<div className="MetadataEditor-headerSection float-left h2 text-medium">
{this.props.title}
</div>
<div className="MetadataEditor-headerSection absolute right float-right top bottom flex layout-centered">
<div
className="MetadataEditor-headerSection absolute right float-right top bottom flex layout-centered"
role="status"
>
<SaveStatus ref={this.props.saveStatusRef} />
</div>
</div>
......
import styled from "@emotion/styled";
import SelectButton from "metabase/core/components/SelectButton/SelectButton";
import DashboardPicker from "metabase/containers/DashboardPicker";
export const DashboardPickerContainer = styled.div`
padding: 1.5rem;
`;
export const StyledDashboardPicker = styled(DashboardPicker)`
min-width: 600px;
`;
export const DashboardPickerButton = styled(SelectButton)`
min-width: 400px;
`;
import React from "react";
import { t } from "ttag";
import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
import DashboardPicker from "metabase/containers/DashboardPicker";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { useDashboardQuery } from "metabase/common/hooks";
import { Collection } from "metabase-types/api";
import {
DashboardPickerContainer,
DashboardPickerButton,
} from "./DashboardSelector.styled";
interface DashboardSelectorProps {
onChange: (value?: number | null) => void;
value?: number;
collectionFilter?: (collection: Collection) => boolean;
}
export const DashboardSelector = ({
onChange,
value,
...rest
}: DashboardSelectorProps) => {
const {
data: dashboard,
error,
isLoading,
} = useDashboardQuery({ id: value });
return (
<LoadingAndErrorWrapper loading={isLoading}>
<TippyPopoverWithTrigger
sizeToFit
maxWidth={600}
renderTrigger={({ onClick }) => (
<DashboardPickerButton onClick={onClick}>
{dashboard?.name || t`Select a dashboard`}
</DashboardPickerButton>
)}
popoverContent={({ closePopover }) => (
<DashboardPickerContainer>
<DashboardPicker
value={error ? undefined : dashboard?.id}
onChange={value => {
closePopover();
onChange(value);
}}
{...rest}
/>
</DashboardPickerContainer>
)}
/>
</LoadingAndErrorWrapper>
);
};
export * from "./DashboardSelector";
import React from "react";
import { CollectionId } from "metabase-types/api";
import ItemPicker, { PickerValue, PickerItemId } from "./ItemPicker";
export interface DashboardPickerProps {
......
......@@ -36,6 +36,7 @@ interface OwnProps {
style?: React.CSSProperties;
onChange: (value: PickerValue) => void;
initialOpenCollectionId?: CollectionId;
collectionFilter?: (collection: Collection) => boolean;
}
interface StateProps {
......@@ -55,7 +56,7 @@ function canWriteToCollectionOrChildren(collection: Collection) {
function mapStateToProps(state: State, props: OwnProps) {
const entity = props.entity || Collections;
return {
collectionsById: entity.selectors.getExpandedCollectionsById(state),
collectionsById: entity.selectors.getExpandedCollectionsById(state, props),
getCollectionIcon: entity.objectSelectors.getIcon,
};
}
......
......@@ -32,6 +32,7 @@ import {
import { hasDatabaseActionsEnabled } from "metabase/dashboard/utils";
import { saveDashboardPdf } from "metabase/visualizations/lib/save-dashboard-pdf";
import { getSetting } from "metabase/selectors/settings";
import DashboardHeaderView from "../components/DashboardHeaderView";
import { SIDEBAR_NAME } from "../constants";
......@@ -46,6 +47,9 @@ const mapStateToProps = (state, props) => {
isNavBarOpen: getIsNavbarOpen(state),
isShowingDashboardInfoSidebar: getIsShowDashboardInfoSidebar(state),
selectedTabId: state.dashboard.selectedTabId,
isHomepageDashboard:
getSetting(state, "custom-homepage") &&
getSetting(state, "custom-homepage-dashboard") === props.dashboard?.id,
};
};
......@@ -451,6 +455,7 @@ class DashboardHeader extends Component {
isAdditionalInfoVisible,
setDashboardAttribute,
setSidebar,
isHomepageDashboard,
} = this.props;
const hasLastEditInfo = dashboard["last-edit-info"] != null;
......@@ -468,7 +473,11 @@ class DashboardHeader extends Component {
isNavBarOpen={this.props.isNavBarOpen}
headerButtons={this.getHeaderButtons()}
editWarning={this.getEditWarning(dashboard)}
editingTitle={t`You're editing this dashboard.`}
editingTitle={t`You're editing this dashboard.`.concat(
isHomepageDashboard
? t` Remember that this dashboard is set as homepage.`
: "",
)}
editingButtons={this.getEditingButtons()}
setDashboardAttribute={setDashboardAttribute}
onLastEditInfoClick={() => setSidebar({ name: SIDEBAR_NAME.info })}
......
......@@ -80,11 +80,16 @@ const Collections = createEntity({
selectors: {
getExpandedCollectionsById: createSelector(
[state => state.entities.collections || {}, getUserPersonalCollectionId],
(collections, currentUserPersonalCollectionId) =>
[
state => state.entities.collections || {},
getUserPersonalCollectionId,
(state, props) => props?.collectionFilter,
],
(collections, currentUserPersonalCollectionId, collectionFilter) =>
getExpandedCollectionsById(
Object.values(collections),
currentUserPersonalCollectionId,
collectionFilter,
),
),
getInitialCollectionId,
......
......@@ -10,9 +10,14 @@ import {
// given list of collections with { id, name, location } returns a map of ids to
// expanded collection objects like { id, name, location, path, children }
// including a root collection
function getExpandedCollectionsById(collections, userPersonalCollectionId) {
function getExpandedCollectionsById(
collections,
userPersonalCollectionId,
collectionFilter = () => true,
) {
const collectionsById = {};
for (const c of collections) {
const filteredCollections = collections.filter(collectionFilter);
for (const c of filteredCollections) {
collectionsById[c.id] = {
...c,
path:
......@@ -39,7 +44,10 @@ function getExpandedCollectionsById(collections, userPersonalCollectionId) {
};
// "My personal collection"
if (userPersonalCollectionId != null) {
if (
userPersonalCollectionId != null &&
!!collectionsById[userPersonalCollectionId]
) {
const personalCollection = collectionsById[userPersonalCollectionId];
collectionsById[ROOT_COLLECTION.id].children.push({
...PERSONAL_COLLECTION,
......@@ -63,7 +71,7 @@ function getExpandedCollectionsById(collections, userPersonalCollectionId) {
// iterate over original collections so we don't include ROOT_COLLECTION as
// a child of itself
for (const { id } of collections) {
for (const { id } of filteredCollections) {
const c = collectionsById[id];
// don't add root as parent of itself
if (c.path && c.id !== ROOT_COLLECTION.id) {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment