Compare commits
17 Commits
python-run
...
browse/ref
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff72b78d8 | ||
|
|
4bf933b250 | ||
|
|
6b9f842d0f | ||
|
|
52725b7abe | ||
|
|
6d2830d00f | ||
|
|
e464f8685e | ||
|
|
6e85ae45e0 | ||
|
|
0f7258bd07 | ||
|
|
1b44329707 | ||
|
|
0a731767c3 | ||
|
|
910b6cc0db | ||
|
|
f8de941e7a | ||
|
|
37f97ed701 | ||
|
|
5f6c89f31e | ||
|
|
b074b74790 | ||
|
|
4ec636c7ad | ||
|
|
48a888a354 |
@@ -64,9 +64,8 @@ export function closeNavigationSidebar() {
|
||||
appBar().findByTestId("sidebar-toggle").click();
|
||||
}
|
||||
|
||||
export function browse() {
|
||||
// takes you to `/browse` (reflecting changes made in `0.38-collection-redesign)
|
||||
return navigationSidebar().findByText("Browse data");
|
||||
export function browseData() {
|
||||
return navigationSidebar().findByLabelText("Browse data");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -276,7 +276,7 @@ describe("scenarios > admin > databases > sample database", () => {
|
||||
|
||||
cy.wait("@loadDatabases");
|
||||
cy.findByTestId("main-navbar-root").within(() => {
|
||||
cy.findByText("Browse data").should("not.exist");
|
||||
cy.findByLabelText("Browse data").should("not.exist");
|
||||
});
|
||||
|
||||
cy.visit("/admin/databases");
|
||||
@@ -293,8 +293,8 @@ describe("scenarios > admin > databases > sample database", () => {
|
||||
|
||||
cy.wait("@loadDatabases");
|
||||
cy.findByTestId("main-navbar-root").within(() => {
|
||||
cy.findByText("Browse data").should("exist");
|
||||
cy.findByText("Browse data").click();
|
||||
cy.findByLabelText("Browse data").should("exist");
|
||||
cy.findByLabelText("Browse data").click();
|
||||
});
|
||||
|
||||
cy.findByRole("tab", { name: "Databases" }).click();
|
||||
|
||||
@@ -163,7 +163,7 @@ describeEE("scenarios > embedding > full app", () => {
|
||||
describe("browse data", () => {
|
||||
it("should hide the top nav when nothing is shown", () => {
|
||||
visitFullAppEmbeddingUrl({
|
||||
url: "/browse",
|
||||
url: "/browse/databases",
|
||||
qs: { side_nav: false, logo: false },
|
||||
});
|
||||
cy.findByRole("heading", { name: /Browse data/ }).should("be.visible");
|
||||
|
||||
@@ -106,6 +106,6 @@ export function selectFromDropdown(option, clickOpts) {
|
||||
export function startQuestionFromModel(modelName) {
|
||||
cy.findByTestId("app-bar").findByText("New").click();
|
||||
popover().findByText("Question").should("be.visible").click();
|
||||
cy.findByText("Models").click();
|
||||
cy.findByLabelText("Browse models").click();
|
||||
cy.findByText(modelName).click();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("issue 25537", () => {
|
||||
cy.createQuestion(questionDetails);
|
||||
|
||||
startNewQuestion();
|
||||
cy.icon("model").click();
|
||||
cy.findByRole("menuitem", { name: /Modelle/i }).click();
|
||||
cy.wait("@getCollectionContent");
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
|
||||
@@ -23,7 +23,7 @@ describeWithSnowplow("scenarios > browse data", () => {
|
||||
|
||||
it("can browse to a model", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("listitem", { name: "Browse data" }).click();
|
||||
cy.findByRole("listitem", { name: "Browse models" }).click();
|
||||
cy.location("pathname").should("eq", "/browse/models");
|
||||
cy.findByTestId("browse-app").findByText("Browse data");
|
||||
cy.findByRole("heading", { name: "Orders Model" }).click();
|
||||
@@ -34,6 +34,7 @@ describeWithSnowplow("scenarios > browse data", () => {
|
||||
model_id: ORDERS_MODEL_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("can browse to a table", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("listitem", { name: "Browse data" }).click();
|
||||
@@ -60,29 +61,9 @@ describeWithSnowplow("scenarios > browse data", () => {
|
||||
cy.findByRole("heading", { name: "Products" }).click();
|
||||
cy.findByRole("gridcell", { name: "Rustic Paper Wallet" });
|
||||
});
|
||||
it("the Browse data page shows the last-used tab by default", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("listitem", { name: "Browse data" }).click();
|
||||
cy.log(
|
||||
"/browse/ defaults to /browse/models/ because no tabs have been visited yet and there are some models to show",
|
||||
);
|
||||
cy.location("pathname").should("eq", "/browse/models");
|
||||
cy.findByRole("tab", { name: "Databases" }).click();
|
||||
cy.findByRole("listitem", { name: "Browse data" }).click();
|
||||
cy.log(
|
||||
"/browse/ now defaults to /browse/databases/ because it was the last tab visited",
|
||||
);
|
||||
cy.location("pathname").should("eq", "/browse/databases");
|
||||
cy.findByRole("tab", { name: "Models" }).click();
|
||||
cy.findByRole("listitem", { name: "Browse data" }).click();
|
||||
cy.log(
|
||||
"/browse/ now defaults to /browse/models/ because it was the last tab visited",
|
||||
);
|
||||
cy.location("pathname").should("eq", "/browse/models");
|
||||
});
|
||||
it("/browse/models has no switch for controlling the 'only show verified models' filter, on an open-source instance", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("listitem", { name: "Browse data" }).click();
|
||||
cy.findByRole("listitem", { name: "Browse models" }).click();
|
||||
cy.findByRole("switch", { name: /Only show verified models/ }).should(
|
||||
"not.exist",
|
||||
);
|
||||
@@ -102,7 +83,7 @@ describeWithSnowplowEE("scenarios > browse data (EE)", () => {
|
||||
cy.findByRole("switch", { name: /Only show verified models/ });
|
||||
setTokenFeatures("all");
|
||||
cy.visit("/");
|
||||
cy.findByRole("listitem", { name: "Browse data" }).click();
|
||||
cy.findByRole("listitem", { name: "Browse models" }).click();
|
||||
cy.findByRole("heading", { name: "Our analytics" }).should("not.exist");
|
||||
cy.findByRole("heading", { name: "Orders Model" }).should("not.exist");
|
||||
toggle().next("label").click();
|
||||
|
||||
@@ -23,7 +23,6 @@ describe("scenarios > question > bookmarks", () => {
|
||||
navigationSidebar().within(() => {
|
||||
getSectionTitle(/Bookmarks/);
|
||||
cy.findByText("Orders");
|
||||
cy.icon("model").should("not.exist");
|
||||
});
|
||||
|
||||
// Rename bookmarked question
|
||||
@@ -49,6 +48,7 @@ describe("scenarios > question > bookmarks", () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Turn back to saved question").click();
|
||||
|
||||
openNavigationSidebar();
|
||||
navigationSidebar().within(() => {
|
||||
cy.icon("model").should("not.exist");
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("11914, 18978, 18977", () => {
|
||||
it("should not display query editing controls and 'Browse Data' link", () => {
|
||||
cy.log("Make sure we don't prompt user to browse data from the sidebar");
|
||||
openNavigationSidebar();
|
||||
cy.findByTestId("main-navbar-root").should("not.contain", "Browse data");
|
||||
cy.findByLabelText("Browse data").should("not.exist");
|
||||
|
||||
cy.log("Make sure we don't prompt user to create a new query");
|
||||
appBar().icon("add").click();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import {
|
||||
browse,
|
||||
browseData,
|
||||
restore,
|
||||
openOrdersTable,
|
||||
openNavigationSidebar,
|
||||
@@ -455,8 +455,8 @@ describe("scenarios > question > settings", () => {
|
||||
|
||||
// create a new question to see if the "add to a dashboard" modal is still there
|
||||
openNavigationSidebar();
|
||||
browse().click();
|
||||
cy.findByRole("tab", { name: "Databases" }).click();
|
||||
browseData().click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Sample Database").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
|
||||
@@ -1,46 +1,81 @@
|
||||
import { useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import type { ModelFilterControlsProps } from "metabase/browse/utils";
|
||||
import { Switch, Text } from "metabase/ui";
|
||||
import type {
|
||||
ActualModelFilters,
|
||||
ModelFilterControlsProps,
|
||||
} from "metabase/browse/utils";
|
||||
import { useUserSetting } from "metabase/common/hooks";
|
||||
import { Button, Icon, Popover, Switch, Text } from "metabase/ui";
|
||||
|
||||
export const ModelFilterControls = ({
|
||||
actualModelFilters,
|
||||
handleModelFilterChange,
|
||||
setActualModelFilters,
|
||||
}: ModelFilterControlsProps) => {
|
||||
const [__, setVerifiedFilterStatus] = useUserSetting(
|
||||
"browse-filter-only-verified-models",
|
||||
{ shouldRefresh: false },
|
||||
);
|
||||
const setVerifiedFilterStatusDebounced = _.debounce(
|
||||
setVerifiedFilterStatus,
|
||||
200,
|
||||
);
|
||||
|
||||
const handleModelFilterChange = useCallback(
|
||||
(modelFilterName: string, active: boolean) => {
|
||||
// For now, only one filter is supported
|
||||
setVerifiedFilterStatusDebounced(active);
|
||||
setActualModelFilters((prev: ActualModelFilters) => {
|
||||
return { ...prev, [modelFilterName]: active };
|
||||
});
|
||||
},
|
||||
[setActualModelFilters, setVerifiedFilterStatusDebounced],
|
||||
);
|
||||
|
||||
const checked = actualModelFilters.onlyShowVerifiedModels;
|
||||
return (
|
||||
<Switch
|
||||
label={
|
||||
<Text
|
||||
align="end"
|
||||
weight="bold"
|
||||
lh="1rem"
|
||||
px=".75rem"
|
||||
>{t`Only show verified models`}</Text>
|
||||
}
|
||||
role="switch"
|
||||
checked={checked}
|
||||
aria-checked={checked}
|
||||
onChange={e => {
|
||||
handleModelFilterChange("onlyShowVerifiedModels", e.target.checked);
|
||||
}}
|
||||
size="sm"
|
||||
labelPosition="left"
|
||||
styles={{
|
||||
root: {
|
||||
marginInlineStart: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
body: {
|
||||
alignItems: "center",
|
||||
// Align with tab labels:
|
||||
position: "relative",
|
||||
top: "-.5px",
|
||||
},
|
||||
labelWrapper: { justifyContent: "center", padding: 0 },
|
||||
track: { marginTop: "-1.5px" },
|
||||
}}
|
||||
/>
|
||||
<Popover position="bottom-end">
|
||||
<Popover.Target>
|
||||
<Button p="sm" variant="subtle" color="text-dark">
|
||||
<Icon name="filter" />
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="lg">
|
||||
<Switch
|
||||
label={
|
||||
<Text
|
||||
align="end"
|
||||
weight="bold"
|
||||
lh="1rem"
|
||||
px=".75rem"
|
||||
>{t`Only show verified models`}</Text>
|
||||
}
|
||||
role="switch"
|
||||
checked={checked}
|
||||
aria-checked={checked}
|
||||
onChange={e => {
|
||||
handleModelFilterChange("onlyShowVerifiedModels", e.target.checked);
|
||||
}}
|
||||
size="sm"
|
||||
labelPosition="left"
|
||||
styles={{
|
||||
root: {
|
||||
marginInlineStart: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
body: {
|
||||
alignItems: "center",
|
||||
// Align with tab labels:
|
||||
position: "relative",
|
||||
top: "-.5px",
|
||||
},
|
||||
labelWrapper: { justifyContent: "center", padding: 0 },
|
||||
track: { marginTop: "-1.5px" },
|
||||
}}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
availableModelFilters,
|
||||
sortCollectionsByVerification,
|
||||
sortModelsByVerification,
|
||||
useModelFilterSettings,
|
||||
} from "./utils";
|
||||
|
||||
if (hasPremiumFeature("content_verification")) {
|
||||
@@ -16,5 +17,6 @@ if (hasPremiumFeature("content_verification")) {
|
||||
availableModelFilters,
|
||||
sortModelsByVerification,
|
||||
sortCollectionsByVerification,
|
||||
useModelFilterSettings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { AvailableModelFilters } from "metabase/browse/utils";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type {
|
||||
ActualModelFilters,
|
||||
AvailableModelFilters,
|
||||
} from "metabase/browse/utils";
|
||||
import { useUserSetting } from "metabase/common/hooks";
|
||||
import type { CollectionEssentials, SearchResult } from "metabase-types/api";
|
||||
|
||||
export const sortCollectionsByVerification = (
|
||||
@@ -34,3 +40,16 @@ export const availableModelFilters: AvailableModelFilters = {
|
||||
activeByDefault: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const useModelFilterSettings = (): ActualModelFilters => {
|
||||
const [initialVerifiedFilterStatus, __] = useUserSetting(
|
||||
"browse-filter-only-verified-models",
|
||||
{ shouldRefresh: false },
|
||||
);
|
||||
return useMemo(
|
||||
() => ({
|
||||
onlyShowVerifiedModels: initialVerifiedFilterStatus ?? false,
|
||||
}),
|
||||
[initialVerifiedFilterStatus],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface Collection {
|
||||
|
||||
location: string | null;
|
||||
effective_location?: string; // location path containing only those collections that the user has permission to access
|
||||
effective_ancestors?: Collection[];
|
||||
effective_ancestors?: Pick<Collection, "id" | "name">[];
|
||||
|
||||
here?: CollectionContentModel[];
|
||||
below?: CollectionContentModel[];
|
||||
@@ -110,6 +110,7 @@ export interface CollectionItem {
|
||||
setPinned?: (isPinned: boolean) => void;
|
||||
setCollection?: (collection: Pick<Collection, "id">) => void;
|
||||
setCollectionPreview?: (isEnabled: boolean) => void;
|
||||
collection_ancestors?: Pick<Collection, "id" | "name">[];
|
||||
}
|
||||
|
||||
export interface CollectionListQuery {
|
||||
|
||||
@@ -166,6 +166,8 @@ export const createMockSettings = (
|
||||
"enable-enhancements?": false,
|
||||
"enable-nested-queries": true,
|
||||
"enable-query-caching": undefined,
|
||||
"expand-browse-in-nav": true,
|
||||
"expand-bookmarks-in-nav": true,
|
||||
"query-caching-ttl-ratio": 10,
|
||||
"query-caching-min-ttl": 60,
|
||||
"enable-password-login": true,
|
||||
|
||||
@@ -72,7 +72,8 @@ export type SearchResponse<
|
||||
export type CollectionEssentials = Pick<
|
||||
Collection,
|
||||
"id" | "name" | "authority_level" | "type"
|
||||
>;
|
||||
> &
|
||||
Partial<Collection>;
|
||||
|
||||
export type SearchResultId =
|
||||
| CollectionId
|
||||
@@ -119,6 +120,11 @@ export interface SearchResult<
|
||||
can_write: boolean | null;
|
||||
}
|
||||
|
||||
export enum SortDirection {
|
||||
Asc = "asc",
|
||||
Desc = "desc",
|
||||
}
|
||||
|
||||
export type SearchRequest = {
|
||||
q?: string;
|
||||
archived?: boolean;
|
||||
@@ -132,6 +138,12 @@ export type SearchRequest = {
|
||||
last_edited_by?: UserId[];
|
||||
search_native_query?: boolean | null;
|
||||
verified?: boolean | null;
|
||||
model_ancestors?: boolean | null;
|
||||
// TODO: Support these in the BE. Note that sorting on collection_path is tricky because this string is assembled in the FE
|
||||
// Is it reaaaaally necessary to do this via the BE or can we just sort in the FE? Will there be a real performance hit? =
|
||||
// And are we supporting pagination?
|
||||
sort_column?: string;
|
||||
sort_direction?: SortDirection;
|
||||
|
||||
// this should be in ListCollectionItemsRequest but legacy code expects them here
|
||||
collection?: CollectionId;
|
||||
|
||||
@@ -315,13 +315,19 @@ interface PublicSettings {
|
||||
"version-info-last-checked": string | null;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
export interface BrowseFilterSettings {
|
||||
"browse-filter-only-verified-models"?: boolean;
|
||||
}
|
||||
|
||||
export type UserSettings = {
|
||||
"dismissed-browse-models-banner"?: boolean;
|
||||
"dismissed-custom-dashboard-toast"?: boolean;
|
||||
"last-used-native-database-id"?: number | null;
|
||||
"notebook-native-preview-shown"?: boolean;
|
||||
"notebook-native-preview-sidebar-width"?: number | null;
|
||||
}
|
||||
"expand-browse-in-nav"?: boolean;
|
||||
"expand-bookmarks-in-nav"?: boolean;
|
||||
} & BrowseFilterSettings;
|
||||
|
||||
export type Settings = InstanceSettings &
|
||||
PublicSettings &
|
||||
|
||||
@@ -422,19 +422,9 @@ const MultiplierFieldSubtitle = () => (
|
||||
<div>
|
||||
{t`To determine how long each cached result should stick around, we take that query's average execution time and multiply that by what you input here. The result is how many seconds the cache should remain valid for.`}{" "}
|
||||
<Tooltip
|
||||
events={{
|
||||
hover: true,
|
||||
focus: true,
|
||||
touch: true,
|
||||
}}
|
||||
variant="multiline"
|
||||
inline={true}
|
||||
styles={{
|
||||
tooltip: {
|
||||
whiteSpace: "normal",
|
||||
},
|
||||
}}
|
||||
label={t`If a query takes on average 120 seconds (2 minutes) to run, and you input 10 for your multiplier, its cache entry will persist for 1,200 seconds (20 minutes).`}
|
||||
maw="20rem"
|
||||
>
|
||||
<Text tabIndex={0} lh="1" display="inline" c="brand">
|
||||
{t`Example`}
|
||||
|
||||
@@ -6,10 +6,10 @@ import { provideSearchItemListTags } from "./tags";
|
||||
export const searchApi = Api.injectEndpoints({
|
||||
endpoints: builder => ({
|
||||
search: builder.query<SearchResponse, SearchRequest>({
|
||||
query: ({ limit, offset, ...body }) => ({
|
||||
query: ({ limit, offset, sort_column, sort_direction, ...body }) => ({
|
||||
method: "GET",
|
||||
url: "/api/search",
|
||||
params: { limit, offset },
|
||||
params: { limit, offset, sort_column, sort_direction },
|
||||
body,
|
||||
}),
|
||||
providesTags: (response, error, { models }) =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
breakpointMinMedium,
|
||||
breakpointMinSmall,
|
||||
} from "metabase/styled-components/theme";
|
||||
import { Grid, Icon, Tabs } from "metabase/ui";
|
||||
import { Flex, Grid, Icon, Tabs } from "metabase/ui";
|
||||
|
||||
export const BrowseAppRoot = styled.div`
|
||||
flex: 1;
|
||||
@@ -38,27 +38,35 @@ export const BrowseTab = styled(Tabs.Tab)`
|
||||
}
|
||||
`;
|
||||
|
||||
export const BrowseTabsPanel = styled(Tabs.Panel)`
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding: 0 2.5rem;
|
||||
`;
|
||||
|
||||
export const BrowseContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-flow: column nowrap;
|
||||
height: 100%;
|
||||
margin-top: 1rem;
|
||||
|
||||
container-name: ItemsTableContainer;
|
||||
container-type: inline-size;
|
||||
`;
|
||||
|
||||
export const BrowseDataHeader = styled.header`
|
||||
export const BrowseSection = styled(Flex)`
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const BrowseHeader = styled.header`
|
||||
display: flex;
|
||||
padding: 1rem 2.5rem;
|
||||
padding-bottom: 0.375rem;
|
||||
flex-direction: column;
|
||||
padding: 1rem 2.5rem 3rem 2.5rem;
|
||||
color: ${color("dark")};
|
||||
background-color: ${color("white")};
|
||||
`;
|
||||
|
||||
export const BrowseMain = styled.main`
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
flex: 1;
|
||||
padding: 0 2.5rem;
|
||||
padding-bottom: 2rem;
|
||||
`;
|
||||
|
||||
export const BrowseGrid = styled(Grid)`
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import {
|
||||
useDatabaseListQuery,
|
||||
useSearchListQuery,
|
||||
} from "metabase/common/hooks";
|
||||
import Link from "metabase/core/components/Link";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
|
||||
import type { FlexProps } from "metabase/ui";
|
||||
import { Flex, Text } from "metabase/ui";
|
||||
import type { SearchResult } from "metabase-types/api";
|
||||
|
||||
import type { ActualModelFilters, BrowseTabId } from "../utils";
|
||||
import { filterModels, isValidBrowseTab } from "../utils";
|
||||
|
||||
import {
|
||||
BrowseAppRoot,
|
||||
BrowseContainer,
|
||||
BrowseDataHeader,
|
||||
BrowseTab,
|
||||
BrowseTabs,
|
||||
BrowseTabsList,
|
||||
BrowseTabsPanel,
|
||||
LearnAboutDataIcon,
|
||||
} from "./BrowseApp.styled";
|
||||
import { BrowseDatabases } from "./BrowseDatabases";
|
||||
import { BrowseHeaderIconContainer } from "./BrowseHeader.styled";
|
||||
import { BrowseModels } from "./BrowseModels";
|
||||
|
||||
const availableModelFilters = PLUGIN_CONTENT_VERIFICATION.availableModelFilters;
|
||||
|
||||
export const BrowseApp = ({
|
||||
tab,
|
||||
children,
|
||||
}: {
|
||||
tab: BrowseTabId;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const modelsResult = useSearchListQuery<SearchResult>({
|
||||
query: {
|
||||
models: ["dataset"],
|
||||
filter_items_in_personal_collection: "exclude",
|
||||
},
|
||||
});
|
||||
const databasesResult = useDatabaseListQuery();
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("defaultBrowseTab", tab);
|
||||
}, [tab]);
|
||||
|
||||
const getInitialModelFilters = () => {
|
||||
return _.reduce(
|
||||
availableModelFilters,
|
||||
(acc, filter, filterName) => {
|
||||
const storedFilterStatus = localStorage.getItem(
|
||||
`browseFilters.${filterName}`,
|
||||
);
|
||||
const shouldFilterBeActive =
|
||||
storedFilterStatus === null
|
||||
? filter.activeByDefault
|
||||
: storedFilterStatus === "on";
|
||||
return {
|
||||
...acc,
|
||||
[filterName]: shouldFilterBeActive,
|
||||
};
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
const [actualModelFilters, setActualModelFilters] =
|
||||
useState<ActualModelFilters>(getInitialModelFilters);
|
||||
const { data: unfilteredModels = [] } = modelsResult;
|
||||
|
||||
const filteredModels = useMemo(
|
||||
() =>
|
||||
filterModels(unfilteredModels, actualModelFilters, availableModelFilters),
|
||||
[unfilteredModels, actualModelFilters],
|
||||
);
|
||||
const filteredModelsResult = { ...modelsResult, data: filteredModels };
|
||||
|
||||
const handleModelFilterChange = useCallback(
|
||||
(modelFilterName: string, active: boolean) => {
|
||||
localStorage.setItem(
|
||||
`browseFilters.${modelFilterName}`,
|
||||
active ? "on" : "off",
|
||||
);
|
||||
setActualModelFilters((prev: ActualModelFilters) => {
|
||||
return { ...prev, [modelFilterName]: active };
|
||||
});
|
||||
},
|
||||
[setActualModelFilters],
|
||||
);
|
||||
|
||||
return (
|
||||
<BrowseAppRoot data-testid="browse-app">
|
||||
<BrowseContainer>
|
||||
<BrowseDataHeader>
|
||||
<BrowseSection>
|
||||
<h2>{t`Browse data`}</h2>
|
||||
</BrowseSection>
|
||||
</BrowseDataHeader>
|
||||
<BrowseTabs
|
||||
value={tab}
|
||||
onTabChange={value => {
|
||||
if (isValidBrowseTab(value)) {
|
||||
dispatch(push(`/browse/${value}`));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BrowseTabsList>
|
||||
<BrowseSection>
|
||||
<BrowseTab key={"models"} value={"models"}>
|
||||
{t`Models`}
|
||||
</BrowseTab>
|
||||
<BrowseTab key={"databases"} value={"databases"}>
|
||||
{t`Databases`}
|
||||
</BrowseTab>
|
||||
{tab === "models" && (
|
||||
<PLUGIN_CONTENT_VERIFICATION.ModelFilterControls
|
||||
actualModelFilters={actualModelFilters}
|
||||
handleModelFilterChange={handleModelFilterChange}
|
||||
/>
|
||||
)}
|
||||
{tab === "databases" && <LearnAboutDataLink />}
|
||||
</BrowseSection>
|
||||
</BrowseTabsList>
|
||||
<BrowseTabsPanel key={tab} value={tab}>
|
||||
<BrowseSection direction="column">
|
||||
<BrowseTabContent
|
||||
tab={tab}
|
||||
modelsResult={filteredModelsResult}
|
||||
databasesResult={databasesResult}
|
||||
>
|
||||
{children}
|
||||
</BrowseTabContent>
|
||||
</BrowseSection>
|
||||
</BrowseTabsPanel>
|
||||
</BrowseTabs>
|
||||
</BrowseContainer>
|
||||
</BrowseAppRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const BrowseTabContent = ({
|
||||
tab,
|
||||
children,
|
||||
modelsResult,
|
||||
databasesResult,
|
||||
}: {
|
||||
tab: BrowseTabId;
|
||||
children?: React.ReactNode;
|
||||
modelsResult: ReturnType<typeof useSearchListQuery<SearchResult>>;
|
||||
databasesResult: ReturnType<typeof useDatabaseListQuery>;
|
||||
}) => {
|
||||
if (children) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
if (tab === "models") {
|
||||
return <BrowseModels modelsResult={modelsResult} />;
|
||||
} else {
|
||||
return <BrowseDatabases databasesResult={databasesResult} />;
|
||||
}
|
||||
};
|
||||
|
||||
const LearnAboutDataLink = () => (
|
||||
<Flex ml="auto" justify="right" align="center" style={{ flexBasis: "40.0%" }}>
|
||||
<Link to="reference">
|
||||
<BrowseHeaderIconContainer>
|
||||
<LearnAboutDataIcon size={14} name="reference" />
|
||||
<Text size="md" lh="1" fw="bold" ml=".5rem" c="inherit">
|
||||
{t`Learn about our data`}
|
||||
</Text>
|
||||
</BrowseHeaderIconContainer>
|
||||
</Link>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const BrowseSection = (props: FlexProps) => (
|
||||
<Flex h="100%" maw="64rem" m="0 auto" w="100%" {...props} />
|
||||
);
|
||||
@@ -1,26 +1,32 @@
|
||||
import { Link } from "react-router";
|
||||
import { t } from "ttag";
|
||||
|
||||
import NoResults from "assets/img/no_results.svg";
|
||||
import type { useDatabaseListQuery } from "metabase/common/hooks";
|
||||
import { useListDatabasesQuery } from "metabase/api";
|
||||
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { Box, Icon, Title } from "metabase/ui";
|
||||
import { Box, Flex, Group, Icon, Text, Title } from "metabase/ui";
|
||||
|
||||
import { CenteredEmptyState } from "./BrowseApp.styled";
|
||||
import {
|
||||
BrowseContainer,
|
||||
BrowseHeader,
|
||||
BrowseMain,
|
||||
BrowseSection,
|
||||
CenteredEmptyState,
|
||||
LearnAboutDataIcon,
|
||||
} from "./BrowseApp.styled";
|
||||
import {
|
||||
DatabaseCard,
|
||||
DatabaseCardLink,
|
||||
DatabaseGrid,
|
||||
} from "./BrowseDatabases.styled";
|
||||
import { BrowseHeaderIconContainer } from "./BrowseHeader.styled";
|
||||
|
||||
export const BrowseDatabases = ({
|
||||
databasesResult,
|
||||
}: {
|
||||
databasesResult: ReturnType<typeof useDatabaseListQuery>;
|
||||
}) => {
|
||||
const { data: databases, error, isLoading } = databasesResult;
|
||||
export const BrowseDatabases = () => {
|
||||
const { data, isLoading, error } = useListDatabasesQuery();
|
||||
const databases = data?.data;
|
||||
|
||||
if (error) {
|
||||
return <LoadingAndErrorWrapper error />;
|
||||
@@ -30,34 +36,76 @@ export const BrowseDatabases = ({
|
||||
return <LoadingAndErrorWrapper loading />;
|
||||
}
|
||||
|
||||
return databases?.length ? (
|
||||
<DatabaseGrid data-testid="database-browser">
|
||||
{databases.map(database => (
|
||||
<div key={database.id}>
|
||||
<DatabaseCardLink to={Urls.browseDatabase(database)}>
|
||||
<DatabaseCard>
|
||||
<Icon
|
||||
name="database"
|
||||
color={color("accent2")}
|
||||
className={CS.mb3}
|
||||
size={32}
|
||||
/>
|
||||
<Title order={2} size="1rem" lh="1rem" color="inherit">
|
||||
{database.name}
|
||||
</Title>
|
||||
</DatabaseCard>
|
||||
</DatabaseCardLink>
|
||||
</div>
|
||||
))}
|
||||
</DatabaseGrid>
|
||||
) : (
|
||||
<CenteredEmptyState
|
||||
title={<Box mb=".5rem">{t`No databases here yet`}</Box>}
|
||||
illustrationElement={
|
||||
<Box mb=".5rem">
|
||||
<img src={NoResults} />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
if (!databases?.length) {
|
||||
return (
|
||||
<CenteredEmptyState
|
||||
title={<Box mb=".5rem">{t`No databases here yet`}</Box>}
|
||||
illustrationElement={
|
||||
<Box mb=".5rem">
|
||||
<img src={NoResults} />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowseContainer>
|
||||
<BrowseHeader>
|
||||
<BrowseSection>
|
||||
<Flex w="100%" direction="row" justify="space-between" align="center">
|
||||
<Title order={1} color="text-dark">
|
||||
<Group spacing="sm">
|
||||
<Icon color={color("brand")} name="database" />
|
||||
{t`Databases`}
|
||||
</Group>
|
||||
</Title>
|
||||
<LearnAboutDataLink />
|
||||
</Flex>
|
||||
</BrowseSection>
|
||||
</BrowseHeader>
|
||||
<BrowseMain>
|
||||
<BrowseSection>
|
||||
<DatabaseGrid data-testid="database-browser">
|
||||
{databases.map(database => (
|
||||
<div key={database.id}>
|
||||
<DatabaseCardLink to={Urls.browseDatabase(database)}>
|
||||
<DatabaseCard>
|
||||
<Icon
|
||||
name="database"
|
||||
color={color("accent2")}
|
||||
className={CS.mb3}
|
||||
size={32}
|
||||
/>
|
||||
<Title order={2} size="1rem" lh="1rem" color="inherit">
|
||||
{database.name}
|
||||
</Title>
|
||||
</DatabaseCard>
|
||||
</DatabaseCardLink>
|
||||
</div>
|
||||
))}
|
||||
</DatabaseGrid>
|
||||
</BrowseSection>
|
||||
</BrowseMain>
|
||||
</BrowseContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const LearnAboutDataLink = () => (
|
||||
<Flex
|
||||
ml="auto"
|
||||
p=".75rem"
|
||||
justify="right"
|
||||
align="center"
|
||||
style={{ flexBasis: "40.0%" }}
|
||||
>
|
||||
<Link to="reference">
|
||||
<BrowseHeaderIconContainer>
|
||||
<LearnAboutDataIcon size={14} name="reference" />
|
||||
<Text size="md" lh="1" fw="bold" ml=".5rem" c="inherit">
|
||||
{t`Learn about our data`}
|
||||
</Text>
|
||||
</BrowseHeaderIconContainer>
|
||||
</Link>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { setupDatabasesEndpoints } from "__support__/server-mocks";
|
||||
import { renderWithProviders, screen } from "__support__/ui";
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import { createMockDatabase } from "metabase-types/api/mocks";
|
||||
|
||||
import { BrowseDatabases } from "./BrowseDatabases";
|
||||
|
||||
const renderBrowseDatabases = (modelCount: number) => {
|
||||
const databases = mockDatabases.slice(0, modelCount);
|
||||
return renderWithProviders(
|
||||
<BrowseDatabases
|
||||
databasesResult={{ data: databases, isLoading: false, error: false }}
|
||||
/>,
|
||||
);
|
||||
setupDatabasesEndpoints(databases);
|
||||
return renderWithProviders(<BrowseDatabases />);
|
||||
};
|
||||
|
||||
const mockDatabases = [...Array(100)].map(
|
||||
(_, index) =>
|
||||
createMockDatabase({ id: index, name: `Database ${index}` }) as Database,
|
||||
const mockDatabases = [...Array(100)].map((_, index) =>
|
||||
createMockDatabase({ id: index, name: `Database ${index}` }),
|
||||
);
|
||||
|
||||
describe("BrowseDatabases", () => {
|
||||
|
||||
@@ -1,119 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import NoResults from "assets/img/no_results.svg";
|
||||
import type { useSearchListQuery } from "metabase/common/hooks";
|
||||
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getLocale } from "metabase/setup/selectors";
|
||||
import { Box } from "metabase/ui";
|
||||
import type { SearchResult, CollectionId } from "metabase-types/api";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
|
||||
import { Flex, Group, Icon, Title } from "metabase/ui";
|
||||
|
||||
import { BROWSE_MODELS_LOCALSTORAGE_KEY } from "../constants";
|
||||
import { getCollectionViewPreferences, groupModels } from "../utils";
|
||||
import type { ActualModelFilters } from "../utils";
|
||||
|
||||
import { CenteredEmptyState } from "./BrowseApp.styled";
|
||||
import { ModelGrid } from "./BrowseModels.styled";
|
||||
import { ModelExplanationBanner } from "./ModelExplanationBanner";
|
||||
import { ModelGroup } from "./ModelGroup";
|
||||
import {
|
||||
BrowseContainer,
|
||||
BrowseHeader,
|
||||
BrowseMain,
|
||||
BrowseSection,
|
||||
} from "./BrowseApp.styled";
|
||||
import { BrowseModelsBody } from "./BrowseModelsBody";
|
||||
|
||||
export const BrowseModels = ({
|
||||
modelsResult,
|
||||
}: {
|
||||
modelsResult: ReturnType<typeof useSearchListQuery<SearchResult>>;
|
||||
}) => {
|
||||
const { data: models = [], error, isLoading } = modelsResult;
|
||||
const locale = useSelector(getLocale);
|
||||
const localeCode: string | undefined = locale?.code;
|
||||
const [collectionViewPreferences, setCollectionViewPreferences] = useState(
|
||||
getCollectionViewPreferences,
|
||||
);
|
||||
const { useModelFilterSettings } = PLUGIN_CONTENT_VERIFICATION;
|
||||
|
||||
if (error || isLoading) {
|
||||
return (
|
||||
<LoadingAndErrorWrapper
|
||||
error={error}
|
||||
loading={isLoading}
|
||||
style={{ display: "flex", flex: 1 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const BrowseModels = () => {
|
||||
const initialModelFilters = useModelFilterSettings();
|
||||
|
||||
const handleToggleCollectionExpand = (collectionId: CollectionId) => {
|
||||
const newPreferences = {
|
||||
...collectionViewPreferences,
|
||||
[collectionId]: {
|
||||
expanded: !(
|
||||
collectionViewPreferences?.[collectionId]?.expanded ?? true
|
||||
),
|
||||
showAll: !!collectionViewPreferences?.[collectionId]?.showAll,
|
||||
},
|
||||
};
|
||||
setCollectionViewPreferences(newPreferences);
|
||||
localStorage.setItem(
|
||||
BROWSE_MODELS_LOCALSTORAGE_KEY,
|
||||
JSON.stringify(newPreferences),
|
||||
);
|
||||
};
|
||||
const [actualModelFilters, setActualModelFilters] =
|
||||
useState<ActualModelFilters>(initialModelFilters);
|
||||
|
||||
const handleToggleCollectionShowAll = (collectionId: CollectionId) => {
|
||||
const newPreferences = {
|
||||
...collectionViewPreferences,
|
||||
[collectionId]: {
|
||||
expanded: collectionViewPreferences?.[collectionId]?.expanded ?? true,
|
||||
showAll: !collectionViewPreferences?.[collectionId]?.showAll,
|
||||
},
|
||||
};
|
||||
setCollectionViewPreferences(newPreferences);
|
||||
localStorage.setItem(
|
||||
BROWSE_MODELS_LOCALSTORAGE_KEY,
|
||||
JSON.stringify(newPreferences),
|
||||
);
|
||||
};
|
||||
|
||||
const groupsOfModels = groupModels(models, localeCode);
|
||||
|
||||
if (models.length) {
|
||||
return (
|
||||
<>
|
||||
<ModelExplanationBanner />
|
||||
<ModelGrid role="grid">
|
||||
{groupsOfModels.map(groupOfModels => {
|
||||
const collectionId = groupOfModels[0].collection.id;
|
||||
return (
|
||||
<ModelGroup
|
||||
expanded={
|
||||
collectionViewPreferences?.[collectionId]?.expanded ?? true
|
||||
}
|
||||
showAll={!!collectionViewPreferences?.[collectionId]?.showAll}
|
||||
toggleExpanded={() =>
|
||||
handleToggleCollectionExpand(collectionId)
|
||||
}
|
||||
toggleShowAll={() =>
|
||||
handleToggleCollectionShowAll(collectionId)
|
||||
}
|
||||
models={groupOfModels}
|
||||
key={`modelgroup-${collectionId}`}
|
||||
localeCode={localeCode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ModelGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
setActualModelFilters(initialModelFilters);
|
||||
}, [initialModelFilters, setActualModelFilters]);
|
||||
|
||||
return (
|
||||
<CenteredEmptyState
|
||||
title={<Box mb=".5rem">{t`No models here yet`}</Box>}
|
||||
message={
|
||||
<Box maw="24rem">{t`Models help curate data to make it easier to find answers to questions all in one place.`}</Box>
|
||||
}
|
||||
illustrationElement={
|
||||
<Box mb=".5rem">
|
||||
<img src={NoResults} />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<BrowseContainer>
|
||||
<BrowseHeader>
|
||||
<BrowseSection>
|
||||
<Flex w="100%" direction="row" justify="space-between" align="center">
|
||||
<Title order={1} color="text-dark">
|
||||
<Group spacing="sm">
|
||||
<Icon size={18} color={color("brand")} name="model" />
|
||||
{t`Models`}
|
||||
</Group>
|
||||
</Title>
|
||||
<PLUGIN_CONTENT_VERIFICATION.ModelFilterControls
|
||||
actualModelFilters={actualModelFilters}
|
||||
setActualModelFilters={setActualModelFilters}
|
||||
/>
|
||||
</Flex>
|
||||
</BrowseSection>
|
||||
</BrowseHeader>
|
||||
<BrowseMain>
|
||||
<BrowseSection>
|
||||
<BrowseModelsBody actualModelFilters={actualModelFilters} />
|
||||
</BrowseSection>
|
||||
</BrowseMain>
|
||||
</BrowseContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import {
|
||||
setupSearchEndpoints,
|
||||
setupSettingsEndpoints,
|
||||
} from "__support__/server-mocks";
|
||||
import { renderWithProviders, screen } from "__support__/ui";
|
||||
import { defaultRootCollection } from "metabase/admin/permissions/pages/CollectionPermissionsPage/tests/setup";
|
||||
import type { SearchResult } from "metabase-types/api";
|
||||
@@ -9,24 +11,19 @@ import {
|
||||
} from "metabase-types/api/mocks";
|
||||
import { createMockSetupState } from "metabase-types/store/mocks";
|
||||
|
||||
import { BROWSE_MODELS_LOCALSTORAGE_KEY } from "../constants";
|
||||
|
||||
import { BrowseModels } from "./BrowseModels";
|
||||
|
||||
const renderBrowseModels = (modelCount: number) => {
|
||||
const setup = (modelCount: number) => {
|
||||
const models = mockModels.slice(0, modelCount);
|
||||
return renderWithProviders(
|
||||
<BrowseModels
|
||||
modelsResult={{ data: models, isLoading: false, error: false }}
|
||||
/>,
|
||||
{
|
||||
storeInitialState: {
|
||||
setup: createMockSetupState({
|
||||
locale: { name: "English", code: "en" },
|
||||
}),
|
||||
},
|
||||
setupSearchEndpoints(models);
|
||||
setupSettingsEndpoints([]);
|
||||
return renderWithProviders(<BrowseModels />, {
|
||||
storeInitialState: {
|
||||
setup: createMockSetupState({
|
||||
locale: { name: "English", code: "en" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const collectionAlpha = createMockCollection({ id: 99, name: "Alpha" });
|
||||
@@ -212,154 +209,19 @@ const mockModels: SearchResult[] = [
|
||||
].map(model => createMockModelResult(model));
|
||||
|
||||
describe("BrowseModels", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
it("displays a 'no models' message in the Models tab when no models exist", async () => {
|
||||
renderBrowseModels(0);
|
||||
setup(0);
|
||||
expect(await screen.findByText("No models here yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays collection groups", async () => {
|
||||
renderBrowseModels(10);
|
||||
expect(await screen.findByText("Alpha")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Beta")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Charlie")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Delta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays models in collections by default", () => {
|
||||
const modelCount = 22;
|
||||
renderBrowseModels(modelCount);
|
||||
expect(screen.queryByText("No models here yet")).not.toBeInTheDocument();
|
||||
assertThatModelsExist(0, modelCount - 1);
|
||||
});
|
||||
|
||||
it("can collapse collections to hide models within them", async () => {
|
||||
renderBrowseModels(10);
|
||||
await userEvent.click(await screen.findByLabelText("collapse Alpha"));
|
||||
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Model 1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Model 2")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("collapse Beta"));
|
||||
expect(screen.queryByText("Model 3")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Model 4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Model 5")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can expand a collection to see models within it", async () => {
|
||||
renderBrowseModels(10);
|
||||
await userEvent.click(await screen.findByLabelText("collapse Alpha"));
|
||||
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByLabelText("expand Alpha"));
|
||||
expect(await screen.findByText("Model 0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the Our Analytics collection if it has a model", async () => {
|
||||
renderBrowseModels(25);
|
||||
await screen.findByText("Alpha");
|
||||
await screen.findByText("Our analytics");
|
||||
setup(25);
|
||||
expect(await screen.findByRole("table")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findAllByTestId("path-for-collection: Our analytics"),
|
||||
).toHaveLength(2);
|
||||
expect(await screen.findByText("Model 20")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Model 21")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Model 22")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the first six models in a collection by default", async () => {
|
||||
renderBrowseModels(9999);
|
||||
expect(await screen.findByText("100 models")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Show all")).toBeInTheDocument();
|
||||
assertThatModelsExist(300, 305);
|
||||
});
|
||||
|
||||
it("can show more than 6 models by clicking 'Show all'", async () => {
|
||||
renderBrowseModels(9999);
|
||||
await screen.findByText("6 of 100");
|
||||
expect(screen.queryByText("Model 350")).not.toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByText("Show all"));
|
||||
assertThatModelsExist(300, 399);
|
||||
});
|
||||
|
||||
it("can show less than all models by clicking 'Show less'", async () => {
|
||||
renderBrowseModels(9999);
|
||||
expect(screen.queryByText("Model 399")).not.toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByText("Show all"));
|
||||
await screen.findByText("Model 301");
|
||||
expect(screen.getByText("Model 399")).toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByText("Show less"));
|
||||
await screen.findByText("Model 301");
|
||||
expect(screen.queryByText("Model 399")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists show-all state when expanding and collapsing collections", async () => {
|
||||
renderBrowseModels(9999);
|
||||
await userEvent.click(screen.getByText("Show all"));
|
||||
expect(await screen.findByText("Model 301")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model 399")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText("collapse Grande"));
|
||||
expect(screen.queryByText("Model 301")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Model 399")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText("expand Grande"));
|
||||
expect(await screen.findByText("Model 301")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model 399")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("local storage", () => {
|
||||
it("persists the expanded state of collections in local storage", async () => {
|
||||
renderBrowseModels(10);
|
||||
await userEvent.click(await screen.findByLabelText("collapse Alpha"));
|
||||
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem(BROWSE_MODELS_LOCALSTORAGE_KEY)).toEqual(
|
||||
JSON.stringify({ 99: { expanded: false, showAll: false } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads the collapsed state of collections from local storage", async () => {
|
||||
localStorage.setItem(
|
||||
BROWSE_MODELS_LOCALSTORAGE_KEY,
|
||||
JSON.stringify({ 99: { expanded: false, showAll: false } }),
|
||||
);
|
||||
renderBrowseModels(10);
|
||||
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists the 'show all' state of collections in local storage", async () => {
|
||||
renderBrowseModels(9999);
|
||||
await userEvent.click(await screen.findByText("Show all"));
|
||||
await screen.findByText("Model 399");
|
||||
expect(localStorage.getItem(BROWSE_MODELS_LOCALSTORAGE_KEY)).toEqual(
|
||||
JSON.stringify({ 7: { expanded: true, showAll: true } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads the 'show all' state of collections from local storage", async () => {
|
||||
localStorage.setItem(
|
||||
BROWSE_MODELS_LOCALSTORAGE_KEY,
|
||||
JSON.stringify({ 7: { expanded: true, showAll: true } }),
|
||||
);
|
||||
renderBrowseModels(9999);
|
||||
expect(await screen.findByText("Show less")).toBeInTheDocument();
|
||||
assertThatModelsExist(300, 399);
|
||||
});
|
||||
|
||||
it("can deal with invalid local storage data", async () => {
|
||||
localStorage.setItem(BROWSE_MODELS_LOCALSTORAGE_KEY, "{invalid json[[[}");
|
||||
renderBrowseModels(10);
|
||||
expect(await screen.findByText("Model 0")).toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByLabelText("collapse Alpha"));
|
||||
expect(screen.queryByText("Model 0")).not.toBeInTheDocument();
|
||||
// ignores invalid data and persists the new state
|
||||
expect(localStorage.getItem(BROWSE_MODELS_LOCALSTORAGE_KEY)).toEqual(
|
||||
JSON.stringify({ 99: { expanded: false, showAll: false } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function assertThatModelsExist(startId: number, endId: number) {
|
||||
for (let i = startId; i <= endId; i++) {
|
||||
expect(screen.getByText(`Model ${i}`)).toBeInTheDocument();
|
||||
}
|
||||
}
|
||||
|
||||
116
frontend/src/metabase/browse/components/BrowseModelsBody.tsx
Normal file
116
frontend/src/metabase/browse/components/BrowseModelsBody.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import NoResults from "assets/img/no_results.svg";
|
||||
import { useSearchQuery } from "metabase/api";
|
||||
import type { SortingOptions } from "metabase/components/ItemsTable/BaseItemsTable";
|
||||
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
|
||||
import Search from "metabase/entities/search";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
|
||||
import { Box, Stack } from "metabase/ui";
|
||||
import type { CollectionEssentials, SearchRequest } from "metabase-types/api";
|
||||
import { SortDirection } from "metabase-types/api";
|
||||
|
||||
import { filterModels, type ActualModelFilters } from "../utils";
|
||||
|
||||
import { CenteredEmptyState } from "./BrowseApp.styled";
|
||||
import { ModelExplanationBanner } from "./ModelExplanationBanner";
|
||||
import { ModelsTable } from "./ModelsTable";
|
||||
import { getCollectionPathString } from "./utils";
|
||||
|
||||
const { availableModelFilters } = PLUGIN_CONTENT_VERIFICATION;
|
||||
|
||||
export const BrowseModelsBody = ({
|
||||
actualModelFilters,
|
||||
}: {
|
||||
actualModelFilters: ActualModelFilters;
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [sortingOptions, setSortingOptions] = useState<SortingOptions>({
|
||||
sort_column: "name",
|
||||
sort_direction: SortDirection.Asc,
|
||||
});
|
||||
|
||||
const query: SearchRequest = {
|
||||
models: ["dataset"], // 'model' in the sense of 'type of thing'
|
||||
model_ancestors: true,
|
||||
filter_items_in_personal_collection: "exclude",
|
||||
};
|
||||
|
||||
const { data, error, isLoading } = useSearchQuery(query);
|
||||
const unfilteredModels = useMemo(() => data?.data, [data]);
|
||||
|
||||
const filteredModels = useMemo(
|
||||
() =>
|
||||
filterModels(
|
||||
unfilteredModels || [],
|
||||
actualModelFilters,
|
||||
availableModelFilters,
|
||||
),
|
||||
[unfilteredModels, actualModelFilters],
|
||||
);
|
||||
|
||||
const sortedModels = useMemo(() => {
|
||||
const { sort_column, sort_direction } = sortingOptions;
|
||||
const sorted = _.sortBy(filteredModels, model => {
|
||||
if (sort_column === "collection") {
|
||||
const collection: CollectionEssentials = model.collection;
|
||||
return getCollectionPathString(collection);
|
||||
}
|
||||
if (sort_column in model) {
|
||||
return model[sort_column as keyof typeof model];
|
||||
} else {
|
||||
console.error("Invalid sort column", sort_column);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
if (sort_direction === SortDirection.Desc) {
|
||||
sorted.reverse();
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredModels, sortingOptions]);
|
||||
|
||||
const wrappedModels = useMemo(
|
||||
() => sortedModels.map(model => Search.wrapEntity(model, dispatch)),
|
||||
[sortedModels, dispatch],
|
||||
);
|
||||
|
||||
if (error || isLoading) {
|
||||
return (
|
||||
<LoadingAndErrorWrapper
|
||||
error={error}
|
||||
loading={isLoading}
|
||||
style={{ display: "flex", flex: 1 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredModels.length) {
|
||||
return (
|
||||
<Stack spacing="md" mb="lg">
|
||||
<ModelExplanationBanner />
|
||||
<ModelsTable
|
||||
items={wrappedModels}
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={setSortingOptions}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CenteredEmptyState
|
||||
title={<Box mb=".5rem">{t`No models here yet`}</Box>}
|
||||
message={
|
||||
<Box maw="24rem">{t`Models help curate data to make it easier to find answers to questions all in one place.`}</Box>
|
||||
}
|
||||
illustrationElement={
|
||||
<Box mb=".5rem">
|
||||
<img src={NoResults} />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { replace } from "react-router-redux";
|
||||
|
||||
import { useSearchListQuery } from "metabase/common/hooks";
|
||||
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import type { SearchResult } from "metabase-types/api";
|
||||
|
||||
export const BrowseRedirect = () => {
|
||||
const defaultTab = localStorage.getItem("defaultBrowseTab");
|
||||
|
||||
const shouldQueryModels = !defaultTab;
|
||||
|
||||
const {
|
||||
data: models,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSearchListQuery<SearchResult>({
|
||||
query: {
|
||||
models: ["dataset"],
|
||||
filter_items_in_personal_collection: "exclude",
|
||||
},
|
||||
enabled: shouldQueryModels,
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
switch (defaultTab) {
|
||||
case "models":
|
||||
dispatch(replace("/browse/models"));
|
||||
break;
|
||||
case "databases":
|
||||
dispatch(replace("/browse/databases"));
|
||||
break;
|
||||
default:
|
||||
if (models !== undefined) {
|
||||
if (models.length > 0) {
|
||||
dispatch(replace("/browse/models"));
|
||||
} else {
|
||||
dispatch(replace("/browse/databases"));
|
||||
}
|
||||
}
|
||||
if (!error && !isLoading) {
|
||||
dispatch(replace("/browse/models"));
|
||||
}
|
||||
}
|
||||
}, [models, defaultTab, dispatch, error, isLoading]);
|
||||
|
||||
if (error || isLoading) {
|
||||
return (
|
||||
<LoadingAndErrorWrapper
|
||||
error={error}
|
||||
loading={isLoading}
|
||||
style={{ display: "flex", flex: 1 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import { replace } from "react-router-redux";
|
||||
|
||||
import { setupSearchEndpoints } from "__support__/server-mocks";
|
||||
import { renderWithProviders, waitFor } from "__support__/ui";
|
||||
import type { SearchResult } from "metabase-types/api";
|
||||
import { createMockModelResult } from "metabase-types/api/mocks";
|
||||
|
||||
import { BrowseRedirect } from "./BrowseRedirect";
|
||||
|
||||
const mockModels: SearchResult[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: "Model 0",
|
||||
collection: { id: 0, name: "Alpha" },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: "Model 1",
|
||||
collection: { id: 0, name: "Alpha" },
|
||||
},
|
||||
].map(model => createMockModelResult(model));
|
||||
|
||||
const setup = ({
|
||||
models,
|
||||
defaultTab = null,
|
||||
}: {
|
||||
models: SearchResult[];
|
||||
defaultTab: string | null;
|
||||
}) => {
|
||||
setupSearchEndpoints(models);
|
||||
if (defaultTab === null) {
|
||||
localStorage.removeItem("defaultBrowseTab");
|
||||
} else {
|
||||
localStorage.setItem("defaultBrowseTab", defaultTab);
|
||||
}
|
||||
return renderWithProviders(<BrowseRedirect />);
|
||||
};
|
||||
|
||||
describe("BrowseRedirect", () => {
|
||||
it("redirects to /browse/databases if there are no models and no saved setting", async () => {
|
||||
const { store } = setup({ models: [], defaultTab: null });
|
||||
const mockDispatch = jest.spyOn(store, "dispatch");
|
||||
await waitFor(() => {
|
||||
expect(mockDispatch).toHaveBeenCalledWith(replace("/browse/databases"));
|
||||
});
|
||||
});
|
||||
it("redirects to /browse/models if there are some models but no saved setting", async () => {
|
||||
const { store } = setup({ models: mockModels, defaultTab: null });
|
||||
const mockDispatch = jest.spyOn(store, "dispatch");
|
||||
await waitFor(() => {
|
||||
expect(mockDispatch).toHaveBeenCalledWith(replace("/browse/models"));
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to /browse/models if the user's defaultBrowseTab setting is 'models'", async () => {
|
||||
const { store, rerender } = setup({
|
||||
models: [],
|
||||
defaultTab: "models",
|
||||
});
|
||||
const mockDispatch = jest.spyOn(store, "dispatch");
|
||||
rerender(<BrowseRedirect />);
|
||||
await waitFor(() => {
|
||||
expect(mockDispatch).toHaveBeenCalledWith(replace("/browse/models"));
|
||||
});
|
||||
});
|
||||
it("redirects to /browse/databases if the user's defaultBrowseBab setting is 'databases'", async () => {
|
||||
const { store, rerender } = setup({
|
||||
models: mockModels,
|
||||
defaultTab: "databases",
|
||||
});
|
||||
const mockDispatch = jest.spyOn(store, "dispatch");
|
||||
rerender(<BrowseRedirect />);
|
||||
await waitFor(() => {
|
||||
expect(mockDispatch).toHaveBeenCalledWith(replace("/browse/databases"));
|
||||
});
|
||||
});
|
||||
it("redirects to /browse/models if the user has an invalid defaultBrowseTab setting, and some models exist", async () => {
|
||||
const { store, rerender } = setup({
|
||||
models: mockModels,
|
||||
defaultTab: "this is an invalid value",
|
||||
});
|
||||
const mockDispatch = jest.spyOn(store, "dispatch");
|
||||
rerender(<BrowseRedirect />);
|
||||
await waitFor(() => {
|
||||
expect(mockDispatch).toHaveBeenCalledWith(replace("/browse/models"));
|
||||
});
|
||||
});
|
||||
});
|
||||
91
frontend/src/metabase/browse/components/BrowseSchemas.tsx
Normal file
91
frontend/src/metabase/browse/components/BrowseSchemas.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import cx from "classnames";
|
||||
import { t } from "ttag";
|
||||
|
||||
import TableBrowser from "metabase/browse/containers/TableBrowser";
|
||||
import { BrowserCrumbs } from "metabase/components/BrowserCrumbs";
|
||||
import Card from "metabase/components/Card";
|
||||
import EntityItem from "metabase/components/EntityItem";
|
||||
import { Grid } from "metabase/components/Grid";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import Database from "metabase/entities/databases";
|
||||
import Schema from "metabase/entities/schemas";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import type { CollectionItem } from "metabase-types/api";
|
||||
|
||||
import { BrowseContainer, BrowseMain } from "./BrowseApp.styled";
|
||||
import { BrowseHeaderContent } from "./BrowseHeader.styled";
|
||||
import { SchemaGridItem, SchemaLink } from "./SchemaBrowser.styled";
|
||||
|
||||
const BrowseSchemas = ({
|
||||
schemas,
|
||||
params,
|
||||
slug,
|
||||
}: {
|
||||
schemas: CollectionItem[];
|
||||
params: any;
|
||||
slug: string;
|
||||
}) => {
|
||||
const dbId = Urls.extractEntityId(slug);
|
||||
return (
|
||||
<BrowseContainer>
|
||||
<BrowseMain>
|
||||
{schemas.length === 1 ? (
|
||||
<TableBrowser
|
||||
schemas={schemas}
|
||||
params={params}
|
||||
slug={slug}
|
||||
dbId={dbId}
|
||||
schemaName={schemas[0].name}
|
||||
// hide the schema since there's only one
|
||||
showSchemaInHeader={false}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<BrowseHeaderContent>
|
||||
<BrowserCrumbs
|
||||
crumbs={[
|
||||
{ title: t`Databases`, to: "/browse/databases" },
|
||||
{ title: <Database.Name id={dbId} /> },
|
||||
]}
|
||||
/>
|
||||
</BrowseHeaderContent>
|
||||
{schemas.length === 0 ? (
|
||||
<h2
|
||||
className={cx(CS.full, CS.textCentered, CS.textMedium)}
|
||||
>{t`This database doesn't have any tables.`}</h2>
|
||||
) : (
|
||||
<Grid>
|
||||
{schemas.map(schema => (
|
||||
<SchemaGridItem key={schema.id}>
|
||||
<SchemaLink
|
||||
to={`/browse/databases/${dbId}/schema/${encodeURIComponent(
|
||||
schema.name,
|
||||
)}`}
|
||||
>
|
||||
<Card hoverable className={CS.px1}>
|
||||
<EntityItem
|
||||
name={schema.name}
|
||||
iconName="folder"
|
||||
// TODO: Is it necessary to support this color?
|
||||
// iconColor={color("accent2")}
|
||||
item={schema}
|
||||
/>
|
||||
</Card>
|
||||
</SchemaLink>
|
||||
</SchemaGridItem>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</BrowseMain>
|
||||
</BrowseContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default Schema.loadList({
|
||||
query: (state: any, { params: { slug } }: { params: { slug: string } }) => ({
|
||||
dbId: Urls.extractEntityId(slug),
|
||||
}),
|
||||
})(BrowseSchemas);
|
||||
@@ -0,0 +1,56 @@
|
||||
import styled from "@emotion/styled";
|
||||
import type { AnchorHTMLAttributes } from "react";
|
||||
|
||||
import { ResponsiveChild } from "metabase/components/ResponsiveContainer/ResponsiveContainer";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import type { AnchorProps } from "metabase/ui";
|
||||
import { Anchor } from "metabase/ui";
|
||||
|
||||
import type { RefProp } from "./types";
|
||||
|
||||
export const Breadcrumb = styled(Anchor)<
|
||||
AnchorProps &
|
||||
AnchorHTMLAttributes<HTMLAnchorElement> &
|
||||
RefProp<HTMLAnchorElement>
|
||||
>`
|
||||
color: ${color("text-dark")};
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
:hover {
|
||||
color: ${color("brand")};
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CollectionBreadcrumbsWrapper = styled(ResponsiveChild)`
|
||||
line-height: 1;
|
||||
${props => {
|
||||
const breakpoint = "10rem";
|
||||
return `
|
||||
.initial-ellipsis {
|
||||
display: none;
|
||||
}
|
||||
@container ${props.containerName} (width < ${breakpoint}) {
|
||||
.ellipsis-and-separator {
|
||||
display: none;
|
||||
}
|
||||
.initial-ellipsis {
|
||||
display: inline;
|
||||
}
|
||||
.for-index-0:not(.sole-breadcrumb) {
|
||||
display: none;
|
||||
}
|
||||
.breadcrumb {
|
||||
max-width: calc(95cqw - 3rem) ! important;
|
||||
}
|
||||
.sole-breadcrumb {
|
||||
max-width: calc(95cqw - 1rem) ! important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
@@ -0,0 +1,149 @@
|
||||
import classNames from "classnames";
|
||||
import type { FC } from "react";
|
||||
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import type { Collection } from "metabase-types/api";
|
||||
import { ResponsiveContainer } from "metabase/components/ResponsiveContainer/ResponsiveContainer";
|
||||
import { useAreAnyTruncated } from "metabase/hooks/use-is-truncated";
|
||||
import resizeObserver from "metabase/lib/resize-observer";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import type { FlexProps } from "metabase/ui";
|
||||
import { FixedSizeIcon, Flex, Group, Text, Tooltip } from "metabase/ui";
|
||||
|
||||
import { getCollectionName } from "../utils";
|
||||
|
||||
import { c } from "ttag";
|
||||
import {
|
||||
Breadcrumb,
|
||||
CollectionBreadcrumbsWrapper
|
||||
} from "./CollectionBreadcrumbsWithTooltip.styled";
|
||||
import { pathSeparatorChar } from "./constants";
|
||||
import type { RefProp } from "./types";
|
||||
import { getBreadcrumbMaxWidths, getCollectionPathString } from "./utils";
|
||||
|
||||
export const CollectionBreadcrumbsWithTooltip = ({
|
||||
collection,
|
||||
containerName,
|
||||
}: {
|
||||
collection: Collection;
|
||||
containerName: string;
|
||||
}) => {
|
||||
const collections = (collection.effective_ancestors || []).concat(collection);
|
||||
const pathString = getCollectionPathString(collection);
|
||||
const ellipsifyPath = collections.length > 2;
|
||||
const shownCollections = ellipsifyPath
|
||||
? [collections[0], collections[collections.length - 1]]
|
||||
: collections;
|
||||
const justOneShown = shownCollections.length === 1;
|
||||
|
||||
const { areAnyTruncated, ref } = useAreAnyTruncated<HTMLAnchorElement>({
|
||||
tolerance: 1,
|
||||
});
|
||||
|
||||
const initialEllipsisRef = useRef<HTMLDivElement | null>(null);
|
||||
const [
|
||||
isFirstCollectionDisplayedAsEllipsis,
|
||||
setIsFirstCollectionDisplayedAsEllipsis,
|
||||
] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const initialEllipsis = initialEllipsisRef.current;
|
||||
if (!initialEllipsis) {
|
||||
return;
|
||||
}
|
||||
const handleResize = () => {
|
||||
// The initial ellipsis might be hidden via CSS,
|
||||
// so we need to check whether it is displayed via getComputedStyle
|
||||
const style = window.getComputedStyle(initialEllipsis);
|
||||
setIsFirstCollectionDisplayedAsEllipsis(style.display !== "none");
|
||||
};
|
||||
resizeObserver.subscribe(initialEllipsis, handleResize);
|
||||
return () => {
|
||||
resizeObserver.unsubscribe(initialEllipsis, handleResize);
|
||||
};
|
||||
}, [initialEllipsisRef]);
|
||||
|
||||
const isTooltipEnabled =
|
||||
areAnyTruncated || ellipsifyPath || isFirstCollectionDisplayedAsEllipsis;
|
||||
|
||||
const maxWidths = getBreadcrumbMaxWidths(shownCollections, 96, ellipsifyPath);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
disabled={!isTooltipEnabled}
|
||||
variant="multiline"
|
||||
label={pathString}
|
||||
>
|
||||
<ResponsiveContainer
|
||||
aria-label={pathString}
|
||||
data-testid={`breadcrumbs-for-collection: ${collection.name}`}
|
||||
name={containerName}
|
||||
w="auto"
|
||||
>
|
||||
<Flex align="center" w="100%" lh="1" style={{ flexFlow: "row nowrap" }}>
|
||||
<FixedSizeIcon name="folder" style={{ marginInlineEnd: ".5rem" }} />
|
||||
{shownCollections.map((collection, index) => {
|
||||
const key = `collection${collection.id}`;
|
||||
return (
|
||||
<Group spacing={0} style={{ flexFlow: "row nowrap" }} key={key}>
|
||||
{index > 0 && <PathSeparator />}
|
||||
<CollectionBreadcrumbsWrapper
|
||||
containerName={containerName}
|
||||
style={{ alignItems: "center" }}
|
||||
w="auto"
|
||||
display="flex"
|
||||
>
|
||||
{index === 0 && !justOneShown && (
|
||||
<Ellipsis
|
||||
ref={initialEllipsisRef}
|
||||
includeSep={false}
|
||||
className="initial-ellipsis"
|
||||
/>
|
||||
)}
|
||||
{index > 0 && ellipsifyPath && <Ellipsis />}
|
||||
<Breadcrumb
|
||||
href={Urls.collection(collection)}
|
||||
className={classNames("breadcrumb", `for-index-${index}`, {
|
||||
"sole-breadcrumb": collections.length === 1,
|
||||
})}
|
||||
ref={(el: HTMLAnchorElement) => ref.current.set(key, el)}
|
||||
maw={maxWidths[index]}
|
||||
key={collection.id}
|
||||
>
|
||||
{getCollectionName(collection)}
|
||||
</Breadcrumb>
|
||||
</CollectionBreadcrumbsWrapper>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</ResponsiveContainer>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
type EllipsisProps = {
|
||||
includeSep?: boolean;
|
||||
} & FlexProps;
|
||||
|
||||
const Ellipsis: FC<EllipsisProps & Partial<RefProp<HTMLDivElement | null>>> =
|
||||
forwardRef<HTMLDivElement, EllipsisProps>(
|
||||
({ includeSep = true, ...flexProps }, ref) => (
|
||||
<Flex
|
||||
ref={ref}
|
||||
align="center"
|
||||
className="ellipsis-and-separator"
|
||||
{...flexProps}
|
||||
>
|
||||
<Text lh={1}>…</Text>
|
||||
{includeSep && <PathSeparator />}
|
||||
</Flex>
|
||||
),
|
||||
);
|
||||
Ellipsis.displayName = "Ellipsis";
|
||||
|
||||
const PathSeparator = () => (
|
||||
<Text color="text-light" mx="xs" py={1}>
|
||||
{pathSeparatorChar}
|
||||
</Text>
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Ellipsified } from "metabase/core/components/Ellipsified";
|
||||
import Markdown from "metabase/core/components/Markdown";
|
||||
|
||||
export const EllipsifiedWithMarkdown = ({
|
||||
shouldMarkdownifyTooltipTarget = false,
|
||||
shouldMarkdownifyTooltip = true,
|
||||
children,
|
||||
}: {
|
||||
/** NOTE: If the tooltip target is markdownified,
|
||||
* useIsTruncated won't know whether it has been truncated */
|
||||
shouldMarkdownifyTooltipTarget?: boolean;
|
||||
shouldMarkdownifyTooltip?: boolean;
|
||||
children: string;
|
||||
}) => {
|
||||
const tooltip = shouldMarkdownifyTooltip ? (
|
||||
<Markdown disallowHeading unstyleLinks lineClamp={12}>
|
||||
{children}
|
||||
</Markdown>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
const tooltipTarget = shouldMarkdownifyTooltipTarget ? (
|
||||
<Markdown disallowHeading>{children}</Markdown>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
return <Ellipsified tooltip={tooltip}>{tooltipTarget}</Ellipsified>;
|
||||
};
|
||||
@@ -1,39 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { updateUserSetting } from "metabase/redux/settings";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { Flex, Paper, Icon, Text } from "metabase/ui";
|
||||
import { useUserSetting } from "metabase/common/hooks";
|
||||
import { Flex, Icon, Paper, Text } from "metabase/ui";
|
||||
|
||||
import { BannerCloseButton, BannerModelIcon } from "./BrowseModels.styled";
|
||||
|
||||
export function ModelExplanationBanner() {
|
||||
const hasDismissedBanner = useSelector(state =>
|
||||
getSetting(state, "dismissed-browse-models-banner"),
|
||||
const [hasDismissedBanner, setHasDismissedBanner] = useUserSetting(
|
||||
"dismissed-browse-models-banner",
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [shouldShowBanner, setShouldShowBanner] = useState(!hasDismissedBanner);
|
||||
|
||||
const dismissBanner = () => {
|
||||
setShouldShowBanner(false);
|
||||
dispatch(
|
||||
updateUserSetting({
|
||||
key: "dismissed-browse-models-banner",
|
||||
value: true,
|
||||
}),
|
||||
);
|
||||
setHasDismissedBanner(true);
|
||||
};
|
||||
|
||||
if (!shouldShowBanner) {
|
||||
if (hasDismissedBanner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
mt="1rem"
|
||||
mb="-0.5rem"
|
||||
p="1rem"
|
||||
color="text-dark"
|
||||
bg="brand-lighter"
|
||||
@@ -44,7 +29,7 @@ export function ModelExplanationBanner() {
|
||||
>
|
||||
<Flex>
|
||||
<BannerModelIcon name="model" />
|
||||
<Text size="md" lh="1rem" mr="1rem">
|
||||
<Text size="md" lh="1rem" style={{ marginInlineEnd: "1rem" }}>
|
||||
{t`Models help curate data to make it easier to find answers to questions all in one place.`}
|
||||
</Text>
|
||||
<BannerCloseButton onClick={dismissBanner}>
|
||||
|
||||
134
frontend/src/metabase/browse/components/ModelsTable.tsx
Normal file
134
frontend/src/metabase/browse/components/ModelsTable.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
SortableColumnHeader,
|
||||
type SortingOptions,
|
||||
} from "metabase/components/ItemsTable/BaseItemsTable";
|
||||
import {
|
||||
ColumnHeader,
|
||||
ItemCell,
|
||||
Table,
|
||||
TableColumn,
|
||||
TBody,
|
||||
} from "metabase/components/ItemsTable/BaseItemsTable.styled";
|
||||
import { Columns } from "metabase/components/ItemsTable/Columns";
|
||||
import type { ResponsiveProps } from "metabase/components/ItemsTable/utils";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import type { CollectionItem } from "metabase-types/api";
|
||||
|
||||
import { getCollectionName } from "../utils";
|
||||
|
||||
import { CollectionBreadcrumbsWithTooltip } from "./CollectionBreadcrumbsWithTooltip";
|
||||
import { EllipsifiedWithMarkdown } from "./EllipsifiedWithMarkdown";
|
||||
import { getModelDescription } from "./utils";
|
||||
|
||||
export interface ModelsTableProps {
|
||||
items: CollectionItem[];
|
||||
sortingOptions?: SortingOptions;
|
||||
onSortingOptionsChange?: (newSortingOptions: SortingOptions) => void;
|
||||
}
|
||||
|
||||
const descriptionProps: ResponsiveProps = {
|
||||
hideAtContainerBreakpoint: "sm",
|
||||
containerName: "ItemsTableContainer",
|
||||
};
|
||||
|
||||
const collectionProps: ResponsiveProps = {
|
||||
hideAtContainerBreakpoint: "xs",
|
||||
containerName: "ItemsTableContainer",
|
||||
};
|
||||
|
||||
export const ModelsTable = ({
|
||||
items,
|
||||
sortingOptions,
|
||||
onSortingOptionsChange,
|
||||
}: ModelsTableProps) => {
|
||||
return (
|
||||
<Table>
|
||||
<colgroup>
|
||||
<Columns.Type.Col />
|
||||
|
||||
{/* <col> for Name column */}
|
||||
<TableColumn style={{ width: "10rem" }} />
|
||||
|
||||
{/* <col> for Description column */}
|
||||
<TableColumn {...descriptionProps} />
|
||||
|
||||
{/* <col> for Collection column */}
|
||||
<TableColumn {...collectionProps} />
|
||||
|
||||
<Columns.RightEdge.Col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<Columns.Type.Header title="" />
|
||||
<Columns.Name.Header
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
/>
|
||||
<ColumnHeader {...descriptionProps}>{t`Description`}</ColumnHeader>
|
||||
<SortableColumnHeader
|
||||
name="collection"
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
{...collectionProps}
|
||||
>
|
||||
{t`Collection`}
|
||||
</SortableColumnHeader>
|
||||
<Columns.RightEdge.Header />
|
||||
</tr>
|
||||
</thead>
|
||||
<TBody>
|
||||
{items.map((item: CollectionItem) => (
|
||||
<TBodyRow item={item} key={`${item.model}-${item.id}`} />
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
const TBodyRow = ({ item }: { item: CollectionItem }) => {
|
||||
const icon = item.getIcon();
|
||||
if (item.model === "card") {
|
||||
icon.color = color("text-light");
|
||||
}
|
||||
|
||||
const containerName = `collections-path-for-${item.id}`;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
{/* Type */}
|
||||
<Columns.Type.Cell icon={icon} />
|
||||
|
||||
{/* Name */}
|
||||
<Columns.Name.Cell item={item} includeDescription={false} />
|
||||
|
||||
{/* Description */}
|
||||
<ItemCell {...descriptionProps}>
|
||||
<EllipsifiedWithMarkdown>
|
||||
{getModelDescription(item) || ""}
|
||||
</EllipsifiedWithMarkdown>
|
||||
</ItemCell>
|
||||
|
||||
{/* Collection */}
|
||||
<ItemCell
|
||||
data-testid={`path-for-collection: ${
|
||||
item.collection
|
||||
? getCollectionName(item.collection)
|
||||
: t`Untitled collection`
|
||||
}`}
|
||||
{...collectionProps}
|
||||
>
|
||||
{item.collection && (
|
||||
<CollectionBreadcrumbsWithTooltip
|
||||
containerName={containerName}
|
||||
collection={item.collection}
|
||||
/>
|
||||
)}
|
||||
</ItemCell>
|
||||
|
||||
{/* Adds a border-radius to the table */}
|
||||
<Columns.RightEdge.Cell />
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import cx from "classnames";
|
||||
import { t } from "ttag";
|
||||
|
||||
import TableBrowser from "metabase/browse/containers/TableBrowser";
|
||||
import { BrowserCrumbs } from "metabase/components/BrowserCrumbs";
|
||||
import Card from "metabase/components/Card";
|
||||
import EntityItem from "metabase/components/EntityItem";
|
||||
import { Grid } from "metabase/components/Grid";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import Database from "metabase/entities/databases";
|
||||
import Schema from "metabase/entities/schemas";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
|
||||
import { BrowseHeaderContent } from "./BrowseHeader.styled";
|
||||
import {
|
||||
SchemaBrowserContainer,
|
||||
SchemaGridItem,
|
||||
SchemaLink,
|
||||
} from "./SchemaBrowser.styled";
|
||||
|
||||
function SchemaBrowser(props) {
|
||||
const { schemas, params } = props;
|
||||
const { slug } = params;
|
||||
const dbId = Urls.extractEntityId(slug);
|
||||
return (
|
||||
<SchemaBrowserContainer>
|
||||
{schemas.length === 1 ? (
|
||||
<TableBrowser
|
||||
{...props}
|
||||
dbId={dbId}
|
||||
schemaName={schemas[0].name}
|
||||
// hide the schema since there's only one
|
||||
showSchemaInHeader={false}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<BrowseHeaderContent>
|
||||
<BrowserCrumbs
|
||||
crumbs={[
|
||||
{ title: t`Databases`, to: "/browse/databases" },
|
||||
{ title: <Database.Name id={dbId} /> },
|
||||
]}
|
||||
/>
|
||||
</BrowseHeaderContent>
|
||||
{schemas.length === 0 ? (
|
||||
<h2
|
||||
className={cx(CS.full, CS.textCentered, CS.textMedium)}
|
||||
>{t`This database doesn't have any tables.`}</h2>
|
||||
) : (
|
||||
<Grid>
|
||||
{schemas.map(schema => (
|
||||
<SchemaGridItem key={schema.id}>
|
||||
<SchemaLink
|
||||
to={`/browse/databases/${dbId}/schema/${encodeURIComponent(
|
||||
schema.name,
|
||||
)}`}
|
||||
>
|
||||
<Card hoverable className={CS.px1}>
|
||||
<EntityItem
|
||||
name={schema.name}
|
||||
iconName="folder"
|
||||
iconColor={color("accent2")}
|
||||
item={schema}
|
||||
/>
|
||||
</Card>
|
||||
</SchemaLink>
|
||||
</SchemaGridItem>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SchemaBrowserContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Schema.loadList({
|
||||
query: (state, { params: { slug } }) => ({
|
||||
dbId: Urls.extractEntityId(slug),
|
||||
}),
|
||||
})(SchemaBrowser);
|
||||
6
frontend/src/metabase/browse/components/constants.tsx
Normal file
6
frontend/src/metabase/browse/components/constants.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { c } from "ttag";
|
||||
|
||||
export const pathSeparatorChar = c(
|
||||
"Character that separates the names of collections in a path, as in Europe / Belgium / Antwerp",
|
||||
).t`/`;
|
||||
|
||||
4
frontend/src/metabase/browse/components/types.tsx
Normal file
4
frontend/src/metabase/browse/components/types.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
|
||||
/** @template T: The type of the value the ref will hold */
|
||||
export type RefProp<T> = { ref: MutableRefObject<T> | ((el: T) => void) };
|
||||
59
frontend/src/metabase/browse/components/utils.tsx
Normal file
59
frontend/src/metabase/browse/components/utils.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { isRootCollection } from "metabase/collections/utils";
|
||||
import type {
|
||||
CollectionItem,
|
||||
Collection,
|
||||
CollectionEssentials,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { getCollectionName } from "../utils";
|
||||
|
||||
import { pathSeparatorChar } from "./constants";
|
||||
|
||||
export const getBreadcrumbMaxWidths = (
|
||||
collections: Collection["effective_ancestors"],
|
||||
totalUnitsOfWidthAvailable: number,
|
||||
isPathEllipsified: boolean,
|
||||
) => {
|
||||
if (!collections || collections.length < 2) {
|
||||
return [];
|
||||
}
|
||||
const lengths = collections.map(
|
||||
collection => getCollectionName(collection).length,
|
||||
);
|
||||
const ratio = lengths[0] / (lengths[0] + lengths[1]);
|
||||
const firstWidth = Math.max(
|
||||
Math.round(ratio * totalUnitsOfWidthAvailable),
|
||||
25,
|
||||
);
|
||||
const secondWidth = totalUnitsOfWidthAvailable - firstWidth;
|
||||
const padding = isPathEllipsified ? "2rem" : "1rem";
|
||||
return [
|
||||
`calc(${firstWidth}cqw - ${padding})`,
|
||||
`calc(${secondWidth}cqw - ${padding})`,
|
||||
];
|
||||
};
|
||||
|
||||
export const isModel = (item: CollectionItem) => item.model === "dataset";
|
||||
|
||||
export const getModelDescription = (item: CollectionItem) => {
|
||||
if (
|
||||
item.collection &&
|
||||
isRootCollection(item.collection) &&
|
||||
isModel(item) &&
|
||||
!item.description?.trim()
|
||||
) {
|
||||
return t`A model`;
|
||||
} else {
|
||||
return item.description;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCollectionPathString = (collection: CollectionEssentials) => {
|
||||
const collections = (collection.effective_ancestors || []).concat(collection);
|
||||
const pathString = collections
|
||||
.map(coll => getCollectionName(coll))
|
||||
.join(` ${pathSeparatorChar} `);
|
||||
return pathString;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
@@ -95,7 +96,7 @@ export type AvailableModelFilters = Record<
|
||||
|
||||
export type ModelFilterControlsProps = {
|
||||
actualModelFilters: ActualModelFilters;
|
||||
handleModelFilterChange: (filterName: string, active: boolean) => void;
|
||||
setActualModelFilters: Dispatch<SetStateAction<ActualModelFilters>>;
|
||||
};
|
||||
|
||||
export const sortModels = (
|
||||
|
||||
@@ -18,10 +18,7 @@ import type {
|
||||
} from "metabase/collections/types";
|
||||
import { isPersonalCollectionChild } from "metabase/collections/utils";
|
||||
import { ItemsTable } from "metabase/components/ItemsTable";
|
||||
import {
|
||||
Sort,
|
||||
type SortingOptions,
|
||||
} from "metabase/components/ItemsTable/BaseItemsTable";
|
||||
import type { SortingOptions } from "metabase/components/ItemsTable/BaseItemsTable";
|
||||
import PaginationControls from "metabase/components/PaginationControls";
|
||||
import ItemsDragLayer from "metabase/containers/dnd/ItemsDragLayer";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
@@ -32,11 +29,12 @@ import { useToggle } from "metabase/hooks/use-toggle";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type {
|
||||
Bookmark,
|
||||
Collection,
|
||||
CollectionId,
|
||||
CollectionItem,
|
||||
import {
|
||||
SortDirection,
|
||||
type Bookmark,
|
||||
type Collection,
|
||||
type CollectionId,
|
||||
type CollectionItem,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { CollectionOrTableIdProps } from "../ModelUploadModal";
|
||||
@@ -97,7 +95,7 @@ export const CollectionContentView = ({
|
||||
const [unpinnedItemsSorting, setUnpinnedItemsSorting] =
|
||||
useState<SortingOptions>({
|
||||
sort_column: "name",
|
||||
sort_direction: Sort.Asc,
|
||||
sort_direction: SortDirection.Asc,
|
||||
});
|
||||
|
||||
const [
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { updateUserSetting } from "metabase/redux/settings";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import type { Settings } from "metabase-types/api";
|
||||
import type { Settings, UserSettings } from "metabase-types/api";
|
||||
|
||||
export const useSetting = <SettingName extends keyof Settings>(
|
||||
settingName: SettingName,
|
||||
) => {
|
||||
return useSelector(state => getSetting(state, settingName));
|
||||
};
|
||||
|
||||
export const useUserSetting = <T extends keyof UserSettings>(
|
||||
key: T,
|
||||
{
|
||||
shouldRefresh = false,
|
||||
shouldDebounce = true,
|
||||
debounceTimeout = 200,
|
||||
debounceOnLeadingEdge,
|
||||
}: {
|
||||
shouldRefresh?: boolean;
|
||||
shouldDebounce?: boolean;
|
||||
debounceTimeout?: number;
|
||||
debounceOnLeadingEdge?: boolean;
|
||||
} = {},
|
||||
): [UserSettings[T], (value: UserSettings[T]) => void] => {
|
||||
const currentValue = useSetting(key);
|
||||
const dispatch = useDispatch();
|
||||
const setter = useCallback(
|
||||
(value: UserSettings[T]) => {
|
||||
dispatch(updateUserSetting({ key, value, shouldRefresh }));
|
||||
},
|
||||
[dispatch, key, shouldRefresh],
|
||||
);
|
||||
const debouncedSetter = useMemo(
|
||||
() => _.debounce(setter, debounceTimeout, debounceOnLeadingEdge),
|
||||
[setter, debounceTimeout, debounceOnLeadingEdge],
|
||||
);
|
||||
return [currentValue, shouldDebounce ? debouncedSetter : setter];
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import styled from "@emotion/styled";
|
||||
import type { IconName, IconProps } from "metabase/ui";
|
||||
import { Icon } from "metabase/ui";
|
||||
|
||||
export const HeaderContainer = styled.div<{ role: string; tabIndex?: number }>`
|
||||
export const HeaderContainer = styled.div<{ role?: string; tabIndex?: number }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
@@ -43,9 +43,8 @@ export const ToggleIcon = styled(
|
||||
isExpanded,
|
||||
variant,
|
||||
size = 12,
|
||||
name: _,
|
||||
...props
|
||||
}: ToggleIconProps & IconProps) => {
|
||||
}: ToggleIconProps & Omit<IconProps, "name">) => {
|
||||
const { collapsed, expanded } = ICON_VARIANTS[variant];
|
||||
const name = isExpanded ? expanded : collapsed;
|
||||
return <Icon name={name as IconName} size={size} {...props} />;
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { HeaderContainer, Header, ToggleIcon } from "./CollapseSection.styled";
|
||||
|
||||
const propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
header: PropTypes.node,
|
||||
headerClass: PropTypes.string,
|
||||
bodyClass: PropTypes.string,
|
||||
initialState: PropTypes.oneOf(["expanded", "collapsed"]),
|
||||
iconVariant: PropTypes.oneOf(["right-down", "up-down"]),
|
||||
iconPosition: PropTypes.oneOf(["left", "right"]),
|
||||
iconSize: PropTypes.number,
|
||||
onToggle: PropTypes.func,
|
||||
};
|
||||
|
||||
function CollapseSection({
|
||||
const CollapseSection = ({
|
||||
initialState = "collapsed",
|
||||
iconVariant = "right-down",
|
||||
iconPosition = "left",
|
||||
@@ -27,7 +13,18 @@ function CollapseSection({
|
||||
bodyClass,
|
||||
children,
|
||||
onToggle,
|
||||
}) {
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
header?: React.ReactNode;
|
||||
headerClass?: string;
|
||||
bodyClass?: string;
|
||||
initialState?: "expanded" | "collapsed";
|
||||
iconVariant?: "right-down" | "up-down";
|
||||
iconPosition?: "left" | "right";
|
||||
iconSize?: number;
|
||||
onToggle?: (nextState: boolean) => void;
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(initialState === "expanded");
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
@@ -37,7 +34,7 @@ function CollapseSection({
|
||||
}, [isExpanded, onToggle]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
e => {
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
toggle();
|
||||
}
|
||||
@@ -70,8 +67,7 @@ function CollapseSection({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CollapseSection.propTypes = propTypes;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default CollapseSection;
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
useCallback,
|
||||
type HTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
import type { ActionMenuProps } from "metabase/collections/components/ActionMenu/ActionMenu";
|
||||
@@ -15,7 +16,12 @@ import type {
|
||||
import ItemDragSource from "metabase/containers/dnd/ItemDragSource";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type { Bookmark, Collection, CollectionItem } from "metabase-types/api";
|
||||
import {
|
||||
SortDirection,
|
||||
type Bookmark,
|
||||
type Collection,
|
||||
type CollectionItem,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import {
|
||||
ColumnHeader,
|
||||
@@ -29,7 +35,7 @@ import type { ResponsiveProps } from "./utils";
|
||||
|
||||
export type SortingOptions = {
|
||||
sort_column: string;
|
||||
sort_direction: "asc" | "desc";
|
||||
sort_direction: SortDirection;
|
||||
};
|
||||
|
||||
export type SortableColumnHeaderProps = {
|
||||
@@ -38,36 +44,38 @@ export type SortableColumnHeaderProps = {
|
||||
onSortingOptionsChange?: (newSortingOptions: SortingOptions) => void;
|
||||
} & PropsWithChildren<Partial<HTMLAttributes<HTMLDivElement>>>;
|
||||
|
||||
export enum Sort {
|
||||
Asc = "asc",
|
||||
Desc = "desc",
|
||||
}
|
||||
|
||||
export const SortableColumnHeader = ({
|
||||
name = "",
|
||||
sortingOptions = {
|
||||
sort_column: "",
|
||||
sort_direction: Sort.Asc,
|
||||
},
|
||||
name,
|
||||
sortingOptions,
|
||||
onSortingOptionsChange,
|
||||
children,
|
||||
hideAtContainerBreakpoint,
|
||||
containerName,
|
||||
...props
|
||||
}: SortableColumnHeaderProps & ResponsiveProps) => {
|
||||
const isSortable = !!onSortingOptionsChange;
|
||||
const isSortingThisColumn = sortingOptions.sort_column === name;
|
||||
const isSortable = !!onSortingOptionsChange && !!name;
|
||||
const isSortingThisColumn = sortingOptions?.sort_column === name;
|
||||
const direction = isSortingThisColumn
|
||||
? sortingOptions.sort_direction
|
||||
: Sort.Desc;
|
||||
? sortingOptions?.sort_direction
|
||||
: SortDirection.Desc;
|
||||
|
||||
const onSortingControlClick = () => {
|
||||
const nextDirection = direction === Sort.Asc ? Sort.Desc : Sort.Asc;
|
||||
onSortingOptionsChange?.({
|
||||
sort_column: name,
|
||||
sort_direction: nextDirection,
|
||||
});
|
||||
};
|
||||
const onSortingControlClick = useMemo(() => {
|
||||
if (!isSortable) {
|
||||
return undefined;
|
||||
}
|
||||
const handler = () => {
|
||||
const nextDirection =
|
||||
direction === SortDirection.Asc
|
||||
? SortDirection.Desc
|
||||
: SortDirection.Asc;
|
||||
const newSortingOptions = {
|
||||
sort_column: name,
|
||||
sort_direction: nextDirection,
|
||||
};
|
||||
onSortingOptionsChange?.(newSortingOptions);
|
||||
};
|
||||
return handler;
|
||||
}, [direction, isSortable, name, onSortingOptionsChange]);
|
||||
|
||||
return (
|
||||
<ColumnHeader
|
||||
@@ -84,7 +92,7 @@ export const SortableColumnHeader = ({
|
||||
{children}
|
||||
{isSortable && (
|
||||
<SortingIcon
|
||||
name={direction === Sort.Asc ? "chevronup" : "chevrondown"}
|
||||
name={direction === SortDirection.Asc ? "chevronup" : "chevrondown"}
|
||||
/>
|
||||
)}
|
||||
</SortingControlContainer>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DEFAULT_TIME_STYLE,
|
||||
} from "metabase/lib/formatting/datetime-utils";
|
||||
import type { IconName } from "metabase/ui";
|
||||
import type { CollectionItem } from "metabase-types/api";
|
||||
import { SortDirection, type CollectionItem } from "metabase-types/api";
|
||||
import { createMockCollection } from "metabase-types/api/mocks";
|
||||
|
||||
import type { BaseItemsTableProps } from "./BaseItemsTable";
|
||||
@@ -64,7 +64,10 @@ describe("BaseItemsTable", () => {
|
||||
component={() => (
|
||||
<BaseItemsTable
|
||||
items={items}
|
||||
sortingOptions={{ sort_column: "name", sort_direction: "asc" }}
|
||||
sortingOptions={{
|
||||
sort_column: "name",
|
||||
sort_direction: SortDirection.Asc,
|
||||
}}
|
||||
onSortingOptionsChange={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import { Box, type BoxProps } from "metabase/ui";
|
||||
|
||||
const doNotForwardProps = (...propNamesToBlock: string[]) => ({
|
||||
shouldForwardProp: (propName: string) => !propNamesToBlock.includes(propName),
|
||||
});
|
||||
|
||||
/** Helps with @container queries in CSS */
|
||||
export const ResponsiveContainer = styled(
|
||||
Box,
|
||||
doNotForwardProps("name", "type"),
|
||||
)<
|
||||
BoxProps & {
|
||||
name: string;
|
||||
type?: string;
|
||||
}
|
||||
>`
|
||||
container-name: ${props => props.name};
|
||||
container-type: ${props => props.type};
|
||||
`;
|
||||
ResponsiveContainer.defaultProps = { type: "inline-size" };
|
||||
|
||||
export const ResponsiveChild = styled(Box, doNotForwardProps("containerName"))<
|
||||
BoxProps & {
|
||||
containerName: string;
|
||||
}
|
||||
>``;
|
||||
@@ -22,4 +22,8 @@ interface EllipsifiedRootProps {
|
||||
|
||||
export const EllipsifiedRoot = styled.div<EllipsifiedRootProps>`
|
||||
${props => ((props.lines ?? 1) > 1 ? clampCss(props) : ellipsifyCss)};
|
||||
// To ellipsify markdown
|
||||
p {
|
||||
${ellipsifyCss}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import _ from "underscore";
|
||||
|
||||
import resizeObserver from "metabase/lib/resize-observer";
|
||||
|
||||
type UseIsTruncatedProps = {
|
||||
disabled?: boolean;
|
||||
/** To avoid rounding errors, we can require that the truncation is at least a certain number of pixels */
|
||||
tolerance?: number;
|
||||
};
|
||||
|
||||
export const useIsTruncated = <E extends Element>({
|
||||
disabled = false,
|
||||
}: { disabled?: boolean } = {}) => {
|
||||
tolerance = 0,
|
||||
}: UseIsTruncatedProps = {}) => {
|
||||
const ref = useRef<E | null>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
@@ -16,7 +24,7 @@ export const useIsTruncated = <E extends Element>({
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
setIsTruncated(getIsTruncated(element));
|
||||
setIsTruncated(getIsTruncated(element, tolerance));
|
||||
};
|
||||
|
||||
handleResize();
|
||||
@@ -25,14 +33,56 @@ export const useIsTruncated = <E extends Element>({
|
||||
return () => {
|
||||
resizeObserver.unsubscribe(element, handleResize);
|
||||
};
|
||||
}, [disabled]);
|
||||
}, [disabled, tolerance]);
|
||||
|
||||
return { isTruncated, ref };
|
||||
};
|
||||
|
||||
const getIsTruncated = (element: Element): boolean => {
|
||||
const getIsTruncated = (element: Element, tolerance: number): boolean => {
|
||||
return (
|
||||
element.scrollHeight > element.clientHeight ||
|
||||
element.scrollWidth > element.clientWidth
|
||||
element.scrollHeight > element.clientHeight + tolerance ||
|
||||
element.scrollWidth > element.clientWidth + tolerance
|
||||
);
|
||||
};
|
||||
|
||||
export const useAreAnyTruncated = <E extends Element>({
|
||||
disabled = false,
|
||||
tolerance = 0,
|
||||
}: UseIsTruncatedProps = {}) => {
|
||||
const ref = useRef(new Map<string, E>());
|
||||
const [truncationStatusByKey, setTruncationStatusByKey] = useState<
|
||||
Map<string, boolean>
|
||||
>(new Map());
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const elementsMap = ref.current;
|
||||
|
||||
if (!elementsMap.size || disabled) {
|
||||
return;
|
||||
}
|
||||
const unsubscribeFns: (() => void)[] = [];
|
||||
|
||||
[...elementsMap.entries()].forEach(([elementKey, element]) => {
|
||||
const handleResize = () => {
|
||||
const isTruncated = getIsTruncated(element, tolerance);
|
||||
setTruncationStatusByKey(statuses => {
|
||||
const newStatuses = new Map(statuses);
|
||||
newStatuses.set(elementKey, isTruncated);
|
||||
return newStatuses;
|
||||
});
|
||||
};
|
||||
const handleResizeDebounced = _.debounce(handleResize, 200);
|
||||
resizeObserver.subscribe(element, handleResizeDebounced);
|
||||
unsubscribeFns.push(() =>
|
||||
resizeObserver.unsubscribe(element, handleResizeDebounced),
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFns.forEach(fn => fn());
|
||||
};
|
||||
}, [disabled, tolerance]);
|
||||
|
||||
const areAnyTruncated = [...truncationStatusByKey.values()].some(Boolean);
|
||||
return { areAnyTruncated, ref };
|
||||
};
|
||||
|
||||
@@ -18,6 +18,6 @@ export function createThunkAction<TArgs extends any[]>(
|
||||
thunkCreator: (
|
||||
...args: TArgs
|
||||
) => (dispatch: Dispatch, getState: GetState) => any,
|
||||
): (...args: TArgs) => any {
|
||||
) {
|
||||
return withAction(actionType)(thunkCreator);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import slugg from "slugg";
|
||||
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type DatabaseV1 from "metabase-lib/v1/metadata/Database";
|
||||
import type Table from "metabase-lib/v1/metadata/Table";
|
||||
import { SAVED_QUESTIONS_VIRTUAL_DB_ID } from "metabase-lib/v1/metadata/utils/saved-questions";
|
||||
import type { Database } from "metabase-types/api";
|
||||
|
||||
import { appendSlug } from "./utils";
|
||||
|
||||
export function browseDatabase(database: Database) {
|
||||
export function browseDatabase(database: DatabaseV1 | Database) {
|
||||
const name =
|
||||
database.id === SAVED_QUESTIONS_VIRTUAL_DB_ID
|
||||
? "Saved Questions"
|
||||
@@ -18,7 +19,7 @@ export function browseDatabase(database: Database) {
|
||||
export function browseSchema(table: {
|
||||
db_id?: Table["db_id"];
|
||||
schema_name: Table["schema_name"] | null;
|
||||
db?: Pick<Database, "id">;
|
||||
db?: Pick<DatabaseV1, "id">;
|
||||
}) {
|
||||
const databaseId = table.db?.id || table.db_id;
|
||||
return `/browse/databases/${databaseId}/schema/${encodeURIComponent(
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SidebarLink } from "./SidebarItems";
|
||||
const openSidebarCSS = css`
|
||||
width: ${NAV_SIDEBAR_WIDTH};
|
||||
|
||||
border-right: 1px solid ${color("border")};
|
||||
border-inline-end: 1px solid ${color("border")};
|
||||
|
||||
${breakpointMaxSmall} {
|
||||
width: 90vw;
|
||||
@@ -44,7 +44,7 @@ export const Sidebar = styled.aside<{ isOpen: boolean }>`
|
||||
${breakpointMaxSmall} {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inline-start: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -85,8 +85,8 @@ export const SidebarContentRoot = styled.div`
|
||||
export const SidebarSection = styled.div`
|
||||
margin-top: ${space(1)};
|
||||
margin-bottom: ${space(2)};
|
||||
padding-left: ${space(2)};
|
||||
padding-right: ${space(2)};
|
||||
padding-inline-start: ${space(2)};
|
||||
padding-inline-end: ${space(2)};
|
||||
`;
|
||||
|
||||
export const SidebarHeadingWrapper = styled.div`
|
||||
@@ -101,12 +101,12 @@ export const SidebarHeading = styled.h4`
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.45px;
|
||||
padding-left: ${space(2)};
|
||||
padding-inline-start: ${space(2)};
|
||||
`;
|
||||
|
||||
export const CollectionsMoreIconContainer = styled.button`
|
||||
margin-left: auto;
|
||||
margin-right: ${space(1)};
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: ${space(1)};
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
@@ -140,7 +140,7 @@ export const LoadingAndErrorTitle = styled.h2`
|
||||
`;
|
||||
|
||||
export const PaddedSidebarLink = styled(SidebarLink)`
|
||||
padding-left: 12px;
|
||||
padding-inline-start: 12px;
|
||||
`;
|
||||
|
||||
export const AddYourOwnDataLink = styled(SidebarLink)`
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
renderWithProviders,
|
||||
screen,
|
||||
waitForLoaderToBeRemoved,
|
||||
within,
|
||||
} from "__support__/ui";
|
||||
import { ROOT_COLLECTION } from "metabase/entities/collections";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
@@ -191,28 +192,29 @@ describe("nav > containers > MainNavbar", () => {
|
||||
describe("browse data link", () => {
|
||||
it("should render", async () => {
|
||||
await setup();
|
||||
const link = screen.getByRole("link", { name: /Browse data/i });
|
||||
const listItem = screen.getByRole("listitem", { name: /Browse data/i });
|
||||
const link = within(listItem).getByRole("link");
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "/browse");
|
||||
expect(link).toHaveAttribute("href", "/browse/databases");
|
||||
});
|
||||
|
||||
it("should not render when a user has no data access", async () => {
|
||||
await setup({ hasDataAccess: false });
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /Browse data/i }),
|
||||
screen.queryByRole("listitem", { name: /Browse data/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be highlighted if selected", async () => {
|
||||
await setup({ pathname: "/browse" });
|
||||
const link = screen.getByRole("listitem", { name: /Browse data/i });
|
||||
expect(link).toHaveAttribute("aria-selected", "true");
|
||||
await setup({ pathname: "/browse/databases" });
|
||||
const listItem = screen.getByRole("listitem", { name: /Browse data/i });
|
||||
expect(listItem).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("should be highlighted if child route selected", async () => {
|
||||
await setup({ pathname: "/browse/databases/1" });
|
||||
const link = screen.getByRole("listitem", { name: /Browse data/i });
|
||||
expect(link).toHaveAttribute("aria-selected", "true");
|
||||
const listItem = screen.getByRole("listitem", { name: /Browse data/i });
|
||||
expect(listItem).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ interface CollectionSidebarBookmarksProps {
|
||||
newIndex: number;
|
||||
oldIndex: number;
|
||||
}) => void;
|
||||
onToggle: (isExpanded: boolean) => void;
|
||||
initialState: "expanded" | "collapsed";
|
||||
}
|
||||
|
||||
interface BookmarkItemProps {
|
||||
@@ -56,9 +58,6 @@ interface BookmarkItemProps {
|
||||
onDeleteBookmark: (bookmark: Bookmark) => void;
|
||||
}
|
||||
|
||||
const BOOKMARKS_INITIALLY_VISIBLE =
|
||||
localStorage.getItem("shouldDisplayBookmarks") !== "false";
|
||||
|
||||
function isBookmarkSelected(bookmark: Bookmark, selectedItem?: SelectedItem) {
|
||||
if (!selectedItem) {
|
||||
return false;
|
||||
@@ -116,6 +115,8 @@ const BookmarkList = ({
|
||||
onSelect,
|
||||
onDeleteBookmark,
|
||||
reorderBookmarks,
|
||||
onToggle,
|
||||
initialState,
|
||||
}: CollectionSidebarBookmarksProps) => {
|
||||
const [orderedBookmarks, setOrderedBookmarks] = useState(bookmarks);
|
||||
const [isSorting, setIsSorting] = useState(false);
|
||||
@@ -128,10 +129,6 @@ const BookmarkList = ({
|
||||
activationConstraint: { distance: 15 },
|
||||
});
|
||||
|
||||
const onToggleBookmarks = useCallback(isVisible => {
|
||||
localStorage.setItem("shouldDisplayBookmarks", String(isVisible));
|
||||
}, []);
|
||||
|
||||
const handleSortStart = useCallback(() => {
|
||||
document.body.classList.add(GrabberS.grabbing);
|
||||
setIsSorting(true);
|
||||
@@ -153,11 +150,11 @@ const BookmarkList = ({
|
||||
return (
|
||||
<CollapseSection
|
||||
header={<SidebarHeading>{t`Bookmarks`}</SidebarHeading>}
|
||||
initialState={BOOKMARKS_INITIALLY_VISIBLE ? "expanded" : "collapsed"}
|
||||
initialState={initialState}
|
||||
iconPosition="right"
|
||||
iconSize={8}
|
||||
headerClass={CS.mb1}
|
||||
onToggle={onToggleBookmarks}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<DndContext
|
||||
onDragEnd={handleSortEnd}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
import { c, t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useUserSetting } from "metabase/common/hooks";
|
||||
import CollapseSection from "metabase/components/CollapseSection";
|
||||
import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
|
||||
import { Tree } from "metabase/components/tree";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import {
|
||||
getCollectionIcon,
|
||||
PERSONAL_COLLECTIONS,
|
||||
@@ -34,7 +37,6 @@ interface CollectionTreeItem extends Collection {
|
||||
icon: IconName | IconProps;
|
||||
children: CollectionTreeItem[];
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isAdmin: boolean;
|
||||
isOpen: boolean;
|
||||
@@ -55,8 +57,8 @@ type Props = {
|
||||
oldIndex: number;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const BROWSE_URL = "/browse";
|
||||
const BROWSE_MODELS_URL = "/browse/models";
|
||||
const BROWSE_DATA_URL = "/browse/databases";
|
||||
const OTHER_USERS_COLLECTIONS_URL = Urls.otherUsersPersonalCollections();
|
||||
const ARCHIVE_URL = "/archive";
|
||||
const ADD_YOUR_OWN_DATA_URL = "/admin/databases/create";
|
||||
@@ -86,44 +88,77 @@ function MainNavbarView({
|
||||
}
|
||||
}, [handleCloseNavbar]);
|
||||
|
||||
const [expandBrowse = true, setExpandBrowse] = useUserSetting(
|
||||
"expand-browse-in-nav",
|
||||
);
|
||||
const [expandBookmarks = true, setExpandBookmarks] = useUserSetting(
|
||||
"expand-bookmarks-in-nav",
|
||||
);
|
||||
|
||||
const debounceTimeout = 200;
|
||||
|
||||
return (
|
||||
<SidebarContentRoot>
|
||||
<div>
|
||||
<SidebarSection>
|
||||
<ul>
|
||||
<PaddedSidebarLink
|
||||
isSelected={nonEntityItem?.url === "/"}
|
||||
icon="home"
|
||||
onClick={onItemSelect}
|
||||
url="/"
|
||||
>
|
||||
{t`Home`}
|
||||
</PaddedSidebarLink>
|
||||
</SidebarSection>
|
||||
<SidebarSection>
|
||||
<CollapseSection
|
||||
header={
|
||||
<SidebarHeading>{c("A verb, shown in the sidebar")
|
||||
.t`Browse`}</SidebarHeading>
|
||||
}
|
||||
initialState={expandBrowse ? "expanded" : "collapsed"}
|
||||
iconPosition="right"
|
||||
iconSize={8}
|
||||
headerClass={CS.mb1}
|
||||
onToggle={_.debounce(setExpandBrowse, debounceTimeout)}
|
||||
>
|
||||
<PaddedSidebarLink
|
||||
isSelected={nonEntityItem?.url === "/"}
|
||||
icon="home"
|
||||
icon="model"
|
||||
url={BROWSE_MODELS_URL}
|
||||
isSelected={nonEntityItem?.url?.startsWith(BROWSE_MODELS_URL)}
|
||||
onClick={onItemSelect}
|
||||
url="/"
|
||||
aria-label={t`Browse models`}
|
||||
>
|
||||
{t`Home`}
|
||||
{t`Models`}
|
||||
</PaddedSidebarLink>
|
||||
{hasDataAccess && (
|
||||
<>
|
||||
<PaddedSidebarLink
|
||||
icon="database"
|
||||
url={BROWSE_URL}
|
||||
isSelected={nonEntityItem?.url?.startsWith(BROWSE_URL)}
|
||||
<PaddedSidebarLink
|
||||
icon="database"
|
||||
url={BROWSE_DATA_URL}
|
||||
isSelected={nonEntityItem?.url?.startsWith(BROWSE_DATA_URL)}
|
||||
onClick={onItemSelect}
|
||||
aria-label={t`Browse data`}
|
||||
>
|
||||
{t`Data`}
|
||||
</PaddedSidebarLink>
|
||||
)}
|
||||
</CollapseSection>
|
||||
{hasDataAccess && (
|
||||
<>
|
||||
{!hasOwnDatabase && isAdmin && (
|
||||
<AddYourOwnDataLink
|
||||
icon="add"
|
||||
url={ADD_YOUR_OWN_DATA_URL}
|
||||
isSelected={nonEntityItem?.url?.startsWith(
|
||||
ADD_YOUR_OWN_DATA_URL,
|
||||
)}
|
||||
onClick={onItemSelect}
|
||||
>
|
||||
{t`Browse data`}
|
||||
</PaddedSidebarLink>
|
||||
{!hasOwnDatabase && isAdmin && (
|
||||
<AddYourOwnDataLink
|
||||
icon="add"
|
||||
url={ADD_YOUR_OWN_DATA_URL}
|
||||
isSelected={nonEntityItem?.url?.startsWith(
|
||||
ADD_YOUR_OWN_DATA_URL,
|
||||
)}
|
||||
onClick={onItemSelect}
|
||||
>
|
||||
{t`Add your own data`}
|
||||
</AddYourOwnDataLink>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
{t`Add your own data`}
|
||||
</AddYourOwnDataLink>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
{bookmarks.length > 0 && (
|
||||
@@ -133,6 +168,8 @@ function MainNavbarView({
|
||||
selectedItem={cardItem ?? dashboardItem ?? collectionItem}
|
||||
onSelect={onItemSelect}
|
||||
reorderBookmarks={reorderBookmarks}
|
||||
onToggle={_.debounce(setExpandBookmarks, debounceTimeout)}
|
||||
initialState={expandBookmarks ? "expanded" : "collapsed"}
|
||||
/>
|
||||
</SidebarSection>
|
||||
)}
|
||||
@@ -156,12 +193,10 @@ function MainNavbarView({
|
||||
</SidebarContentRoot>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollectionSectionHeadingProps {
|
||||
currentUser: User;
|
||||
handleCreateNewCollection: () => void;
|
||||
}
|
||||
|
||||
function CollectionSectionHeading({
|
||||
currentUser,
|
||||
handleCreateNewCollection,
|
||||
@@ -217,6 +252,5 @@ function CollectionSectionHeading({
|
||||
</SidebarHeadingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default MainNavbarView;
|
||||
|
||||
@@ -144,13 +144,22 @@ export const useCommandPaletteBasicActions = ({
|
||||
}
|
||||
|
||||
const browseActions: Action[] = [
|
||||
{
|
||||
id: "navigate_models",
|
||||
name: t`Browse models`,
|
||||
section: "basic",
|
||||
icon: "model",
|
||||
perform: () => {
|
||||
dispatch(push("/browse/models"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "navigate_data",
|
||||
name: t`Browse Data`,
|
||||
name: t`Browse data`,
|
||||
section: "basic",
|
||||
icon: "database",
|
||||
perform: () => {
|
||||
dispatch(push("/browse"));
|
||||
dispatch(push("/browse/databases"));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "metabase/admin/permissions/types";
|
||||
import type { ADMIN_SETTINGS_SECTIONS } from "metabase/admin/settings/selectors";
|
||||
import type {
|
||||
ActualModelFilters,
|
||||
AvailableModelFilters,
|
||||
ModelFilterControlsProps,
|
||||
} from "metabase/browse/utils";
|
||||
@@ -426,6 +427,7 @@ export const PLUGIN_CONTENT_VERIFICATION = {
|
||||
_a: CollectionEssentials,
|
||||
_b: CollectionEssentials,
|
||||
) => 0,
|
||||
useModelFilterSettings: () => ({} as ActualModelFilters),
|
||||
};
|
||||
|
||||
export const PLUGIN_DASHBOARD_HEADER = {
|
||||
|
||||
@@ -244,13 +244,14 @@ export const QuestionActions = ({
|
||||
const handleFileUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && question._card.based_on_upload) {
|
||||
uploadFile({
|
||||
file,
|
||||
tableId: question._card.based_on_upload,
|
||||
reloadQuestionData: true,
|
||||
uploadMode,
|
||||
})(dispatch);
|
||||
|
||||
dispatch(
|
||||
uploadFile({
|
||||
file,
|
||||
tableId: question._card.based_on_upload,
|
||||
reloadQuestionData: true,
|
||||
uploadMode,
|
||||
}),
|
||||
);
|
||||
// reset the file input so that subsequent uploads of the same file trigger the change handler
|
||||
if (fileInputRef.current?.value) {
|
||||
fileInputRef.current.value = "";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createAsyncThunk, createReducer } from "@reduxjs/toolkit";
|
||||
import { createReducer } from "@reduxjs/toolkit";
|
||||
|
||||
import { createThunkAction } from "metabase/lib/redux";
|
||||
import { createAsyncThunk } from "metabase/lib/redux";
|
||||
import MetabaseSettings from "metabase/lib/settings";
|
||||
import { SessionApi, SettingsApi } from "metabase/services";
|
||||
import type { UserSettings } from "metabase-types/api";
|
||||
@@ -19,6 +19,40 @@ export const refreshSiteSettings = createAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
interface UpdateUserSettingProps<K extends keyof UserSettings> {
|
||||
key: K;
|
||||
value: UserSettings[K];
|
||||
shouldRefresh?: boolean;
|
||||
}
|
||||
|
||||
export const UPDATE_USER_SETTING = "metabase/settings/UPDATE_USER_SETTING";
|
||||
export const updateUserSetting = createAsyncThunk(
|
||||
UPDATE_USER_SETTING,
|
||||
async (
|
||||
{ key, value, shouldRefresh }: UpdateUserSettingProps<keyof UserSettings>,
|
||||
{ dispatch },
|
||||
) => {
|
||||
const setting = {
|
||||
key,
|
||||
value,
|
||||
};
|
||||
try {
|
||||
await SettingsApi.put(setting);
|
||||
if (!shouldRefresh) {
|
||||
// When we aren't refreshing all the settings, we need to put the setting into the state
|
||||
return setting;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error updating user setting", setting, error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (shouldRefresh) {
|
||||
await dispatch(refreshSiteSettings({}));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const settings = createReducer(
|
||||
{ values: window.MetabaseBootstrap || {}, loading: false },
|
||||
builder => {
|
||||
@@ -32,25 +66,10 @@ export const settings = createReducer(
|
||||
builder.addCase(refreshSiteSettings.rejected, state => {
|
||||
state.loading = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const UPDATE_USER_SETTING = "metabase/settings/UPDATE_USER_SETTING";
|
||||
export const updateUserSetting = createThunkAction(
|
||||
UPDATE_USER_SETTING,
|
||||
function <K extends keyof UserSettings>(setting: {
|
||||
key: K;
|
||||
value: Exclude<UserSettings[K], undefined>;
|
||||
}) {
|
||||
return async function (dispatch) {
|
||||
try {
|
||||
await SettingsApi.put(setting);
|
||||
} catch (error) {
|
||||
console.error("error updating user setting", setting, error);
|
||||
throw error;
|
||||
} finally {
|
||||
await dispatch(refreshSiteSettings({}));
|
||||
builder.addCase(updateUserSetting.fulfilled, (state, { payload }) => {
|
||||
if (payload) {
|
||||
state.values[payload.key] = payload.value;
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,8 +10,6 @@ import { ForgotPassword } from "metabase/auth/components/ForgotPassword";
|
||||
import { Login } from "metabase/auth/components/Login";
|
||||
import { Logout } from "metabase/auth/components/Logout";
|
||||
import { ResetPassword } from "metabase/auth/components/ResetPassword";
|
||||
import { BrowseApp } from "metabase/browse/components/BrowseApp";
|
||||
import SchemaBrowser from "metabase/browse/components/SchemaBrowser";
|
||||
import TableBrowser from "metabase/browse/containers/TableBrowser";
|
||||
import CollectionLanding from "metabase/collections/components/CollectionLanding";
|
||||
import { MoveCollectionModal } from "metabase/collections/components/MoveCollectionModal";
|
||||
@@ -60,7 +58,9 @@ import SearchApp from "metabase/search/containers/SearchApp";
|
||||
import { Setup } from "metabase/setup/components/Setup";
|
||||
import getCollectionTimelineRoutes from "metabase/timelines/collections/routes";
|
||||
|
||||
import { BrowseRedirect } from "./browse/components/BrowseRedirect";
|
||||
import { BrowseDatabases } from "./browse/components/BrowseDatabases";
|
||||
import { BrowseModels } from "./browse/components/BrowseModels";
|
||||
import BrowseSchemas from "./browse/components/BrowseSchemas";
|
||||
import {
|
||||
CanAccessMetabot,
|
||||
CanAccessSettings,
|
||||
@@ -200,27 +200,13 @@ export const getRoutes = store => {
|
||||
</Route>
|
||||
|
||||
<Route path="browse">
|
||||
<IndexRoute component={BrowseRedirect} />
|
||||
<Route path="models" component={() => <BrowseApp tab="models" />} />
|
||||
<Route
|
||||
path="databases"
|
||||
component={() => <BrowseApp tab="databases" />}
|
||||
/>
|
||||
<Route
|
||||
path="databases/:slug"
|
||||
component={({ params }) => (
|
||||
<BrowseApp tab="databases">
|
||||
<SchemaBrowser params={params} />
|
||||
</BrowseApp>
|
||||
)}
|
||||
/>
|
||||
<IndexRedirect to="/browse/models" />
|
||||
<Route path="models" component={BrowseModels} />
|
||||
<Route path="databases" component={BrowseDatabases} />
|
||||
<Route path="databases/:slug" component={BrowseSchemas} />
|
||||
<Route
|
||||
path="databases/:dbId/schema/:schemaName"
|
||||
component={({ params }) => (
|
||||
<BrowseApp tab="databases">
|
||||
<TableBrowser params={params} />
|
||||
</BrowseApp>
|
||||
)}
|
||||
component={TableBrowser}
|
||||
/>
|
||||
|
||||
{/* These two Redirects support legacy paths in v48 and earlier */}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(function Icon(
|
||||
return tooltip ? <Tooltip label={tooltip}>{icon}</Tooltip> : icon;
|
||||
});
|
||||
|
||||
/** An icon that does not shrink when the viewport gets narrower **/
|
||||
/** An icon that does not shrink when its container is too narrow **/
|
||||
export const FixedSizeIcon = styled(Icon)<{ size?: number }>`
|
||||
min-width: ${({ size }) => size ?? 16}px;
|
||||
min-height: ${({ size }) => size ?? 16}px;
|
||||
|
||||
@@ -25,5 +25,16 @@ export const getTooltipOverrides = (): MantineThemeOverride["components"] => ({
|
||||
padding: "0.6rem 0.75rem",
|
||||
},
|
||||
}),
|
||||
variants: {
|
||||
multiline: _theme => {
|
||||
return {
|
||||
tooltip: {
|
||||
whiteSpace: "normal",
|
||||
maxWidth: "20rem",
|
||||
overflowWrap: "break-word",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -494,6 +494,30 @@
|
||||
:type :integer
|
||||
:default nil)
|
||||
|
||||
(defsetting expand-browse-in-nav
|
||||
(deferred-tru "User preference for whether the 'Browse' section of the nav is expanded.")
|
||||
:user-local :only
|
||||
:export? false
|
||||
:visibility :authenticated
|
||||
:type :boolean
|
||||
:default true)
|
||||
|
||||
(defsetting expand-bookmarks-in-nav
|
||||
(deferred-tru "User preference for whether the 'Bookmarks' section of the nav is expanded.")
|
||||
:user-local :only
|
||||
:export? false
|
||||
:visibility :authenticated
|
||||
:type :boolean
|
||||
:default true)
|
||||
|
||||
(defsetting browse-filter-only-verified-models
|
||||
(deferred-tru "User preference for whether the 'Browse models' page should be filtered to show only verified models.")
|
||||
:user-local :only
|
||||
:export? false
|
||||
:visibility :authenticated
|
||||
:type :boolean
|
||||
:default true)
|
||||
|
||||
;;; ## ------------------------------------------ AUDIT LOG ------------------------------------------
|
||||
|
||||
(defmethod audit-log/model-details :model/User
|
||||
|
||||
Reference in New Issue
Block a user