Compare commits
8 Commits
python-run
...
browse/avo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e64bbe26f2 | ||
|
|
ff24068178 | ||
|
|
4498036f08 | ||
|
|
2c0ed89bc3 | ||
|
|
0204645f47 | ||
|
|
75e2d84984 | ||
|
|
f61d1144bc | ||
|
|
8494a93f94 |
@@ -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();
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -132,6 +132,7 @@ export type SearchRequest = {
|
||||
last_edited_by?: UserId[];
|
||||
search_native_query?: boolean | null;
|
||||
verified?: boolean | null;
|
||||
model_ancestors?: boolean | null;
|
||||
|
||||
// this should be in ListCollectionItemsRequest but legacy code expects them here
|
||||
collection?: CollectionId;
|
||||
|
||||
@@ -311,13 +311,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,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,33 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import NoResults from "assets/img/no_results.svg";
|
||||
import type { useSearchListQuery } from "metabase/common/hooks";
|
||||
import { useSearchQuery } from "metabase/api";
|
||||
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 { ContentViewportContext } from "metabase/core/context/ContentViewportContext";
|
||||
import Search from "metabase/entities/search";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins";
|
||||
import { Box, Flex, Group, Icon, Stack, Title } from "metabase/ui";
|
||||
|
||||
import { BROWSE_MODELS_LOCALSTORAGE_KEY } from "../constants";
|
||||
import { getCollectionViewPreferences, groupModels } from "../utils";
|
||||
import { filterModels, type ActualModelFilters } from "../utils";
|
||||
|
||||
import { CenteredEmptyState } from "./BrowseApp.styled";
|
||||
import { ModelGrid } from "./BrowseModels.styled";
|
||||
import {
|
||||
BrowseContainer,
|
||||
BrowseHeader,
|
||||
BrowseMain,
|
||||
BrowseSection,
|
||||
CenteredEmptyState,
|
||||
} from "./BrowseApp.styled";
|
||||
import { ModelExplanationBanner } from "./ModelExplanationBanner";
|
||||
import { ModelGroup } from "./ModelGroup";
|
||||
import { ModelsTable } from "./ModelsTable";
|
||||
|
||||
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 { availableModelFilters, useModelFilterSettings } =
|
||||
PLUGIN_CONTENT_VERIFICATION;
|
||||
|
||||
export const BrowseModels = () => {
|
||||
const initialModelFilters = useModelFilterSettings();
|
||||
|
||||
const [actualModelFilters, setActualModelFilters] =
|
||||
useState<ActualModelFilters>(initialModelFilters);
|
||||
|
||||
useEffect(() => {
|
||||
setActualModelFilters(initialModelFilters);
|
||||
}, [initialModelFilters, setActualModelFilters]);
|
||||
|
||||
const contentViewportElement = useContext(ContentViewportContext);
|
||||
const cachedViewportOverflowYRef = useRef<string>();
|
||||
|
||||
// Set the content viewport to scroll to prevent the page from jumping a little when filters are toggled
|
||||
useEffect(() => {
|
||||
if (!contentViewportElement) {
|
||||
return;
|
||||
}
|
||||
cachedViewportOverflowYRef.current = getComputedStyle(
|
||||
contentViewportElement,
|
||||
).overflowY;
|
||||
contentViewportElement.style.overflowY = "scroll";
|
||||
return () => {
|
||||
if (!contentViewportElement) {
|
||||
return;
|
||||
}
|
||||
const cachedViewportOverflowY = cachedViewportOverflowYRef.current;
|
||||
if (cachedViewportOverflowY) {
|
||||
contentViewportElement.style.overflowY = cachedViewportOverflowY;
|
||||
}
|
||||
};
|
||||
}, [contentViewportElement]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export const BrowseModelsBody = ({
|
||||
actualModelFilters,
|
||||
}: {
|
||||
actualModelFilters: ActualModelFilters;
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { data, error, isLoading } = useSearchQuery({
|
||||
models: ["dataset"],
|
||||
model_ancestors: true,
|
||||
filter_items_in_personal_collection: "exclude",
|
||||
});
|
||||
const unfilteredModels = data?.data;
|
||||
|
||||
const models = useMemo(
|
||||
() =>
|
||||
filterModels(
|
||||
unfilteredModels || [],
|
||||
actualModelFilters,
|
||||
availableModelFilters,
|
||||
),
|
||||
[unfilteredModels, actualModelFilters],
|
||||
);
|
||||
|
||||
const wrappedModels = models.map(model => Search.wrapEntity(model, dispatch));
|
||||
|
||||
if (error || isLoading) {
|
||||
return (
|
||||
@@ -39,67 +122,12 @@ export const BrowseModels = ({
|
||||
);
|
||||
}
|
||||
|
||||
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 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 (
|
||||
<>
|
||||
<Stack spacing="md" mb="lg">
|
||||
<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>
|
||||
</>
|
||||
<ModelsTable items={wrappedModels} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,150 +209,29 @@ 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", () => {
|
||||
it("displays models in collections by default", async () => {
|
||||
const modelCount = 22;
|
||||
renderBrowseModels(modelCount);
|
||||
setup(modelCount);
|
||||
expect(await screen.findByRole("table")).toBeInTheDocument();
|
||||
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) {
|
||||
|
||||
@@ -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,218 @@
|
||||
import styled from "@emotion/styled";
|
||||
import classNames from "classnames";
|
||||
import type { AnchorHTMLAttributes, MutableRefObject, FC } from "react";
|
||||
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
ResponsiveChild,
|
||||
ResponsiveContainer,
|
||||
} from "metabase/components/ResponsiveContainer/ResponsiveContainer";
|
||||
import { useAreAnyTruncated } from "metabase/hooks/use-is-truncated";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import resizeObserver from "metabase/lib/resize-observer";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import type { AnchorProps, FlexProps } from "metabase/ui";
|
||||
import { Anchor, FixedSizeIcon, Flex, Group, Text, Tooltip } from "metabase/ui";
|
||||
import type { Collection } from "metabase-types/api";
|
||||
|
||||
import { getCollectionName } from "../utils";
|
||||
|
||||
export const CollectionBreadcrumbsWithTooltip = ({
|
||||
collection,
|
||||
containerName,
|
||||
}: {
|
||||
collection: Collection;
|
||||
containerName: string;
|
||||
}) => {
|
||||
const collections = (collection.effective_ancestors || []).concat(collection);
|
||||
const pathString = collections
|
||||
.map(coll => getCollectionName(coll))
|
||||
.join(" / ");
|
||||
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>();
|
||||
|
||||
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={`collection-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>
|
||||
);
|
||||
};
|
||||
|
||||
/** @template T The type of the value the ref will hold */
|
||||
type RefProp<T> = { ref: MutableRefObject<T> | ((el: T) => void) };
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
|
||||
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}>
|
||||
/
|
||||
</Text>
|
||||
);
|
||||
|
||||
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})`,
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Ellipsified } from "metabase/core/components/Ellipsified";
|
||||
import Markdown from "metabase/core/components/Markdown";
|
||||
|
||||
export const EllipsifiedWithMarkdown = ({ children }: { children: string }) => {
|
||||
return (
|
||||
<Ellipsified
|
||||
tooltip={
|
||||
<Markdown disallowHeading unstyleLinks lineClamp={12}>
|
||||
{children}
|
||||
</Markdown>
|
||||
}
|
||||
>
|
||||
<Markdown disallowHeading>
|
||||
{typeof children === "string" ? children.replace(/\s/g, " ") : children}
|
||||
</Markdown>
|
||||
</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}>
|
||||
|
||||
117
frontend/src/metabase/browse/components/ModelsTable.tsx
Normal file
117
frontend/src/metabase/browse/components/ModelsTable.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
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";
|
||||
|
||||
export interface ModelsTableProps {
|
||||
items: CollectionItem[];
|
||||
}
|
||||
|
||||
const descriptionProps: ResponsiveProps = {
|
||||
hideAtContainerBreakpoint: "sm",
|
||||
containerName: "ItemsTableContainer",
|
||||
};
|
||||
|
||||
const collectionProps: ResponsiveProps = {
|
||||
hideAtContainerBreakpoint: "xs",
|
||||
containerName: "ItemsTableContainer",
|
||||
};
|
||||
|
||||
export const ModelsTable = ({ items }: 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 />
|
||||
<ColumnHeader {...descriptionProps}>{t`Description`}</ColumnHeader>
|
||||
<ColumnHeader {...collectionProps}>{t`Collection`}</ColumnHeader>
|
||||
<Columns.RightEdge.Header />
|
||||
</tr>
|
||||
</thead>
|
||||
<TBody>
|
||||
{items.map((item: CollectionItem) => {
|
||||
return (
|
||||
<tr key={item.id} style={{ height: 48 }}>
|
||||
<RowOfCells key={`${item.model}-${item.id}`} item={item} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
const RowOfCells = ({ item }: { item: CollectionItem }) => {
|
||||
const icon = item.getIcon();
|
||||
if (item.model === "card") {
|
||||
icon.color = color("text-light");
|
||||
}
|
||||
|
||||
const containerName = `collections-path-for-${item.id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Type */}
|
||||
<Columns.Type.Cell icon={icon} />
|
||||
|
||||
{/* Name */}
|
||||
<Columns.Name.Cell item={item} includeDescription={false} />
|
||||
|
||||
{/* Description */}
|
||||
<ItemCell {...descriptionProps}>
|
||||
<EllipsifiedWithMarkdown>
|
||||
{item.description || ""}
|
||||
</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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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 = (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import type {
|
||||
@@ -24,7 +24,7 @@ import type { State } from "metabase-types/store";
|
||||
|
||||
import { EntityItemMenu } from "./ActionMenu.styled";
|
||||
|
||||
interface OwnProps {
|
||||
export interface ActionMenuProps {
|
||||
className?: string;
|
||||
item: CollectionItem;
|
||||
collection?: Collection;
|
||||
@@ -36,13 +36,11 @@ interface OwnProps {
|
||||
deleteBookmark?: DeleteBookmark;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
interface ActionMenuStateProps {
|
||||
isXrayEnabled: boolean;
|
||||
isMetabotEnabled: boolean;
|
||||
}
|
||||
|
||||
type ActionMenuProps = OwnProps & StateProps;
|
||||
|
||||
function getIsBookmarked(item: CollectionItem, bookmarks: Bookmark[]) {
|
||||
const normalizedItemModel = normalizeItemModel(item);
|
||||
|
||||
@@ -58,7 +56,7 @@ function normalizeItemModel(item: CollectionItem) {
|
||||
return item.model === "dataset" ? "card" : item.model;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: State): StateProps {
|
||||
function mapStateToProps(state: State): ActionMenuStateProps {
|
||||
return {
|
||||
isXrayEnabled: getSetting(state, "enable-xrays"),
|
||||
isMetabotEnabled: getSetting(state, "is-metabot-enabled"),
|
||||
@@ -77,7 +75,7 @@ function ActionMenu({
|
||||
onMove,
|
||||
createBookmark,
|
||||
deleteBookmark,
|
||||
}: ActionMenuProps) {
|
||||
}: ActionMenuProps & ActionMenuStateProps) {
|
||||
const database = databases?.find(({ id }) => id === item.database_id);
|
||||
const isBookmarked = bookmarks && getIsBookmarked(item, bookmarks);
|
||||
const canPin = canPinItem(item, collection);
|
||||
@@ -103,9 +101,15 @@ function ActionMenu({
|
||||
item.setArchived?.(true);
|
||||
}, [item]);
|
||||
|
||||
const handleToggleBookmark = useCallback(() => {
|
||||
const toggleBookmark = isBookmarked ? deleteBookmark : createBookmark;
|
||||
toggleBookmark?.(item.id.toString(), normalizeItemModel(item));
|
||||
const handleToggleBookmark = useMemo(() => {
|
||||
if (!createBookmark && !deleteBookmark) {
|
||||
return undefined;
|
||||
}
|
||||
const handler = () => {
|
||||
const toggleBookmark = isBookmarked ? deleteBookmark : createBookmark;
|
||||
toggleBookmark?.(item.id.toString(), normalizeItemModel(item));
|
||||
};
|
||||
return handler;
|
||||
}, [createBookmark, deleteBookmark, isBookmarked, item]);
|
||||
|
||||
const handleTogglePreview = useCallback(() => {
|
||||
|
||||
@@ -142,6 +142,7 @@ describe("ActionMenu", () => {
|
||||
personal_owner_id: 1,
|
||||
setCollection: jest.fn(),
|
||||
setArchived: jest.fn(),
|
||||
copy: true,
|
||||
});
|
||||
|
||||
setup({ item });
|
||||
@@ -158,6 +159,7 @@ describe("ActionMenu", () => {
|
||||
can_write: false,
|
||||
setCollection: jest.fn(),
|
||||
setArchived: jest.fn(),
|
||||
copy: true,
|
||||
});
|
||||
|
||||
setup({ item });
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import type { HTMLAttributes, PropsWithChildren } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import CheckBox from "metabase/core/components/CheckBox";
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type { Bookmark, Collection, CollectionItem } from "metabase-types/api";
|
||||
|
||||
import type {
|
||||
CreateBookmark,
|
||||
DeleteBookmark,
|
||||
OnCopy,
|
||||
OnMove,
|
||||
OnToggleSelectedWithItem,
|
||||
} from "../types";
|
||||
|
||||
import {
|
||||
BulkSelectWrapper,
|
||||
ColumnHeader,
|
||||
LastEditedByCol,
|
||||
SortingControlContainer,
|
||||
SortingIcon,
|
||||
Table,
|
||||
TBody,
|
||||
} from "./BaseItemsTable.styled";
|
||||
import BaseTableItem, { type BaseTableItemProps } from "./BaseTableItem";
|
||||
|
||||
export type SortingOptions = {
|
||||
sort_column: string;
|
||||
sort_direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
interface SortableColumnHeaderProps
|
||||
extends PropsWithChildren<Partial<HTMLAttributes<HTMLDivElement>>> {
|
||||
name: string;
|
||||
sortingOptions: SortingOptions;
|
||||
onSortingOptionsChange: (newSortingOptions: SortingOptions) => void;
|
||||
}
|
||||
|
||||
export enum Sort {
|
||||
Asc = "asc",
|
||||
Desc = "desc",
|
||||
}
|
||||
|
||||
const SortableColumnHeader = ({
|
||||
name,
|
||||
sortingOptions,
|
||||
onSortingOptionsChange,
|
||||
children,
|
||||
...props
|
||||
}: SortableColumnHeaderProps) => {
|
||||
const isSortingThisColumn = sortingOptions.sort_column === name;
|
||||
const direction = isSortingThisColumn
|
||||
? sortingOptions.sort_direction
|
||||
: Sort.Desc;
|
||||
|
||||
const onSortingControlClick = () => {
|
||||
const nextDirection = direction === Sort.Asc ? Sort.Desc : Sort.Asc;
|
||||
onSortingOptionsChange({
|
||||
sort_column: name,
|
||||
sort_direction: nextDirection,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ColumnHeader>
|
||||
<SortingControlContainer
|
||||
{...props}
|
||||
isActive={isSortingThisColumn}
|
||||
onClick={onSortingControlClick}
|
||||
role="button"
|
||||
>
|
||||
{children}
|
||||
<SortingIcon
|
||||
name={direction === Sort.Asc ? "chevronup" : "chevrondown"}
|
||||
/>
|
||||
</SortingControlContainer>
|
||||
</ColumnHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export interface BaseItemsTableProps {
|
||||
items: CollectionItem[];
|
||||
collection?: Collection;
|
||||
databases?: Database[];
|
||||
bookmarks?: Bookmark[];
|
||||
createBookmark?: CreateBookmark;
|
||||
deleteBookmark?: DeleteBookmark;
|
||||
selectedItems?: CollectionItem[];
|
||||
hasUnselected?: boolean;
|
||||
isPinned?: boolean;
|
||||
renderItem?: (props: ItemRendererProps) => JSX.Element;
|
||||
sortingOptions: SortingOptions;
|
||||
onSortingOptionsChange: (newSortingOptions: SortingOptions) => void;
|
||||
onToggleSelected?: OnToggleSelectedWithItem;
|
||||
onSelectAll?: () => void;
|
||||
onSelectNone?: () => void;
|
||||
onCopy?: OnCopy;
|
||||
onMove?: OnMove;
|
||||
onDrop?: () => void;
|
||||
getIsSelected?: (item: any) => boolean;
|
||||
/** Used for dragging */
|
||||
headless?: boolean;
|
||||
}
|
||||
|
||||
type ItemRendererProps = {
|
||||
item: CollectionItem;
|
||||
} & BaseTableItemProps;
|
||||
|
||||
const defaultItemRenderer = ({ item, ...props }: ItemRendererProps) => {
|
||||
return (
|
||||
<BaseTableItem key={`${item.model}-${item.id}`} item={item} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
const BaseItemsTable = ({
|
||||
databases,
|
||||
bookmarks,
|
||||
createBookmark,
|
||||
deleteBookmark,
|
||||
items,
|
||||
collection,
|
||||
selectedItems,
|
||||
hasUnselected,
|
||||
isPinned,
|
||||
renderItem = defaultItemRenderer,
|
||||
onCopy,
|
||||
onMove,
|
||||
onDrop,
|
||||
sortingOptions,
|
||||
onSortingOptionsChange,
|
||||
onToggleSelected,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
getIsSelected = () => false,
|
||||
headless = false,
|
||||
...props
|
||||
}: BaseItemsTableProps) => {
|
||||
const itemRenderer = (item: CollectionItem) =>
|
||||
renderItem({
|
||||
databases,
|
||||
bookmarks,
|
||||
createBookmark,
|
||||
deleteBookmark,
|
||||
item,
|
||||
collection,
|
||||
selectedItems,
|
||||
isSelected: getIsSelected(item),
|
||||
isPinned,
|
||||
onCopy,
|
||||
onMove,
|
||||
onDrop,
|
||||
onToggleSelected,
|
||||
});
|
||||
|
||||
const canSelect = !!collection?.can_write;
|
||||
|
||||
return (
|
||||
<Table canSelect={canSelect} {...props}>
|
||||
<colgroup>
|
||||
{canSelect && <col style={{ width: "70px" }} />}
|
||||
<col style={{ width: "70px" }} />
|
||||
<col />
|
||||
<LastEditedByCol />
|
||||
<col style={{ width: "140px" }} />
|
||||
<col style={{ width: "100px" }} />
|
||||
</colgroup>
|
||||
{!headless && (
|
||||
<thead
|
||||
data-testid={
|
||||
isPinned ? "pinned-items-table-head" : "items-table-head"
|
||||
}
|
||||
>
|
||||
<tr>
|
||||
{canSelect && (
|
||||
<ColumnHeader>
|
||||
<BulkSelectWrapper>
|
||||
<CheckBox
|
||||
checked={!!selectedItems?.length}
|
||||
indeterminate={!!selectedItems?.length && hasUnselected}
|
||||
onChange={hasUnselected ? onSelectAll : onSelectNone}
|
||||
aria-label={t`Select all items`}
|
||||
/>
|
||||
</BulkSelectWrapper>
|
||||
</ColumnHeader>
|
||||
)}
|
||||
<SortableColumnHeader
|
||||
name="model"
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
style={{ marginInlineStart: 6 }}
|
||||
>
|
||||
{t`Type`}
|
||||
</SortableColumnHeader>
|
||||
<SortableColumnHeader
|
||||
name="name"
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
>
|
||||
{t`Name`}
|
||||
</SortableColumnHeader>
|
||||
<SortableColumnHeader
|
||||
name="last_edited_by"
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
>
|
||||
{t`Last edited by`}
|
||||
</SortableColumnHeader>
|
||||
<SortableColumnHeader
|
||||
name="last_edited_at"
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
>
|
||||
{t`Last edited at`}
|
||||
</SortableColumnHeader>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<TBody>{items.map(itemRenderer)}</TBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
BaseItemsTable.Item = BaseTableItem;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default BaseItemsTable;
|
||||
@@ -3,15 +3,12 @@ import styled from "@emotion/styled";
|
||||
import Card from "metabase/components/Card";
|
||||
import Button from "metabase/core/components/Button";
|
||||
import { alpha, color } from "metabase/lib/colors";
|
||||
import { NAV_SIDEBAR_WIDTH } from "metabase/nav/constants";
|
||||
import { space } from "metabase/styled-components/theme";
|
||||
|
||||
export const BulkActionsToast = styled.div<{ isNavbarOpen: boolean }>`
|
||||
export const BulkActionsToast = styled.div`
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
margin-left: ${props =>
|
||||
props.isNavbarOpen ? `${parseInt(NAV_SIDEBAR_WIDTH) / 2}px` : "0"};
|
||||
margin-bottom: ${space(2)};
|
||||
z-index: 400; // needed to put this over popovers (z-index: 300)
|
||||
`;
|
||||
|
||||
@@ -39,7 +39,6 @@ type BulkActionsProps = {
|
||||
onCloseModal: () => void;
|
||||
onMove: OnMoveWithOneItem;
|
||||
onCopy: OnCopyWithoutArguments;
|
||||
isNavbarOpen: boolean;
|
||||
};
|
||||
|
||||
const BulkActions = ({
|
||||
@@ -52,7 +51,6 @@ const BulkActions = ({
|
||||
onCloseModal,
|
||||
onMove,
|
||||
onCopy,
|
||||
isNavbarOpen,
|
||||
}: BulkActionsProps) => {
|
||||
const canMove = selected.every(item => canMoveItem(item, collection));
|
||||
const canArchive = selected.every(item => canArchiveItem(item, collection));
|
||||
@@ -69,7 +67,7 @@ const BulkActions = ({
|
||||
timingFunction="ease"
|
||||
>
|
||||
{styles => (
|
||||
<BulkActionsToast style={styles} isNavbarOpen={isNavbarOpen}>
|
||||
<BulkActionsToast style={styles}>
|
||||
<ToastCard dark data-testid="toast-card">
|
||||
<CardSide>
|
||||
{ngettext(
|
||||
|
||||
@@ -4,6 +4,11 @@ export const CollectionRoot = styled.div`
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
container-name: ItemsTableContainer;
|
||||
container-type: inline-size;
|
||||
// NOTE: The BulkActionsToast is centered within this container.
|
||||
// If this div ever ceases to be a container one day, let's restore the previously
|
||||
// existing margin-left fix for the toast so it is still centered when the nav sidebar is open
|
||||
`;
|
||||
|
||||
export const CollectionMain = styled.div`
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import _ from "underscore";
|
||||
|
||||
import {
|
||||
useCollectionQuery,
|
||||
useCollectionListQuery,
|
||||
useDatabaseListQuery,
|
||||
useBookmarkListQuery,
|
||||
useCollectionListQuery,
|
||||
useCollectionQuery,
|
||||
useDatabaseListQuery,
|
||||
} from "metabase/common/hooks";
|
||||
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
|
||||
import Bookmark from "metabase/entities/bookmarks";
|
||||
import Databases from "metabase/entities/databases";
|
||||
import { useSelector, useDispatch } from "metabase/lib/redux";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import type { UploadFileProps } from "metabase/redux/uploads";
|
||||
import { uploadFile as uploadFileAction } from "metabase/redux/uploads";
|
||||
import { getIsNavbarOpen } from "metabase/selectors/app";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import type {
|
||||
CollectionId,
|
||||
BookmarkId,
|
||||
BookmarkType,
|
||||
CollectionId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { CollectionContentView } from "./CollectionContentView";
|
||||
@@ -61,7 +58,6 @@ export function CollectionContent({
|
||||
);
|
||||
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
const isNavbarOpen = useSelector(getIsNavbarOpen);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -102,7 +98,6 @@ export function CollectionContent({
|
||||
createBookmark={createBookmark}
|
||||
deleteBookmark={deleteBookmark}
|
||||
isAdmin={isAdmin}
|
||||
isNavbarOpen={isNavbarOpen}
|
||||
uploadFile={uploadFile}
|
||||
uploadsEnabled={uploadsEnabled}
|
||||
canUploadToDb={canUploadToDb}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { t } from "ttag";
|
||||
import ErrorBoundary from "metabase/ErrorBoundary";
|
||||
import BulkActions from "metabase/collections/components/BulkActions";
|
||||
import CollectionEmptyState from "metabase/collections/components/CollectionEmptyState";
|
||||
import ItemsTable from "metabase/collections/components/ItemsTable";
|
||||
import PinnedItemOverview from "metabase/collections/components/PinnedItemOverview";
|
||||
import Header from "metabase/collections/containers/CollectionHeader";
|
||||
import type {
|
||||
@@ -18,6 +17,11 @@ import type {
|
||||
UploadFile,
|
||||
} 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 PaginationControls from "metabase/components/PaginationControls";
|
||||
import ItemsDragLayer from "metabase/containers/dnd/ItemsDragLayer";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
@@ -35,7 +39,6 @@ import type {
|
||||
CollectionItem,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { Sort, type SortingOptions } from "../BaseItemsTable";
|
||||
import type { CollectionOrTableIdProps } from "../ModelUploadModal";
|
||||
import { ModelUploadModal } from "../ModelUploadModal";
|
||||
import UploadOverlay from "../UploadOverlay";
|
||||
@@ -70,7 +73,6 @@ export const CollectionContentView = ({
|
||||
createBookmark,
|
||||
deleteBookmark,
|
||||
isAdmin,
|
||||
isNavbarOpen,
|
||||
uploadFile,
|
||||
uploadsEnabled,
|
||||
canUploadToDb,
|
||||
@@ -83,7 +85,6 @@ export const CollectionContentView = ({
|
||||
createBookmark: CreateBookmark;
|
||||
deleteBookmark: DeleteBookmark;
|
||||
isAdmin: boolean;
|
||||
isNavbarOpen: boolean;
|
||||
uploadFile: UploadFile;
|
||||
uploadsEnabled: boolean;
|
||||
canUploadToDb: boolean;
|
||||
@@ -396,7 +397,6 @@ export const CollectionContentView = ({
|
||||
onCopy={clear}
|
||||
selectedItems={selectedItems}
|
||||
selectedAction={selectedAction}
|
||||
isNavbarOpen={isNavbarOpen}
|
||||
/>
|
||||
</CollectionTable>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import type { UpdateUserSettingOptions } from "metabase/redux/settings";
|
||||
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,
|
||||
options?: UpdateUserSettingOptions,
|
||||
): [UserSettings[T], (value: UserSettings[T]) => void] => {
|
||||
const currentValue = useSetting(key);
|
||||
const dispatch = useDispatch();
|
||||
const setter = (value: UserSettings[T]) => {
|
||||
dispatch(updateUserSetting({ key, value }, options));
|
||||
};
|
||||
return [currentValue, 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;
|
||||
@@ -16,7 +16,7 @@ function getForeground(model: string, disabled: boolean) {
|
||||
: color("brand");
|
||||
}
|
||||
|
||||
const getItemPadding = (variant: string) => {
|
||||
const getItemPadding = (variant?: string) => {
|
||||
switch (variant) {
|
||||
case "list":
|
||||
return "1rem";
|
||||
@@ -42,8 +42,8 @@ export const EntityIconWrapper = styled(IconButtonWrapper)<{
|
||||
`;
|
||||
|
||||
export const EntityItemWrapper = styled.div<{
|
||||
variant: string;
|
||||
disabled: boolean;
|
||||
variant?: string;
|
||||
disabled?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -283,16 +283,16 @@ const EntityItem = ({
|
||||
onMove?: OnMove;
|
||||
onCopy?: OnCopy;
|
||||
onArchive?: OnArchive;
|
||||
selected: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelected?: OnToggleSelected;
|
||||
selectable?: boolean;
|
||||
variant: string;
|
||||
variant?: string;
|
||||
item: CollectionItem;
|
||||
buttons?: ReactNode;
|
||||
extraInfo?: ReactNode;
|
||||
pinned?: boolean;
|
||||
loading?: boolean;
|
||||
disabled: boolean;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const icon = useMemo(() => ({ name: iconName }), [iconName]);
|
||||
|
||||
|
||||
@@ -6,81 +6,61 @@ import Link from "metabase/core/components/Link";
|
||||
import AdminS from "metabase/css/admin.module.css";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import BaseModelDetailLink from "metabase/models/components/ModelDetailLink";
|
||||
import {
|
||||
breakpointMaxMedium,
|
||||
breakpointMinLarge,
|
||||
} from "metabase/styled-components/theme/media-queries";
|
||||
import { Icon } from "metabase/ui";
|
||||
import { FixedSizeIcon } from "metabase/ui";
|
||||
|
||||
const LAST_EDITED_BY_INDEX = 3;
|
||||
const LAST_EDITED_AT_INDEX = 4;
|
||||
import type { ResponsiveProps } from "./utils";
|
||||
import { getContainerQuery } from "./utils";
|
||||
|
||||
export const Table = styled.table<{ canSelect: boolean }>`
|
||||
export const Table = styled.table<{ isInDragLayer?: boolean }>`
|
||||
background-color: ${color("white")};
|
||||
table-layout: fixed;
|
||||
border-collapse: unset;
|
||||
border-radius: 8px;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
|
||||
thead {
|
||||
th {
|
||||
border-top: 1px solid ${color("border")};
|
||||
|
||||
&:first-of-type {
|
||||
border-top-left-radius: 8px;
|
||||
border-left: 1px solid ${color("border")};
|
||||
border-start-start-radius: 8px;
|
||||
border-inline-start: 1px solid ${color("border")};
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: 8px;
|
||||
border-right: 1px solid ${color("border")};
|
||||
border-start-end-radius: 8px;
|
||||
border-inline-end: 1px solid ${color("border")};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${props => {
|
||||
const offset = props.canSelect ? 1 : 0;
|
||||
const offsetEditedByIndex = LAST_EDITED_BY_INDEX + offset;
|
||||
const offsetEditedAtIndex = LAST_EDITED_AT_INDEX + offset;
|
||||
|
||||
return `
|
||||
${breakpointMaxMedium} {
|
||||
& td:nth-of-type(${offsetEditedByIndex}),
|
||||
th:nth-of-type(${offsetEditedByIndex}),
|
||||
col:nth-of-type(${offsetEditedByIndex}),
|
||||
td:nth-of-type(${offsetEditedAtIndex}),
|
||||
th:nth-of-type(${offsetEditedAtIndex}),
|
||||
col:nth-of-type(${offsetEditedAtIndex}) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}}
|
||||
${props => (props.isInDragLayer ? `width: 50vw;` : "")}
|
||||
`;
|
||||
|
||||
Table.defaultProps = { className: AdminS.ContentTable };
|
||||
|
||||
export const ColumnHeader = styled.th`
|
||||
padding: 1em 1em 0.75em !important;
|
||||
export const ColumnHeader = styled.th<ResponsiveProps>`
|
||||
padding: 0.75em 1em 0.75em !important;
|
||||
font-weight: bold;
|
||||
color: ${color("text-medium")};
|
||||
${({ hideAtContainerBreakpoint, containerName }) =>
|
||||
getContainerQuery({ hideAtContainerBreakpoint, containerName })};
|
||||
`;
|
||||
|
||||
export const BulkSelectWrapper = styled(IconButtonWrapper)`
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
padding-inline: 12px;
|
||||
width: 3em;
|
||||
`;
|
||||
|
||||
export const LastEditedByCol = styled.col`
|
||||
width: 140px;
|
||||
|
||||
${breakpointMinLarge} {
|
||||
width: 240px;
|
||||
}
|
||||
export const ItemCell = styled.td<ResponsiveProps>`
|
||||
padding: 0.25em 0 0.25em 1em !important;
|
||||
${({ hideAtContainerBreakpoint, containerName }) =>
|
||||
getContainerQuery({ hideAtContainerBreakpoint, containerName })};
|
||||
`;
|
||||
|
||||
export const ItemCell = styled.td`
|
||||
padding: 0.25em 0 0.25em 1em !important;
|
||||
export const TableColumn = styled.col<ResponsiveProps>`
|
||||
${({ hideAtContainerBreakpoint, containerName }) =>
|
||||
getContainerQuery({ hideAtContainerBreakpoint, containerName })};
|
||||
`;
|
||||
|
||||
export const EntityIconCheckBox = styled(EntityItem.IconCheckBox)`
|
||||
@@ -114,11 +94,11 @@ export const ItemNameCell = styled.td`
|
||||
}
|
||||
`;
|
||||
|
||||
export const SortingIcon = styled(Icon)`
|
||||
margin-left: 4px;
|
||||
export const SortingIcon = styled(FixedSizeIcon)`
|
||||
margin-inline-start: 4px;
|
||||
`;
|
||||
|
||||
export const DescriptionIcon = styled(Icon)`
|
||||
export const DescriptionIcon = styled(FixedSizeIcon)`
|
||||
color: ${color("text-medium")};
|
||||
`;
|
||||
|
||||
@@ -131,12 +111,14 @@ export const ModelDetailLink = styled(BaseModelDetailLink)`
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
export const SortingControlContainer = styled.div<{ isActive: boolean }>`
|
||||
export const SortingControlContainer = styled.div<{
|
||||
isActive: boolean;
|
||||
isSortable?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${props => (props.isActive ? color("text-dark") : "")};
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
${props => (props.isSortable ? `cursor: pointer; user-select: none;` : "")}
|
||||
|
||||
.Icon {
|
||||
visibility: ${props => (props.isActive ? "visible" : "hidden")};
|
||||
@@ -150,10 +132,15 @@ export const SortingControlContainer = styled.div<{ isActive: boolean }>`
|
||||
}
|
||||
}
|
||||
`;
|
||||
SortingControlContainer.defaultProps = { isSortable: true };
|
||||
|
||||
export const RowActionsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TableItemSecondaryField = styled.span`
|
||||
@@ -169,11 +156,11 @@ export const TBody = styled.tbody`
|
||||
border-top: 1px solid ${color("border")};
|
||||
|
||||
&:first-of-type {
|
||||
border-left: 1px solid ${color("border")};
|
||||
border-inline-start: 1px solid ${color("border")};
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-right: 1px solid ${color("border")};
|
||||
border-inline-end: 1px solid ${color("border")};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,11 +173,11 @@ export const TBody = styled.tbody`
|
||||
border-bottom: 1px solid ${color("border")};
|
||||
|
||||
&:last-child {
|
||||
border-bottom-right-radius: 8px;
|
||||
border-end-end-radius: 8px;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
border-bottom-left-radius: 8px;
|
||||
border-end-start-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
309
frontend/src/metabase/components/ItemsTable/BaseItemsTable.tsx
Normal file
309
frontend/src/metabase/components/ItemsTable/BaseItemsTable.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
useCallback,
|
||||
type HTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
|
||||
import type { ActionMenuProps } from "metabase/collections/components/ActionMenu/ActionMenu";
|
||||
import type {
|
||||
CreateBookmark,
|
||||
DeleteBookmark,
|
||||
OnCopy,
|
||||
OnMove,
|
||||
OnToggleSelectedWithItem,
|
||||
} from "metabase/collections/types";
|
||||
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 {
|
||||
ColumnHeader,
|
||||
SortingControlContainer,
|
||||
SortingIcon,
|
||||
Table,
|
||||
TBody,
|
||||
} from "./BaseItemsTable.styled";
|
||||
import BaseTableItem from "./BaseTableItem";
|
||||
import { Columns } from "./Columns";
|
||||
import type { ResponsiveProps } from "./utils";
|
||||
|
||||
export type SortingOptions = {
|
||||
sort_column: string;
|
||||
sort_direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
export type SortableColumnHeaderProps = {
|
||||
name?: string;
|
||||
sortingOptions?: SortingOptions;
|
||||
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,
|
||||
},
|
||||
onSortingOptionsChange,
|
||||
children,
|
||||
hideAtContainerBreakpoint,
|
||||
containerName,
|
||||
...props
|
||||
}: SortableColumnHeaderProps & ResponsiveProps) => {
|
||||
const isSortable = !!onSortingOptionsChange;
|
||||
const isSortingThisColumn = sortingOptions.sort_column === name;
|
||||
const direction = isSortingThisColumn
|
||||
? sortingOptions.sort_direction
|
||||
: Sort.Desc;
|
||||
|
||||
const onSortingControlClick = () => {
|
||||
const nextDirection = direction === Sort.Asc ? Sort.Desc : Sort.Asc;
|
||||
onSortingOptionsChange?.({
|
||||
sort_column: name,
|
||||
sort_direction: nextDirection,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ColumnHeader
|
||||
hideAtContainerBreakpoint={hideAtContainerBreakpoint}
|
||||
containerName={containerName}
|
||||
>
|
||||
<SortingControlContainer
|
||||
{...props}
|
||||
isActive={isSortingThisColumn}
|
||||
onClick={onSortingControlClick}
|
||||
role="button"
|
||||
isSortable={isSortable}
|
||||
>
|
||||
{children}
|
||||
{isSortable && (
|
||||
<SortingIcon
|
||||
name={direction === Sort.Asc ? "chevronup" : "chevrondown"}
|
||||
/>
|
||||
)}
|
||||
</SortingControlContainer>
|
||||
</ColumnHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export type BaseItemsTableProps = {
|
||||
items: CollectionItem[];
|
||||
collection?: Collection;
|
||||
databases?: Database[];
|
||||
bookmarks?: Bookmark[];
|
||||
createBookmark?: CreateBookmark;
|
||||
deleteBookmark?: DeleteBookmark;
|
||||
selectedItems?: CollectionItem[];
|
||||
hasUnselected?: boolean;
|
||||
isPinned?: boolean;
|
||||
sortingOptions: SortingOptions;
|
||||
onSortingOptionsChange: (newSortingOptions: SortingOptions) => void;
|
||||
onToggleSelected?: OnToggleSelectedWithItem;
|
||||
onSelectAll?: () => void;
|
||||
onSelectNone?: () => void;
|
||||
onCopy?: OnCopy;
|
||||
onMove?: OnMove;
|
||||
onDrop?: () => void;
|
||||
getIsSelected?: (item: any) => boolean;
|
||||
/** Used for dragging */
|
||||
headless?: boolean;
|
||||
isInDragLayer?: boolean;
|
||||
ItemComponent?: (props: ItemRendererProps) => JSX.Element;
|
||||
includeColGroup?: boolean;
|
||||
} & Partial<Omit<HTMLAttributes<HTMLTableElement>, "onCopy">>;
|
||||
|
||||
export const BaseItemsTable = ({
|
||||
databases,
|
||||
bookmarks,
|
||||
createBookmark,
|
||||
deleteBookmark,
|
||||
items,
|
||||
collection,
|
||||
selectedItems,
|
||||
hasUnselected,
|
||||
isPinned,
|
||||
onCopy,
|
||||
onMove,
|
||||
onDrop,
|
||||
sortingOptions,
|
||||
onSortingOptionsChange,
|
||||
onToggleSelected,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
getIsSelected = () => false,
|
||||
headless = false,
|
||||
isInDragLayer = false,
|
||||
ItemComponent = DefaultItemRenderer,
|
||||
includeColGroup = true,
|
||||
...props
|
||||
}: BaseItemsTableProps) => {
|
||||
const canSelect = !!collection?.can_write;
|
||||
|
||||
return (
|
||||
<Table isInDragLayer={isInDragLayer} {...props}>
|
||||
{includeColGroup && (
|
||||
<colgroup>
|
||||
{canSelect && <Columns.Select.Col />}
|
||||
<Columns.Type.Col />
|
||||
<Columns.Name.Col isInDragLayer={isInDragLayer} />
|
||||
<Columns.LastEditedBy.Col />
|
||||
<Columns.LastEditedAt.Col />
|
||||
<Columns.ActionMenu.Col />
|
||||
<Columns.RightEdge.Col />
|
||||
</colgroup>
|
||||
)}
|
||||
{!headless && (
|
||||
<thead
|
||||
data-testid={
|
||||
isPinned ? "pinned-items-table-head" : "items-table-head"
|
||||
}
|
||||
>
|
||||
<tr>
|
||||
{canSelect && (
|
||||
<Columns.Select.Header
|
||||
selectedItems={selectedItems}
|
||||
hasUnselected={hasUnselected}
|
||||
onSelectAll={onSelectAll}
|
||||
onSelectNone={onSelectNone}
|
||||
/>
|
||||
)}
|
||||
<Columns.Type.Header
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
/>
|
||||
<Columns.Name.Header
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
/>
|
||||
<Columns.LastEditedBy.Header
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
/>
|
||||
<Columns.LastEditedAt.Header
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
/>
|
||||
<Columns.ActionMenu.Header />
|
||||
<Columns.RightEdge.Header />
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<TBody>
|
||||
{items.map((item: CollectionItem) => {
|
||||
const isSelected = getIsSelected(item);
|
||||
const testId = `${isPinned ? "pinned-" : ""}collection-entry`;
|
||||
return (
|
||||
<ItemDragSource
|
||||
item={item}
|
||||
collection={collection}
|
||||
isSelected={isSelected}
|
||||
selected={selectedItems}
|
||||
onDrop={onDrop}
|
||||
key={`item-drag-source-${item.id}`}
|
||||
>
|
||||
<tr key={item.id} data-testid={testId} style={{ height: 48 }}>
|
||||
<ItemComponent
|
||||
key={`${item.model}-${item.id}`}
|
||||
testId={testId}
|
||||
item={item}
|
||||
isSelected={isSelected}
|
||||
databases={databases}
|
||||
bookmarks={bookmarks}
|
||||
createBookmark={createBookmark}
|
||||
deleteBookmark={deleteBookmark}
|
||||
collection={collection}
|
||||
isPinned={isPinned}
|
||||
onCopy={onCopy}
|
||||
onMove={onMove}
|
||||
onToggleSelected={onToggleSelected}
|
||||
/>
|
||||
</tr>
|
||||
</ItemDragSource>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
BaseItemsTable.Item = BaseTableItem;
|
||||
|
||||
export type ItemRendererProps = {
|
||||
item: CollectionItem;
|
||||
isSelected?: boolean;
|
||||
isPinned?: boolean;
|
||||
onToggleSelected?: OnToggleSelectedWithItem;
|
||||
collection?: Collection;
|
||||
draggable?: boolean;
|
||||
testId?: string;
|
||||
} & ActionMenuProps;
|
||||
|
||||
const DefaultItemRenderer = ({
|
||||
item,
|
||||
isSelected,
|
||||
isPinned,
|
||||
onToggleSelected,
|
||||
collection,
|
||||
onCopy,
|
||||
onMove,
|
||||
createBookmark,
|
||||
deleteBookmark,
|
||||
testId = "item-renderer",
|
||||
}: ItemRendererProps) => {
|
||||
const canSelect =
|
||||
collection?.can_write && typeof onToggleSelected === "function";
|
||||
|
||||
const icon = item.getIcon();
|
||||
if (item.model === "card") {
|
||||
icon.color = color("text-light");
|
||||
}
|
||||
|
||||
const handleSelectionToggled = useCallback(() => {
|
||||
onToggleSelected?.(item);
|
||||
}, [item, onToggleSelected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{canSelect && (
|
||||
<Columns.Select.Cell
|
||||
testId={`${testId}-check`}
|
||||
icon={icon}
|
||||
isPinned={isPinned}
|
||||
isSelected={isSelected}
|
||||
handleSelectionToggled={handleSelectionToggled}
|
||||
/>
|
||||
)}
|
||||
<Columns.Type.Cell
|
||||
testId={`${testId}-type`}
|
||||
icon={icon}
|
||||
isPinned={isPinned}
|
||||
/>
|
||||
<Columns.Name.Cell item={item} testId={`${testId}-name`} />
|
||||
<Columns.LastEditedBy.Cell
|
||||
item={item}
|
||||
testId={`${testId}-last-edited-by`}
|
||||
/>
|
||||
<Columns.LastEditedAt.Cell
|
||||
item={item}
|
||||
testId={`${testId}-last-edited-at`}
|
||||
/>
|
||||
<Columns.ActionMenu.Cell
|
||||
item={item}
|
||||
collection={collection}
|
||||
onCopy={onCopy}
|
||||
onMove={onMove}
|
||||
createBookmark={createBookmark}
|
||||
deleteBookmark={deleteBookmark}
|
||||
/>
|
||||
<Columns.RightEdge.Cell />
|
||||
</>
|
||||
);
|
||||
};
|
||||
BaseItemsTable.Item = DefaultItemRenderer;
|
||||
@@ -13,7 +13,7 @@ import type { CollectionItem } from "metabase-types/api";
|
||||
import { createMockCollection } from "metabase-types/api/mocks";
|
||||
|
||||
import type { BaseItemsTableProps } from "./BaseItemsTable";
|
||||
import BaseItemsTable from "./BaseItemsTable";
|
||||
import { BaseItemsTable } from "./BaseItemsTable";
|
||||
|
||||
const timestamp = "2021-06-03T19:46:52.128";
|
||||
|
||||
@@ -43,6 +43,7 @@ function getCollectionItem({
|
||||
model,
|
||||
description,
|
||||
name,
|
||||
collection: createMockCollection({ can_write: true }),
|
||||
getIcon: () => ({
|
||||
name: icon,
|
||||
}),
|
||||
@@ -50,7 +51,7 @@ function getCollectionItem({
|
||||
};
|
||||
}
|
||||
|
||||
describe("Collections BaseItemsTable", () => {
|
||||
describe("BaseItemsTable", () => {
|
||||
const ITEM = getCollectionItem();
|
||||
|
||||
function setup({
|
||||
@@ -75,7 +76,7 @@ describe("Collections BaseItemsTable", () => {
|
||||
|
||||
it("displays item data", () => {
|
||||
setup();
|
||||
const lastEditedAt = moment(timestamp).format("MMMM DD, YYYY");
|
||||
const lastEditedAt = moment(timestamp).format("MMMM D, YYYY");
|
||||
|
||||
expect(screen.getByText(ITEM.name)).toBeInTheDocument();
|
||||
expect(screen.getByText("John Doe")).toBeInTheDocument();
|
||||
@@ -84,7 +85,7 @@ describe("Collections BaseItemsTable", () => {
|
||||
|
||||
it("displays last edit time on hover", async () => {
|
||||
setup();
|
||||
const lastEditedAt = moment(timestamp).format("MMMM DD, YYYY");
|
||||
const lastEditedAt = moment(timestamp).format("MMMM D, YYYY");
|
||||
|
||||
await userEvent.hover(screen.getByText(lastEditedAt));
|
||||
|
||||
@@ -2,6 +2,14 @@ import moment from "moment-timezone"; // eslint-disable-line no-restricted-impor
|
||||
import { useCallback } from "react";
|
||||
|
||||
import ActionMenu from "metabase/collections/components/ActionMenu";
|
||||
import type {
|
||||
CreateBookmark,
|
||||
DeleteBookmark,
|
||||
OnCopy,
|
||||
OnDrop,
|
||||
OnMove,
|
||||
OnToggleSelectedWithItem,
|
||||
} from "metabase/collections/types";
|
||||
import DateTime from "metabase/components/DateTime";
|
||||
import EntityItem from "metabase/components/EntityItem";
|
||||
import type { Edit } from "metabase/components/LastEditInfoLabel/LastEditInfoLabel";
|
||||
@@ -15,15 +23,6 @@ import { PLUGIN_MODERATION } from "metabase/plugins";
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type { Bookmark, Collection, CollectionItem } from "metabase-types/api";
|
||||
|
||||
import type {
|
||||
CreateBookmark,
|
||||
DeleteBookmark,
|
||||
OnCopy,
|
||||
OnDrop,
|
||||
OnMove,
|
||||
OnToggleSelectedWithItem,
|
||||
} from "../types";
|
||||
|
||||
import {
|
||||
DescriptionIcon,
|
||||
EntityIconCheckBox,
|
||||
@@ -51,6 +50,7 @@ export type BaseTableItemProps = {
|
||||
onMove?: OnMove;
|
||||
onDrop?: OnDrop;
|
||||
onToggleSelected?: OnToggleSelectedWithItem;
|
||||
shouldShowActionMenu?: boolean;
|
||||
};
|
||||
|
||||
export const BaseTableItem = ({
|
||||
@@ -69,6 +69,7 @@ export const BaseTableItem = ({
|
||||
onMove,
|
||||
onDrop,
|
||||
onToggleSelected,
|
||||
shouldShowActionMenu,
|
||||
}: BaseTableItemProps) => {
|
||||
const handleSelectionToggled = useCallback(() => {
|
||||
onToggleSelected?.(item);
|
||||
@@ -86,10 +87,6 @@ export const BaseTableItem = ({
|
||||
|
||||
const testId = isPinned ? "pinned-collection-entry" : "collection-entry";
|
||||
|
||||
const trStyles = {
|
||||
height: 48,
|
||||
};
|
||||
|
||||
const icon = item.getIcon();
|
||||
if (item.model === "card") {
|
||||
icon.color = color("text-light");
|
||||
@@ -99,7 +96,7 @@ export const BaseTableItem = ({
|
||||
// that only accepts native DOM elements as its children
|
||||
// So styled-components can't be used here
|
||||
return (
|
||||
<tr key={item.id} data-testid={testId} style={trStyles}>
|
||||
<tr key={item.id} data-testid={testId} style={{ height: 48 }}>
|
||||
{canSelect && (
|
||||
<ItemCell data-testid={`${testId}-check`}>
|
||||
<EntityIconCheckBox
|
||||
@@ -146,21 +143,24 @@ export const BaseTableItem = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
</ItemCell>
|
||||
<ItemCell>
|
||||
<RowActionsContainer>
|
||||
<ActionMenu
|
||||
item={item}
|
||||
collection={collection}
|
||||
databases={databases}
|
||||
bookmarks={bookmarks}
|
||||
onCopy={onCopy}
|
||||
onMove={onMove}
|
||||
createBookmark={createBookmark}
|
||||
deleteBookmark={deleteBookmark}
|
||||
/>
|
||||
{item.model === "dataset" && <ModelDetailLink model={item} />}
|
||||
</RowActionsContainer>
|
||||
</ItemCell>
|
||||
{shouldShowActionMenu && (
|
||||
<ItemCell>
|
||||
<RowActionsContainer>
|
||||
<ActionMenu
|
||||
item={item}
|
||||
collection={collection}
|
||||
databases={databases}
|
||||
bookmarks={bookmarks}
|
||||
onCopy={onCopy}
|
||||
onMove={onMove}
|
||||
createBookmark={createBookmark}
|
||||
deleteBookmark={deleteBookmark}
|
||||
/>
|
||||
{item.model === "dataset" && <ModelDetailLink model={item} />}
|
||||
</RowActionsContainer>
|
||||
</ItemCell>
|
||||
)}
|
||||
<ItemCell />
|
||||
</tr>
|
||||
);
|
||||
}, [
|
||||
@@ -177,6 +177,7 @@ export const BaseTableItem = ({
|
||||
collection,
|
||||
onCopy,
|
||||
onMove,
|
||||
shouldShowActionMenu,
|
||||
]);
|
||||
|
||||
if (!draggable) {
|
||||
284
frontend/src/metabase/components/ItemsTable/Columns.tsx
Normal file
284
frontend/src/metabase/components/ItemsTable/Columns.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import ActionMenu from "metabase/collections/components/ActionMenu";
|
||||
import type { ActionMenuProps } from "metabase/collections/components/ActionMenu/ActionMenu";
|
||||
import DateTime from "metabase/components/DateTime";
|
||||
import EntityItem from "metabase/components/EntityItem";
|
||||
import type { Edit } from "metabase/components/LastEditInfoLabel/LastEditInfoLabel";
|
||||
import CheckBox from "metabase/core/components/CheckBox";
|
||||
import { Ellipsified } from "metabase/core/components/Ellipsified";
|
||||
import Markdown from "metabase/core/components/Markdown";
|
||||
import Tooltip from "metabase/core/components/Tooltip";
|
||||
import { getFullName } from "metabase/lib/user";
|
||||
import { PLUGIN_MODERATION } from "metabase/plugins";
|
||||
import type { IconProps } from "metabase/ui";
|
||||
import type { CollectionItem } from "metabase-types/api";
|
||||
|
||||
import type { SortableColumnHeaderProps } from "./BaseItemsTable";
|
||||
import { SortableColumnHeader } from "./BaseItemsTable";
|
||||
import {
|
||||
BulkSelectWrapper,
|
||||
ColumnHeader,
|
||||
DescriptionIcon,
|
||||
EntityIconCheckBox,
|
||||
ItemCell,
|
||||
ItemLink,
|
||||
ItemNameCell,
|
||||
ModelDetailLink,
|
||||
RowActionsContainer,
|
||||
TableColumn,
|
||||
} from "./BaseItemsTable.styled";
|
||||
|
||||
type HeaderProps = Omit<SortableColumnHeaderProps, "name">;
|
||||
|
||||
export const Columns = {
|
||||
Select: {
|
||||
Col: () => <col style={{ width: "70px" }} />,
|
||||
Header: ({
|
||||
selectedItems,
|
||||
hasUnselected,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
}: {
|
||||
selectedItems?: CollectionItem[];
|
||||
hasUnselected?: boolean;
|
||||
onSelectAll?: () => void;
|
||||
onSelectNone?: () => void;
|
||||
}) => (
|
||||
<ColumnHeader>
|
||||
<BulkSelectWrapper>
|
||||
<CheckBox
|
||||
checked={!!selectedItems?.length}
|
||||
indeterminate={!!selectedItems?.length && !!hasUnselected}
|
||||
onChange={hasUnselected ? onSelectAll : onSelectNone}
|
||||
aria-label={t`Select all items`}
|
||||
/>
|
||||
</BulkSelectWrapper>
|
||||
</ColumnHeader>
|
||||
),
|
||||
Cell: ({
|
||||
testId,
|
||||
icon,
|
||||
isPinned,
|
||||
isSelected,
|
||||
handleSelectionToggled,
|
||||
}: {
|
||||
testId: string;
|
||||
icon: IconProps;
|
||||
isPinned?: boolean;
|
||||
isSelected?: boolean;
|
||||
handleSelectionToggled: () => void;
|
||||
}) => (
|
||||
<ItemCell data-testid={`${testId}-check`}>
|
||||
<EntityIconCheckBox
|
||||
variant="list"
|
||||
icon={icon}
|
||||
pinned={isPinned}
|
||||
selected={isSelected}
|
||||
onToggleSelected={handleSelectionToggled}
|
||||
selectable
|
||||
showCheckbox
|
||||
/>
|
||||
</ItemCell>
|
||||
),
|
||||
},
|
||||
Type: {
|
||||
Col: () => <col style={{ width: "70px" }} />,
|
||||
Header: ({
|
||||
sortingOptions,
|
||||
onSortingOptionsChange,
|
||||
title = t`Type`,
|
||||
}: HeaderProps) => (
|
||||
<SortableColumnHeader
|
||||
name="model"
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
style={{ marginInlineStart: 6 }}
|
||||
>
|
||||
{title}
|
||||
</SortableColumnHeader>
|
||||
),
|
||||
Cell: ({
|
||||
testId = "table",
|
||||
icon,
|
||||
isPinned,
|
||||
}: {
|
||||
testId?: string;
|
||||
icon: IconProps;
|
||||
isPinned?: boolean;
|
||||
}) => (
|
||||
<ItemCell data-testid={`${testId}-type`}>
|
||||
<EntityIconCheckBox variant="list" icon={icon} pinned={isPinned} />
|
||||
</ItemCell>
|
||||
),
|
||||
},
|
||||
Name: {
|
||||
Col: ({ isInDragLayer }: { isInDragLayer: boolean }) => (
|
||||
<col style={{ width: isInDragLayer ? "10rem" : undefined }} />
|
||||
),
|
||||
Header: ({ sortingOptions, onSortingOptionsChange }: HeaderProps) => (
|
||||
<SortableColumnHeader
|
||||
name="name"
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
>
|
||||
{t`Name`}
|
||||
</SortableColumnHeader>
|
||||
),
|
||||
Cell: ({
|
||||
item,
|
||||
testId = "table",
|
||||
includeDescription = true,
|
||||
}: {
|
||||
item: CollectionItem;
|
||||
testId?: string;
|
||||
includeDescription?: boolean;
|
||||
}) => (
|
||||
<ItemNameCell data-testid={`${testId}-name`}>
|
||||
<ItemLink to={item.getUrl()}>
|
||||
<EntityItem.Name name={item.name} variant="list" />
|
||||
<PLUGIN_MODERATION.ModerationStatusIcon
|
||||
size={16}
|
||||
status={item.moderated_status}
|
||||
/>
|
||||
{item.description && includeDescription && (
|
||||
<DescriptionIcon
|
||||
name="info"
|
||||
size={16}
|
||||
tooltip={
|
||||
<Markdown dark disallowHeading unstyleLinks lineClamp={8}>
|
||||
{item.description}
|
||||
</Markdown>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ItemLink>
|
||||
</ItemNameCell>
|
||||
),
|
||||
},
|
||||
LastEditedBy: {
|
||||
Col: () => (
|
||||
<TableColumn
|
||||
style={{ width: "140px" }}
|
||||
hideAtContainerBreakpoint="sm"
|
||||
containerName="ItemsTableContainer"
|
||||
/>
|
||||
),
|
||||
Header: ({ sortingOptions, onSortingOptionsChange }: HeaderProps) => (
|
||||
<SortableColumnHeader
|
||||
name="last_edited_by"
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
hideAtContainerBreakpoint="sm"
|
||||
containerName="ItemsTableContainer"
|
||||
>
|
||||
{t`Last edited by`}
|
||||
</SortableColumnHeader>
|
||||
),
|
||||
Cell: ({
|
||||
testId = "table",
|
||||
item,
|
||||
}: {
|
||||
testId?: string;
|
||||
item: CollectionItem;
|
||||
}) => {
|
||||
const lastEditInfo = item["last-edit-info"];
|
||||
const lastEditedBy = getLastEditedBy(lastEditInfo) ?? "";
|
||||
|
||||
return (
|
||||
<ItemCell
|
||||
data-testid={`${testId}-last-edited-by`}
|
||||
hideAtContainerBreakpoint="sm"
|
||||
containerName="ItemsTableContainer"
|
||||
>
|
||||
<Ellipsified>{lastEditedBy}</Ellipsified>
|
||||
</ItemCell>
|
||||
);
|
||||
},
|
||||
},
|
||||
LastEditedAt: {
|
||||
Col: () => (
|
||||
<TableColumn
|
||||
style={{ width: "140px" }}
|
||||
hideAtContainerBreakpoint="md"
|
||||
containerName="ItemsTableContainer"
|
||||
/>
|
||||
),
|
||||
Header: ({ sortingOptions, onSortingOptionsChange }: HeaderProps) => (
|
||||
<SortableColumnHeader
|
||||
name="last_edited_at"
|
||||
sortingOptions={sortingOptions}
|
||||
onSortingOptionsChange={onSortingOptionsChange}
|
||||
hideAtContainerBreakpoint="md"
|
||||
containerName="ItemsTableContainer"
|
||||
>
|
||||
{t`Last edited at`}
|
||||
</SortableColumnHeader>
|
||||
),
|
||||
Cell: ({ testId, item }: { testId: string; item: CollectionItem }) => {
|
||||
const lastEditInfo = item["last-edit-info"];
|
||||
return (
|
||||
<ItemCell
|
||||
data-testid={`${testId}-last-edited-at`}
|
||||
data-server-date
|
||||
hideAtContainerBreakpoint="md"
|
||||
containerName="ItemsTableContainer"
|
||||
>
|
||||
{lastEditInfo && (
|
||||
<Tooltip tooltip={<DateTime value={lastEditInfo.timestamp} />}>
|
||||
<DateTime unit="day" value={lastEditInfo.timestamp} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</ItemCell>
|
||||
);
|
||||
},
|
||||
},
|
||||
ActionMenu: {
|
||||
Header: () => <th></th>,
|
||||
Col: () => <col style={{ width: "100px" }} />,
|
||||
Cell: ({
|
||||
item,
|
||||
collection,
|
||||
databases,
|
||||
bookmarks,
|
||||
onCopy,
|
||||
onMove,
|
||||
createBookmark,
|
||||
deleteBookmark,
|
||||
}: ActionMenuProps) => {
|
||||
return (
|
||||
<ItemCell>
|
||||
<RowActionsContainer>
|
||||
<ActionMenu
|
||||
item={item}
|
||||
collection={collection}
|
||||
databases={databases}
|
||||
bookmarks={bookmarks}
|
||||
onCopy={onCopy}
|
||||
onMove={onMove}
|
||||
createBookmark={createBookmark}
|
||||
deleteBookmark={deleteBookmark}
|
||||
/>
|
||||
{item.model === "dataset" && <ModelDetailLink model={item} />}
|
||||
</RowActionsContainer>
|
||||
</ItemCell>
|
||||
);
|
||||
},
|
||||
},
|
||||
/** Applies a border-radius to the right edge of the table.
|
||||
* This is needed since columns can be hidden responsively,
|
||||
* and so we can't just apply the border-radius to the last column in the DOM */
|
||||
RightEdge: {
|
||||
Header: () => <th></th>,
|
||||
Col: () => <col style={{ width: "1rem" }} />,
|
||||
Cell: () => <ItemCell />,
|
||||
},
|
||||
};
|
||||
|
||||
const getLastEditedBy = (lastEditInfo?: Edit) => {
|
||||
if (!lastEditInfo) {
|
||||
return "";
|
||||
}
|
||||
const name = getFullName(lastEditInfo);
|
||||
return name || lastEditInfo.email;
|
||||
};
|
||||
@@ -2,8 +2,8 @@ import PinDropZone from "metabase/collections/components/PinDropZone";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import type { CollectionItem } from "metabase-types/api";
|
||||
|
||||
import type { BaseItemsTableProps } from "./BaseItemsTable";
|
||||
import BaseItemsTable from "./BaseItemsTable";
|
||||
import type { BaseItemsTableProps, ItemRendererProps } from "./BaseItemsTable";
|
||||
import { BaseItemsTable } from "./BaseItemsTable";
|
||||
import type { BaseTableItemProps } from "./BaseTableItem";
|
||||
import { ItemsTableRoot } from "./ItemsTable.styled";
|
||||
|
||||
@@ -22,10 +22,14 @@ const Item = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ItemsTable = ({
|
||||
export const ItemsTable = ({
|
||||
items,
|
||||
ItemComponent = Item,
|
||||
...props
|
||||
}: { items: CollectionItem[] } & BaseItemsTableProps) => {
|
||||
}: {
|
||||
items: CollectionItem[];
|
||||
ItemComponent?: (props: ItemRendererProps) => JSX.Element;
|
||||
} & BaseItemsTableProps) => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<ItemsTableRoot>
|
||||
@@ -37,10 +41,7 @@ const ItemsTable = ({
|
||||
return (
|
||||
<div className={CS.relative}>
|
||||
<PinDropZone variant="unpin" />
|
||||
<BaseItemsTable items={items} {...props} renderItem={Item} />
|
||||
<BaseItemsTable items={items} {...props} ItemComponent={ItemComponent} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ItemsTable;
|
||||
1
frontend/src/metabase/components/ItemsTable/index.tsx
Normal file
1
frontend/src/metabase/components/ItemsTable/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ItemsTable } from "./ItemsTable";
|
||||
14
frontend/src/metabase/components/ItemsTable/utils.ts
Normal file
14
frontend/src/metabase/components/ItemsTable/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { breakpoints, type BreakpointName } from "metabase/ui/theme";
|
||||
|
||||
export interface ResponsiveProps {
|
||||
/** The element will be hidden when the container's width is below this breakpoint */
|
||||
hideAtContainerBreakpoint?: BreakpointName;
|
||||
containerName?: string;
|
||||
}
|
||||
|
||||
export const getContainerQuery = (props: ResponsiveProps) =>
|
||||
props.hideAtContainerBreakpoint
|
||||
? `@container ${props.containerName || ""} (max-width: ${
|
||||
breakpoints[props.hideAtContainerBreakpoint]
|
||||
}) { display: none; }`
|
||||
: "";
|
||||
@@ -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;
|
||||
}
|
||||
>``;
|
||||
@@ -65,7 +65,7 @@ export default DragSource(
|
||||
|
||||
onDrop && onDrop();
|
||||
} catch (e) {
|
||||
console.error("There was a problem moving these items: " + e);
|
||||
console.error("There was a problem moving these items: ", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Component } from "react";
|
||||
import { DragLayer } from "react-dnd";
|
||||
import _ from "underscore";
|
||||
|
||||
import BaseItemsTable from "metabase/collections/components/BaseItemsTable";
|
||||
import PinnedItemCard from "metabase/collections/components/PinnedItemCard";
|
||||
import BodyComponent from "metabase/components/BodyComponent";
|
||||
import { BaseItemsTable } from "metabase/components/ItemsTable/BaseItemsTable";
|
||||
|
||||
// NOTE: our version of react-hot-loader doesn't play nice with react-dnd's DragLayer,
|
||||
// so we exclude files named `*DragLayer.jsx` in webpack.config.js
|
||||
@@ -79,13 +79,11 @@ class DraggedItems extends Component {
|
||||
|
||||
renderItem = ({ item, ...itemProps }) => {
|
||||
const isPinned = this.checkIsPinned(item);
|
||||
|
||||
const key = `${item.model}-${item.id}`;
|
||||
const PINNED_WIDTH = 400;
|
||||
|
||||
if (isPinned) {
|
||||
return (
|
||||
<div style={{ width: PINNED_WIDTH }}>
|
||||
<td style={{ padding: 0 }}>
|
||||
<PinnedItemCard
|
||||
key={key}
|
||||
item={item}
|
||||
@@ -93,7 +91,7 @@ class DraggedItems extends Component {
|
||||
onCopy={_.noop}
|
||||
onMove={_.noop}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -111,6 +109,7 @@ class DraggedItems extends Component {
|
||||
render() {
|
||||
const { items, draggedItem } = this.props;
|
||||
const index = _.findIndex(items, draggedItem);
|
||||
const allPinned = items.every(item => this.checkIsPinned(item));
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -120,9 +119,11 @@ class DraggedItems extends Component {
|
||||
>
|
||||
<BaseItemsTable
|
||||
items={items}
|
||||
renderItem={this.renderItem}
|
||||
ItemComponent={props => this.renderItem(props)}
|
||||
headless
|
||||
style={{ width: 960 }}
|
||||
isInDragLayer
|
||||
style={{ width: allPinned ? 400 : undefined }}
|
||||
includeColGroup={!allPinned}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 [truncatedStatuses, setTruncatedStatuses] = 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);
|
||||
setTruncatedStatuses(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 = [...truncatedStatuses.values()].some(Boolean);
|
||||
return { areAnyTruncated, ref };
|
||||
};
|
||||
|
||||
@@ -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,78 @@ function MainNavbarView({
|
||||
}
|
||||
}, [handleCloseNavbar]);
|
||||
|
||||
const [expandBrowse, setExpandBrowse] = useUserSetting(
|
||||
"expand-browse-in-nav",
|
||||
{ shouldRefresh: false },
|
||||
);
|
||||
const [expandBookmarks, setExpandBookmarks] = useUserSetting(
|
||||
"expand-bookmarks-in-nav",
|
||||
{ shouldRefresh: false },
|
||||
);
|
||||
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 +169,8 @@ function MainNavbarView({
|
||||
selectedItem={cardItem ?? dashboardItem ?? collectionItem}
|
||||
onSelect={onItemSelect}
|
||||
reorderBookmarks={reorderBookmarks}
|
||||
onToggle={_.debounce(setExpandBookmarks, debounceTimeout)}
|
||||
initialState={expandBookmarks ? "expanded" : "collapsed"}
|
||||
/>
|
||||
</SidebarSection>
|
||||
)}
|
||||
@@ -156,12 +194,10 @@ function MainNavbarView({
|
||||
</SidebarContentRoot>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollectionSectionHeadingProps {
|
||||
currentUser: User;
|
||||
handleCreateNewCollection: () => void;
|
||||
}
|
||||
|
||||
function CollectionSectionHeading({
|
||||
currentUser,
|
||||
handleCreateNewCollection,
|
||||
@@ -217,6 +253,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 = {
|
||||
|
||||
@@ -35,13 +35,20 @@ export const settings = createReducer(
|
||||
},
|
||||
);
|
||||
|
||||
export type UpdateUserSettingOptions = {
|
||||
shouldRefresh?: boolean;
|
||||
};
|
||||
|
||||
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>;
|
||||
}) {
|
||||
function <K extends keyof UserSettings>(
|
||||
setting: {
|
||||
key: K;
|
||||
value: UserSettings[K];
|
||||
},
|
||||
{ shouldRefresh = true }: UpdateUserSettingOptions = {},
|
||||
) {
|
||||
return async function (dispatch) {
|
||||
try {
|
||||
await SettingsApi.put(setting);
|
||||
@@ -49,7 +56,9 @@ export const updateUserSetting = createThunkAction(
|
||||
console.error("error updating user setting", setting, error);
|
||||
throw error;
|
||||
} finally {
|
||||
await dispatch(refreshSiteSettings({}));
|
||||
if (shouldRefresh) {
|
||||
await dispatch(refreshSiteSettings({}));
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,14 +39,17 @@ import {
|
||||
} from "./components";
|
||||
import { getThemeColors } from "./utils/colors";
|
||||
|
||||
export const breakpoints = {
|
||||
xs: "23em",
|
||||
sm: "40em",
|
||||
md: "60em",
|
||||
lg: "80em",
|
||||
xl: "120em",
|
||||
};
|
||||
export type BreakpointName = keyof typeof breakpoints;
|
||||
|
||||
export const getThemeOverrides = (): MantineThemeOverride => ({
|
||||
breakpoints: {
|
||||
xs: "23em",
|
||||
sm: "40em",
|
||||
md: "60em",
|
||||
lg: "80em",
|
||||
xl: "120em",
|
||||
},
|
||||
breakpoints,
|
||||
colors: getThemeColors(),
|
||||
primaryColor: "brand",
|
||||
primaryShade: 0,
|
||||
|
||||
@@ -493,6 +493,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