diff --git a/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts b/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts index 3f0be43433c3505991b190710454d78fe9c9f371..008b853d894a089239b238250cfb32fd2001e04a 100644 --- a/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts +++ b/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts @@ -15,6 +15,11 @@ import { const { PRODUCTS_ID } = SAMPLE_DATABASE; +const filterButton = () => + cy + .findByTestId("browse-models-header") + .findByRole("button", { name: /Filters/i }); + describeWithSnowplow("scenarios > browse", () => { beforeEach(() => { resetSnowplow(); @@ -100,7 +105,7 @@ describeWithSnowplow("scenarios > browse", () => { it("on an open-source instance, the Browse models page has no controls for setting filters", () => { cy.visit("/"); navigationSidebar().findByLabelText("Browse models").click(); - cy.findByRole("button", { name: /filter icon/i }).should("not.exist"); + filterButton().should("not.exist"); cy.findByRole("switch", { name: /Show verified models only/ }).should( "not.exist", ); @@ -119,8 +124,7 @@ describeWithSnowplowEE("scenarios > browse (EE)", () => { ); cy.intercept("POST", "/api/moderation-review").as("updateVerification"); }); - const openFilterPopover = () => - cy.findByRole("button", { name: /filter icon/i }).click(); + const openFilterPopover = () => filterButton().click(); const toggle = () => cy.findByRole("switch", { name: /Show verified models only/ }); @@ -132,10 +136,6 @@ describeWithSnowplowEE("scenarios > browse (EE)", () => { const recentModel2 = () => recentsGrid().findByText("Model 2"); const model1Row = () => modelsTable().findByRole("row", { name: /Model 1/i }); const model2Row = () => modelsTable().findByRole("row", { name: /Model 2/i }); - const filterButton = () => - cy - .findByTestId("browse-models-header") - .findByRole("button", { name: /filter icon/i }); const setVerification = (linkSelector: RegExp | string) => { cy.findByLabelText("Move, trash, and more...").click(); diff --git a/enterprise/frontend/src/metabase-enterprise/browse/components/BrowseModels.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/browse/models/BrowseModels.unit.spec.tsx similarity index 98% rename from enterprise/frontend/src/metabase-enterprise/browse/components/BrowseModels.unit.spec.tsx rename to enterprise/frontend/src/metabase-enterprise/browse/models/BrowseModels.unit.spec.tsx index 3dc80a5392d18f8c77f1c3a5f5e9507a642df229..f615978b30fb809551ea29fc07dfc07ca6c96160 100644 --- a/enterprise/frontend/src/metabase-enterprise/browse/components/BrowseModels.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/browse/models/BrowseModels.unit.spec.tsx @@ -6,11 +6,11 @@ import { } from "__support__/server-mocks"; import { mockSettings } from "__support__/settings"; import { renderWithProviders, screen, within } from "__support__/ui"; -import { BrowseModels } from "metabase/browse/components/BrowseModels"; +import { BrowseModels } from "metabase/browse"; import { createMockModelResult, createMockRecentModel, -} from "metabase/browse/test-utils"; +} from "metabase/browse/models/test-utils"; import type { RecentCollectionItem } from "metabase-types/api"; import { createMockCollection, diff --git a/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.tsx index 5f3b35e7ee8c793134cecfbd4137eb7096a907b3..0570b2f2cca5eb2b52e2f276e2e69dd7f991affa 100644 --- a/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.tsx @@ -1,7 +1,7 @@ import { setupEnterprisePlugins } from "__support__/enterprise"; import { mockSettings } from "__support__/settings"; import { renderWithProviders } from "__support__/ui"; -import { createMockModelResult } from "metabase/browse/test-utils"; +import { createMockModelResult } from "metabase/browse/models/test-utils"; import { createMockCollection, createMockTokenFeatures, diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.styled.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.styled.tsx deleted file mode 100644 index 88e51c28cb81dad0fd95e15d49bdeb0d8e40a1f9..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.styled.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import styled from "@emotion/styled"; - -import { Text } from "metabase/ui"; - -export const ModelFilterControlSwitchLabel = styled(Text)` - text-align: right; - font-weight: bold; - line-height: 1rem; - padding: 0 0.75rem; -`; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.tsx deleted file mode 100644 index 1acb1250272fd255b8abbd856adef2a6b10684dc..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useCallback } from "react"; -import { t } from "ttag"; -import _ from "underscore"; - -import type { - ActualModelFilters, - ModelFilterControlsProps, -} from "metabase/browse/utils"; -import { useUserSetting } from "metabase/common/hooks"; -import { Button, Icon, Paper, Popover, Switch, Text } from "metabase/ui"; - -export const ModelFilterControls = ({ - actualModelFilters, - 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], - ); - - // There's only one filter for now - const filters = [actualModelFilters.onlyShowVerifiedModels]; - - const areAnyFiltersActive = filters.some(filter => filter); - - return ( - <Popover position="bottom-end"> - <Popover.Target> - <Button p="sm" lh={0} variant="subtle" color="text-dark" pos="relative"> - {areAnyFiltersActive && <Dot />} - <Icon name="filter" /> - </Button> - </Popover.Target> - <Popover.Dropdown p="lg"> - <Switch - label={ - <Text - align="end" - weight="bold" - >{t`Show verified models only`}</Text> - } - role="switch" - checked={actualModelFilters.onlyShowVerifiedModels} - onChange={e => { - handleModelFilterChange("onlyShowVerifiedModels", e.target.checked); - }} - labelPosition="left" - /> - </Popover.Dropdown> - </Popover> - ); -}; - -const Dot = () => { - return ( - <Paper - pos="absolute" - right="0px" - top="7px" - radius="50%" - bg={"var(--mb-color-brand)"} - w="sm" - h="sm" - data-testid="filter-dot" - /> - ); -}; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts b/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts index a5c206fcb98b94d49974a472837f0f93083e80eb..1e318383b25edbd3e7e67f1088909db8c362d53e 100644 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts @@ -1,19 +1,18 @@ import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins"; import { hasPremiumFeature } from "metabase-enterprise/settings"; -import { ModelFilterControls } from "./ModelFilterControls"; import { VerifiedFilter } from "./VerifiedFilter"; import { MetricFilterControls, getDefaultMetricFilters } from "./metrics"; -import { availableModelFilters, useModelFilterSettings } from "./utils"; +import { ModelFilterControls, getDefaultModelFilters } from "./models"; if (hasPremiumFeature("content_verification")) { Object.assign(PLUGIN_CONTENT_VERIFICATION, { + contentVerificationEnabled: true, VerifiedFilter, + ModelFilterControls, - availableModelFilters, - useModelFilterSettings, + getDefaultModelFilters, - contentVerificationEnabled: true, getDefaultMetricFilters, MetricFilterControls, }); diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx index 7a78ad6f1511bbedc8c06bdb0818aaa265318ea6..2522465ddb1fbb0c8b6e6d2392223d4279f291ed 100644 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx @@ -4,7 +4,7 @@ import { t } from "ttag"; import type { MetricFilterControlsProps, MetricFilterSettings, -} from "metabase/browse/utils"; +} from "metabase/browse/metrics"; import { useUserSetting } from "metabase/common/hooks"; import { getSetting } from "metabase/selectors/settings"; import { Button, Icon, Paper, Popover, Switch, Text } from "metabase/ui"; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/models.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/models.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7d9f473960d0bfd978de96bb8cae090ba3d609cc --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/models.tsx @@ -0,0 +1,85 @@ +import { type ChangeEvent, useCallback } from "react"; +import { t } from "ttag"; + +import type { + ModelFilterControlsProps, + ModelFilterSettings, +} from "metabase/browse/models"; +import { useUserSetting } from "metabase/common/hooks"; +import { getSetting } from "metabase/selectors/settings"; +import { Button, Icon, Paper, Popover, Switch, Text } from "metabase/ui"; +import type { State } from "metabase-types/store"; + +const USER_SETTING_KEY = "browse-filter-only-verified-models"; + +export function getDefaultModelFilters(state: State): ModelFilterSettings { + return { + verified: getSetting(state, USER_SETTING_KEY) ?? false, + }; +} + +// This component is similar to the MetricFilterControls component from ./MetricFilterControls.tsx +// merging them might be a good idea in the future. +export const ModelFilterControls = ({ + modelFilters, + setModelFilters, +}: ModelFilterControlsProps) => { + const areAnyFiltersActive = Object.values(modelFilters).some(Boolean); + + const [_, setUserSetting] = useUserSetting(USER_SETTING_KEY); + + const handleVerifiedFilterChange = useCallback( + function (evt: ChangeEvent<HTMLInputElement>) { + setModelFilters({ ...modelFilters, verified: evt.target.checked }); + setUserSetting(evt.target.checked); + }, + [modelFilters, setModelFilters, setUserSetting], + ); + + return ( + <Popover position="bottom-end"> + <Popover.Target> + <Button + p="sm" + lh={0} + variant="subtle" + color="var(--mb-color-text-dark)" + pos="relative" + aria-label={t`Filters`} + > + {areAnyFiltersActive && <Dot />} + <Icon name="filter" /> + </Button> + </Popover.Target> + <Popover.Dropdown p="lg"> + <Switch + label={ + <Text + align="end" + weight="bold" + >{t`Show verified models only`}</Text> + } + role="switch" + checked={Boolean(modelFilters.verified)} + onChange={handleVerifiedFilterChange} + labelPosition="left" + /> + </Popover.Dropdown> + </Popover> + ); +}; + +const Dot = () => { + return ( + <Paper + pos="absolute" + right="0px" + top="7px" + radius="50%" + bg={"var(--mb-color-brand)"} + w="sm" + h="sm" + data-testid="filter-dot" + /> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/utils.ts b/enterprise/frontend/src/metabase-enterprise/content_verification/utils.ts deleted file mode 100644 index 5e7f5968c03495f41beda64a4cb139fcab7fe8cd..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Dispatch, SetStateAction } from "react"; -import { useEffect, useMemo, useState } from "react"; - -import type { - ActualModelFilters, - AvailableModelFilters, -} from "metabase/browse/utils"; -import { useUserSetting } from "metabase/common/hooks"; - -export const availableModelFilters: AvailableModelFilters = { - onlyShowVerifiedModels: { - predicate: model => model.moderated_status === "verified", - activeByDefault: true, - }, -}; - -export const useModelFilterSettings = (): [ - ActualModelFilters, - Dispatch<SetStateAction<ActualModelFilters>>, -] => { - const [initialVerifiedFilterStatus] = useUserSetting( - "browse-filter-only-verified-models", - { shouldRefresh: false }, - ); - const initialModelFilters = useMemo( - () => ({ - onlyShowVerifiedModels: initialVerifiedFilterStatus ?? false, - }), - [initialVerifiedFilterStatus], - ); - - const [actualModelFilters, setActualModelFilters] = - useState<ActualModelFilters>(initialModelFilters); - - useEffect(() => { - setActualModelFilters(initialModelFilters); - }, [initialModelFilters, setActualModelFilters]); - - return [actualModelFilters, setActualModelFilters]; -}; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/utils.unit.spec.ts b/enterprise/frontend/src/metabase-enterprise/content_verification/utils.unit.spec.ts deleted file mode 100644 index 0a65bb1485c62ec6b45c510f93f1c752a8a2cf46..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/utils.unit.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createMockModelResult } from "metabase/browse/test-utils"; - -import { availableModelFilters } from "./utils"; - -describe("Utilities related to content verification", () => { - it("include a constant that defines a filter for only showing verified models", () => { - const models = [ - createMockModelResult({ - name: "A verified model", - moderated_status: "verified", - }), - createMockModelResult({ - name: "An unverified model", - moderated_status: null, - }), - ]; - const filteredModels = models.filter( - availableModelFilters.onlyShowVerifiedModels.predicate, - ); - expect(filteredModels.length).toBe(1); - expect(filteredModels[0].name).toBe("A verified model"); - }); -}); diff --git a/frontend/src/metabase/browse/components/BrowseModels.tsx b/frontend/src/metabase/browse/components/BrowseModels.tsx deleted file mode 100644 index 94e62e4c21eec54549dce340d37c35b8d9d65563..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/components/BrowseModels.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { useMemo } from "react"; -import { t } from "ttag"; - -import NoResults from "assets/img/no_results.svg"; -import { useListRecentsQuery } from "metabase/api"; -import { useFetchModels } from "metabase/common/hooks/use-fetch-models"; -import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper"; -import { - PLUGIN_COLLECTIONS, - PLUGIN_CONTENT_VERIFICATION, -} from "metabase/plugins"; -import { Box, Flex, Group, Icon, Stack, Title } from "metabase/ui"; - -import type { ModelResult } from "../types"; -import { isRecentModel } from "../types"; -import { filterModels } from "../utils"; - -import { - BrowseContainer, - BrowseHeader, - BrowseMain, - BrowseSection, - CenteredEmptyState, -} from "./BrowseContainer.styled"; -import { ModelExplanationBanner } from "./ModelExplanationBanner"; -import { ModelsTable } from "./ModelsTable"; -import { RecentModels } from "./RecentModels"; -import { getMaxRecentModelCount } from "./utils"; - -const { availableModelFilters, useModelFilterSettings, ModelFilterControls } = - PLUGIN_CONTENT_VERIFICATION; - -export const BrowseModels = () => { - /** Mapping of filter names to true if the filter is active or false if it is inactive */ - const [actualModelFilters, setActualModelFilters] = useModelFilterSettings(); - - const modelsResult = useFetchModels({ model_ancestors: true }); - - const { models, doVerifiedModelsExist } = useMemo(() => { - const unfilteredModels = - (modelsResult.data?.data as ModelResult[] | undefined) ?? []; - const doVerifiedModelsExist = unfilteredModels.some( - model => model.moderated_status === "verified", - ); - const models = - PLUGIN_COLLECTIONS.filterOutItemsFromInstanceAnalytics(unfilteredModels); - return { models, doVerifiedModelsExist }; - }, [modelsResult]); - - const { filteredModels } = useMemo(() => { - const filteredModels = filterModels( - models, - // If no models are verified, don't filter them - doVerifiedModelsExist ? actualModelFilters : {}, - availableModelFilters, - ); - return { filteredModels }; - }, [actualModelFilters, models, doVerifiedModelsExist]); - - const recentModelsResult = useListRecentsQuery(undefined, { - refetchOnMountOrArgChange: true, - }); - - const filteredRecentModels = useMemo( - () => - filterModels( - recentModelsResult.data?.filter(isRecentModel), - // If no models are verified, don't filter them - doVerifiedModelsExist ? actualModelFilters : {}, - availableModelFilters, - ), - [recentModelsResult.data, actualModelFilters, doVerifiedModelsExist], - ); - - const recentModels = useMemo(() => { - const cap = getMaxRecentModelCount(models.length); - return filteredRecentModels.slice(0, cap); - }, [filteredRecentModels, models.length]); - - const isEmpty = - !recentModelsResult.isLoading && - !modelsResult.isLoading && - !filteredModels.length; - - return ( - <BrowseContainer> - <BrowseHeader role="heading" data-testid="browse-models-header"> - <BrowseSection> - <Flex - w="100%" - h="2.25rem" - direction="row" - justify="space-between" - align="center" - > - <Title order={1} color="text-dark"> - <Group spacing="sm"> - <Icon - size={24} - color="var(--mb-color-icon-primary)" - name="model" - /> - {t`Models`} - </Group> - </Title> - {doVerifiedModelsExist && ( - <ModelFilterControls - actualModelFilters={actualModelFilters} - setActualModelFilters={setActualModelFilters} - /> - )} - </Flex> - </BrowseSection> - </BrowseHeader> - <BrowseMain> - <BrowseSection> - <Stack mb="lg" spacing="md" w="100%"> - {isEmpty ? ( - <CenteredEmptyState - title={<Box mb=".5rem">{t`No models here yet`}</Box>} - message={ - <Box maw="24rem">{t`Models help curate data to make it easier to find answers to questions all in one place.`}</Box> - } - illustrationElement={ - <Box mb=".5rem"> - <img src={NoResults} /> - </Box> - } - /> - ) : ( - <> - <ModelExplanationBanner /> - <DelayedLoadingAndErrorWrapper - error={recentModelsResult.error} - loading={ - // If the main models result is still pending, the list of recently viewed - // models isn't ready yet, since the number of recently viewed models is - // capped according to the size of the main models result - recentModelsResult.isLoading || modelsResult.isLoading - } - style={{ flex: 1 }} - loader={<RecentModels skeleton />} - > - <RecentModels models={recentModels} /> - </DelayedLoadingAndErrorWrapper> - <DelayedLoadingAndErrorWrapper - error={modelsResult.error} - loading={modelsResult.isLoading} - style={{ flex: 1 }} - loader={<ModelsTable skeleton />} - > - <ModelsTable models={filteredModels} /> - </DelayedLoadingAndErrorWrapper> - </> - )} - </Stack> - </BrowseSection> - </BrowseMain> - </BrowseContainer> - ); -}; diff --git a/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx b/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx index f762df7e2a1105c5328731b08b6bbbeaa85c025a..37ea6879b89cb6a413819e9be23b698c78556c31 100644 --- a/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx +++ b/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx @@ -9,8 +9,8 @@ import { getSetting } from "metabase/selectors/settings"; import { SAVED_QUESTIONS_VIRTUAL_DB_ID } from "metabase-lib/v1/metadata/utils/saved-questions"; import * as ML_Urls from "metabase-lib/v1/urls"; -import TableBrowser from "../../components/TableBrowser"; import { RELOAD_INTERVAL } from "../../constants"; +import TableBrowser from "../../tables/TableBrowser"; const getDatabaseId = (props, { includeVirtual } = {}) => { const { params } = props; diff --git a/frontend/src/metabase/browse/components/BrowseDatabases.styled.tsx b/frontend/src/metabase/browse/databases/BrowseDatabases.styled.tsx similarity index 86% rename from frontend/src/metabase/browse/components/BrowseDatabases.styled.tsx rename to frontend/src/metabase/browse/databases/BrowseDatabases.styled.tsx index 7ed6b06559958d44142eb0c882afae2022f5bc09..87a3679a3471e7b885d6e53b34519df14384ee81 100644 --- a/frontend/src/metabase/browse/components/BrowseDatabases.styled.tsx +++ b/frontend/src/metabase/browse/databases/BrowseDatabases.styled.tsx @@ -3,7 +3,7 @@ import { Link } from "react-router"; import Card from "metabase/components/Card"; -import { BrowseGrid } from "./BrowseContainer.styled"; +import { BrowseGrid } from "../components/BrowseContainer.styled"; export const DatabaseGrid = styled(BrowseGrid)``; diff --git a/frontend/src/metabase/browse/components/BrowseDatabases.tsx b/frontend/src/metabase/browse/databases/BrowseDatabases.tsx similarity index 94% rename from frontend/src/metabase/browse/components/BrowseDatabases.tsx rename to frontend/src/metabase/browse/databases/BrowseDatabases.tsx index 1690c1cb420cd5104553b3aaa6fe750d6dca4a4d..93d61a1bd22bbfcfd0fa58b93e6b364ba441e2a2 100644 --- a/frontend/src/metabase/browse/components/BrowseDatabases.tsx +++ b/frontend/src/metabase/browse/databases/BrowseDatabases.tsx @@ -13,8 +13,9 @@ import { BrowseMain, BrowseSection, CenteredEmptyState, -} from "./BrowseContainer.styled"; -import { BrowseDataHeader } from "./BrowseDataHeader"; +} from "../components/BrowseContainer.styled"; +import { BrowseDataHeader } from "../components/BrowseDataHeader"; + import { DatabaseCard, DatabaseCardLink, diff --git a/frontend/src/metabase/browse/components/BrowseDatabases.unit.spec.tsx b/frontend/src/metabase/browse/databases/BrowseDatabases.unit.spec.tsx similarity index 100% rename from frontend/src/metabase/browse/components/BrowseDatabases.unit.spec.tsx rename to frontend/src/metabase/browse/databases/BrowseDatabases.unit.spec.tsx diff --git a/frontend/src/metabase/browse/databases/index.tsx b/frontend/src/metabase/browse/databases/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7529e040d02997291f914c3dd88ca41e776c4bc6 --- /dev/null +++ b/frontend/src/metabase/browse/databases/index.tsx @@ -0,0 +1 @@ +export { BrowseDatabases } from "./BrowseDatabases"; diff --git a/frontend/src/metabase/browse/index.tsx b/frontend/src/metabase/browse/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a9e26c2d9a22fcbfd2f96bf5a6b1e1d132610ae7 --- /dev/null +++ b/frontend/src/metabase/browse/index.tsx @@ -0,0 +1,5 @@ +export { BrowseMetrics } from "./metrics"; +export { BrowseModels } from "./models"; +export { BrowseDatabases } from "./databases"; +export { BrowseTables } from "./tables"; +export { BrowseSchemas } from "./schemas"; diff --git a/frontend/src/metabase/browse/components/BrowseMetrics.tsx b/frontend/src/metabase/browse/metrics/BrowseMetrics.tsx similarity index 97% rename from frontend/src/metabase/browse/components/BrowseMetrics.tsx rename to frontend/src/metabase/browse/metrics/BrowseMetrics.tsx index 4a8c48f0421aaece1d309bbac7e3ab9534e61122..66264f2a439198088f8539b597ec2fbaf6f7cd31 100644 --- a/frontend/src/metabase/browse/components/BrowseMetrics.tsx +++ b/frontend/src/metabase/browse/metrics/BrowseMetrics.tsx @@ -10,16 +10,15 @@ import { useSelector } from "metabase/lib/redux"; import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins"; import { Box, Flex, Group, Icon, Stack, Text, Title } from "metabase/ui"; -import type { MetricResult } from "../types"; -import type { MetricFilterSettings } from "../utils"; - import { BrowseContainer, BrowseHeader, BrowseMain, BrowseSection, -} from "./BrowseContainer.styled"; +} from "../components/BrowseContainer.styled"; + import { MetricsTable } from "./MetricsTable"; +import type { MetricFilterSettings, MetricResult } from "./types"; const { contentVerificationEnabled, diff --git a/frontend/src/metabase/browse/components/BrowseMetrics.unit.spec.tsx b/frontend/src/metabase/browse/metrics/BrowseMetrics.unit.spec.tsx similarity index 98% rename from frontend/src/metabase/browse/components/BrowseMetrics.unit.spec.tsx rename to frontend/src/metabase/browse/metrics/BrowseMetrics.unit.spec.tsx index b5335e1e03d1c362304dd7113509e0289cacf2f7..d7a5c4d28f1e53af51f2a695e322fda18e8f8d37 100644 --- a/frontend/src/metabase/browse/components/BrowseMetrics.unit.spec.tsx +++ b/frontend/src/metabase/browse/metrics/BrowseMetrics.unit.spec.tsx @@ -14,10 +14,9 @@ import { } from "metabase-types/api/mocks"; import { createMockSetupState } from "metabase-types/store/mocks"; -import { createMockMetricResult, createMockRecentMetric } from "../test-utils"; -import type { MetricResult, RecentMetric } from "../types"; - import { BrowseMetrics } from "./BrowseMetrics"; +import { createMockMetricResult, createMockRecentMetric } from "./test-utils"; +import type { MetricResult, RecentMetric } from "./types"; type SetupOpts = { metricCount?: number; diff --git a/frontend/src/metabase/browse/components/MetricsTable.tsx b/frontend/src/metabase/browse/metrics/MetricsTable.tsx similarity index 98% rename from frontend/src/metabase/browse/components/MetricsTable.tsx rename to frontend/src/metabase/browse/metrics/MetricsTable.tsx index 7721eadb578fc46b49e779bc2d6312c219e3df9c..07971547644a244af7abb4d90cc17248c922582f 100644 --- a/frontend/src/metabase/browse/components/MetricsTable.tsx +++ b/frontend/src/metabase/browse/metrics/MetricsTable.tsx @@ -42,8 +42,6 @@ import { import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; -import type { MetricResult } from "../types"; - import { Cell, CollectionLink, @@ -53,11 +51,13 @@ import { Value, ValueTableCell, ValueWrapper, -} from "./BrowseTable.styled"; +} from "../components/BrowseTable.styled"; + +import type { MetricResult } from "./types"; import { getDatasetValueForMetric, getMetricDescription, - sortModelOrMetric, + sortMetrics, } from "./utils"; type MetricsTableProps = { @@ -109,7 +109,7 @@ export function MetricsTable({ ); const locale = useLocale(); - const sortedMetrics = sortModelOrMetric(metrics, sortingOptions, locale); + const sortedMetrics = sortMetrics(metrics, sortingOptions, locale); const handleSortingOptionsChange = skeleton ? undefined : setSortingOptions; diff --git a/frontend/src/metabase/browse/metrics/index.tsx b/frontend/src/metabase/browse/metrics/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9eefac1ac955fed05b847ef5bac3663021ce8034 --- /dev/null +++ b/frontend/src/metabase/browse/metrics/index.tsx @@ -0,0 +1,6 @@ +export { BrowseMetrics } from "./BrowseMetrics"; +export type { + MetricFilterControlsProps, + MetricFilterSettings, + RecentMetric, +} from "./types"; diff --git a/frontend/src/metabase/browse/metrics/test-utils.ts b/frontend/src/metabase/browse/metrics/test-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..a420b268588a0fa164193c15e15783e9353d370d --- /dev/null +++ b/frontend/src/metabase/browse/metrics/test-utils.ts @@ -0,0 +1,19 @@ +import { + createMockRecentCollectionItem, + createMockSearchResult, +} from "metabase-types/api/mocks"; + +import type { MetricResult, RecentMetric } from "./types"; + +export const createMockMetricResult = ( + metric: Partial<MetricResult> = {}, +): MetricResult => + createMockSearchResult({ ...metric, model: "metric" }) as MetricResult; + +export const createMockRecentMetric = ( + metric: Partial<RecentMetric>, +): RecentMetric => + createMockRecentCollectionItem({ + ...metric, + model: "metric", + }) as RecentMetric; diff --git a/frontend/src/metabase/browse/metrics/types.tsx b/frontend/src/metabase/browse/metrics/types.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f3ba40104fcaced1a0e98079a8487908fea0f9ff --- /dev/null +++ b/frontend/src/metabase/browse/metrics/types.tsx @@ -0,0 +1,19 @@ +import type { RecentCollectionItem, SearchResult } from "metabase-types/api"; + +/** + * Metric retrieved through the search endpoint + */ +export type MetricResult = SearchResult<number, "metric">; + +export interface RecentMetric extends RecentCollectionItem { + model: "metric"; +} + +export type MetricFilterSettings = { + verified?: boolean; +}; + +export type MetricFilterControlsProps = { + metricFilters: MetricFilterSettings; + setMetricFilters: (settings: MetricFilterSettings) => void; +}; diff --git a/frontend/src/metabase/browse/components/utils.tsx b/frontend/src/metabase/browse/metrics/utils.tsx similarity index 68% rename from frontend/src/metabase/browse/components/utils.tsx rename to frontend/src/metabase/browse/metrics/utils.tsx index c12fb60054eca2c12576fa2023f61a580929e99c..919e223535fdb02e2bd8aea017b7714fc0c8b1d5 100644 --- a/frontend/src/metabase/browse/components/utils.tsx +++ b/frontend/src/metabase/browse/metrics/utils.tsx @@ -3,24 +3,10 @@ import { t } from "ttag"; import { getCollectionPathAsString } from "metabase/collections/utils"; import { formatValue } from "metabase/lib/formatting"; import { isDate } from "metabase-lib/v1/types/utils/isa"; -import type { Dataset, SearchResult } from "metabase-types/api"; +import type { Dataset } from "metabase-types/api"; import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; -import type { MetricResult, ModelResult } from "../types"; - -export type ModelOrMetricResult = ModelResult | MetricResult; - -export const isModel = (item: SearchResult) => item.model === "dataset"; - -export const getModelDescription = (item: ModelResult) => { - if (item.collection && !item.description?.trim()) { - return t`A model`; - } else { - return item.description; - } -}; - -export const isMetric = (item: SearchResult) => item.model === "metric"; +import type { MetricResult } from "./types"; export const getMetricDescription = (item: MetricResult) => { if (item.collection && !item.description?.trim()) { @@ -31,30 +17,30 @@ export const getMetricDescription = (item: MetricResult) => { }; const getValueForSorting = ( - model: ModelResult | MetricResult, - sort_column: keyof ModelResult, + metric: MetricResult, + sort_column: keyof MetricResult, ): string => { if (sort_column === "collection") { - return getCollectionPathAsString(model.collection) ?? ""; + return getCollectionPathAsString(metric.collection) ?? ""; } else { - return model[sort_column] ?? ""; + return metric[sort_column] ?? ""; } }; export const isValidSortColumn = ( sort_column: string, -): sort_column is keyof ModelResult => { +): sort_column is keyof MetricResult => { return ["name", "collection", "description"].includes(sort_column); }; export const getSecondarySortColumn = ( sort_column: string, -): keyof ModelResult => { +): keyof MetricResult => { return sort_column === "name" ? "collection" : "name"; }; -export function sortModelOrMetric<T extends ModelOrMetricResult>( - modelsOrMetrics: T[], +export function sortMetrics( + metrics: MetricResult[], sortingOptions: SortingOptions, localeCode: string = "en", ) { @@ -62,21 +48,21 @@ export function sortModelOrMetric<T extends ModelOrMetricResult>( if (!isValidSortColumn(sort_column)) { console.error("Invalid sort column", sort_column); - return modelsOrMetrics; + return metrics; } const compare = (a: string, b: string) => a.localeCompare(b, localeCode, { sensitivity: "base" }); - return [...modelsOrMetrics].sort((modelOrMetricA, modelOrMetricB) => { - const a = getValueForSorting(modelOrMetricA, sort_column); - const b = getValueForSorting(modelOrMetricB, sort_column); + return [...metrics].sort((metricA, metricB) => { + const a = getValueForSorting(metricA, sort_column); + const b = getValueForSorting(metricB, sort_column); let result = compare(a, b); if (result === 0) { const sort_column2 = getSecondarySortColumn(sort_column); - const a2 = getValueForSorting(modelOrMetricA, sort_column2); - const b2 = getValueForSorting(modelOrMetricB, sort_column2); + const a2 = getValueForSorting(metricA, sort_column2); + const b2 = getValueForSorting(metricB, sort_column2); result = compare(a2, b2); } @@ -84,22 +70,6 @@ export function sortModelOrMetric<T extends ModelOrMetricResult>( }); } -/** Find the maximum number of recently viewed models to show. - * This is roughly proportional to the number of models the user - * has permission to see */ -export const getMaxRecentModelCount = ( - /** How many models the user has permission to see */ - modelCount: number, -) => { - if (modelCount > 20) { - return 8; - } - if (modelCount > 9) { - return 4; - } - return 0; -}; - export function isDatasetScalar(dataset: Dataset) { if (dataset.error) { return false; diff --git a/frontend/src/metabase/browse/components/utils.unit.spec.tsx b/frontend/src/metabase/browse/metrics/utils.unit.spec.tsx similarity index 72% rename from frontend/src/metabase/browse/components/utils.unit.spec.tsx rename to frontend/src/metabase/browse/metrics/utils.unit.spec.tsx index 1c22219bead8eb4dd058f3473df03c36b7e34a72..325dd859a520f93924d1d38936430055fd685cd2 100644 --- a/frontend/src/metabase/browse/components/utils.unit.spec.tsx +++ b/frontend/src/metabase/browse/metrics/utils.unit.spec.tsx @@ -6,20 +6,18 @@ import { } from "metabase-types/api/mocks"; import { SortDirection } from "metabase-types/api/sorting"; -import { createMockModelResult } from "../test-utils"; -import type { ModelResult } from "../types"; - +import { createMockMetricResult } from "./test-utils"; +import type { MetricResult } from "./types"; import { getDatasetValueForMetric, - getMaxRecentModelCount, isDatasetScalar, - sortModelOrMetric, + sortMetrics, } from "./utils"; -describe("sortModels", () => { +describe("sortMetrics", () => { let id = 0; - const modelMap: Record<string, ModelResult> = { - "model named A, with collection path X / Y / Z": createMockModelResult({ + const metricMap: Record<string, MetricResult> = { + "model named A, with collection path X / Y / Z": createMockMetricResult({ id: id++, name: "A", collection: createMockCollection({ @@ -30,12 +28,12 @@ describe("sortModels", () => { ], }), }), - "model named C, with collection path Y": createMockModelResult({ + "model named C, with collection path Y": createMockMetricResult({ id: id++, name: "C", collection: createMockCollection({ name: "Y" }), }), - "model named B, with collection path D / E / F": createMockModelResult({ + "model named B, with collection path D / E / F": createMockMetricResult({ id: id++, name: "B", collection: createMockCollection({ @@ -47,14 +45,14 @@ describe("sortModels", () => { }), }), }; - const mockSearchResults = Object.values(modelMap); + const mockSearchResults = Object.values(metricMap); it("can sort by name in ascending order", () => { const sortingOptions = { sort_column: "name", sort_direction: SortDirection.Asc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["A", "B", "C"]); }); @@ -63,7 +61,7 @@ describe("sortModels", () => { sort_column: "name", sort_direction: SortDirection.Desc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["C", "B", "A"]); }); @@ -72,7 +70,7 @@ describe("sortModels", () => { sort_column: "collection", sort_direction: SortDirection.Asc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["B", "A", "C"]); }); @@ -81,17 +79,19 @@ describe("sortModels", () => { sort_column: "collection", sort_direction: SortDirection.Desc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["C", "A", "B"]); }); describe("secondary sort", () => { - modelMap["model named C, with collection path Z"] = createMockModelResult({ - name: "C", - collection: createMockCollection({ name: "Z" }), - }); - modelMap["model named Bz, with collection path D / E / F"] = - createMockModelResult({ + metricMap["model named C, with collection path Z"] = createMockMetricResult( + { + name: "C", + collection: createMockCollection({ name: "Z" }), + }, + ); + metricMap["model named Bz, with collection path D / E / F"] = + createMockMetricResult({ name: "Bz", collection: createMockCollection({ name: "F", @@ -101,20 +101,20 @@ describe("sortModels", () => { ], }), }); - const mockSearchResults = Object.values(modelMap); + const mockSearchResults = Object.values(metricMap); it("can sort by collection path, ascending, and then does a secondary sort by name", () => { const sortingOptions = { sort_column: "collection", sort_direction: SortDirection.Asc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted).toEqual([ - modelMap["model named B, with collection path D / E / F"], - modelMap["model named Bz, with collection path D / E / F"], - modelMap["model named A, with collection path X / Y / Z"], - modelMap["model named C, with collection path Y"], - modelMap["model named C, with collection path Z"], + metricMap["model named B, with collection path D / E / F"], + metricMap["model named Bz, with collection path D / E / F"], + metricMap["model named A, with collection path X / Y / Z"], + metricMap["model named C, with collection path Y"], + metricMap["model named C, with collection path Z"], ]); }); @@ -123,13 +123,13 @@ describe("sortModels", () => { sort_column: "collection", sort_direction: SortDirection.Desc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted).toEqual([ - modelMap["model named C, with collection path Z"], - modelMap["model named C, with collection path Y"], - modelMap["model named A, with collection path X / Y / Z"], - modelMap["model named Bz, with collection path D / E / F"], - modelMap["model named B, with collection path D / E / F"], + metricMap["model named C, with collection path Z"], + metricMap["model named C, with collection path Y"], + metricMap["model named A, with collection path X / Y / Z"], + metricMap["model named Bz, with collection path D / E / F"], + metricMap["model named B, with collection path D / E / F"], ]); }); @@ -139,7 +139,7 @@ describe("sortModels", () => { sort_direction: SortDirection.Asc, } as const; - const addUmlauts = (model: ModelResult): ModelResult => ({ + const addUmlauts = (model: MetricResult): MetricResult => ({ ...model, name: model.name.replace(/^B$/g, "Bä"), collection: { @@ -153,63 +153,45 @@ describe("sortModels", () => { }, }); - const swedishModelMap = { + const swedishmetricMap = { "model named A, with collection path Ä / Y / Z": addUmlauts( - modelMap["model named A, with collection path X / Y / Z"], + metricMap["model named A, with collection path X / Y / Z"], ), "model named Bä, with collection path D / E / F": addUmlauts( - modelMap["model named B, with collection path D / E / F"], + metricMap["model named B, with collection path D / E / F"], ), "model named Bz, with collection path D / E / F": addUmlauts( - modelMap["model named Bz, with collection path D / E / F"], + metricMap["model named Bz, with collection path D / E / F"], ), "model named C, with collection path Y": addUmlauts( - modelMap["model named C, with collection path Y"], + metricMap["model named C, with collection path Y"], ), "model named C, with collection path Z": addUmlauts( - modelMap["model named C, with collection path Z"], + metricMap["model named C, with collection path Z"], ), }; - const swedishResults = Object.values(swedishModelMap); + const swedishResults = Object.values(swedishmetricMap); // When sorting in Swedish, z comes before ä const swedishLocaleCode = "sv"; - const sorted = sortModelOrMetric( + const sorted = sortMetrics( swedishResults, sortingOptions, swedishLocaleCode, ); expect("ä".localeCompare("z", "sv", { sensitivity: "base" })).toEqual(1); expect(sorted).toEqual([ - swedishModelMap["model named Bz, with collection path D / E / F"], // Model Bz sorts before Bä - swedishModelMap["model named Bä, with collection path D / E / F"], - swedishModelMap["model named C, with collection path Y"], - swedishModelMap["model named C, with collection path Z"], // Collection Z sorts before Ä - swedishModelMap["model named A, with collection path Ä / Y / Z"], + swedishmetricMap["model named Bz, with collection path D / E / F"], // Model Bz sorts before Bä + swedishmetricMap["model named Bä, with collection path D / E / F"], + swedishmetricMap["model named C, with collection path Y"], + swedishmetricMap["model named C, with collection path Z"], // Collection Z sorts before Ä + swedishmetricMap["model named A, with collection path Ä / Y / Z"], ]); }); }); }); -describe("getMaxRecentModelCount", () => { - it("returns 8 for modelCount greater than 20", () => { - expect(getMaxRecentModelCount(21)).toBe(8); - expect(getMaxRecentModelCount(100)).toBe(8); - }); - - it("returns 4 for modelCount greater than 9 and less than or equal to 20", () => { - expect(getMaxRecentModelCount(10)).toBe(4); - expect(getMaxRecentModelCount(20)).toBe(4); - }); - - it("returns 0 for modelCount of 9 or less", () => { - expect(getMaxRecentModelCount(0)).toBe(0); - expect(getMaxRecentModelCount(5)).toBe(0); - expect(getMaxRecentModelCount(9)).toBe(0); - }); -}); - describe("isDatasetScalar", () => { it("should return true for a dataset with a single column and a single row", () => { const dataset = createMockDataset({ diff --git a/frontend/src/metabase/browse/components/BrowseModels.styled.tsx b/frontend/src/metabase/browse/models/BrowseModels.styled.tsx similarity index 98% rename from frontend/src/metabase/browse/components/BrowseModels.styled.tsx rename to frontend/src/metabase/browse/models/BrowseModels.styled.tsx index dda35826be06393c48cb57e594f1bf7fb2c25ad0..720e82f301fcaf8149d2312c46df72603d443520 100644 --- a/frontend/src/metabase/browse/components/BrowseModels.styled.tsx +++ b/frontend/src/metabase/browse/models/BrowseModels.styled.tsx @@ -7,7 +7,7 @@ import { Ellipsified } from "metabase/core/components/Ellipsified"; import Link from "metabase/core/components/Link"; import { Box, type ButtonProps, Collapse, Icon } from "metabase/ui"; -import { BrowseGrid } from "./BrowseContainer.styled"; +import { BrowseGrid } from "../components/BrowseContainer.styled"; export const ModelCardLink = styled(Link)` margin: 0.5rem 0; diff --git a/frontend/src/metabase/browse/models/BrowseModels.tsx b/frontend/src/metabase/browse/models/BrowseModels.tsx new file mode 100644 index 0000000000000000000000000000000000000000..afc510f1e4e6d03b490106f0d6448be8bf819945 --- /dev/null +++ b/frontend/src/metabase/browse/models/BrowseModels.tsx @@ -0,0 +1,209 @@ +import { useState } from "react"; +import { t } from "ttag"; + +import NoResults from "assets/img/no_results.svg"; +import { skipToken, useListRecentsQuery } from "metabase/api"; +import { useFetchModels } from "metabase/common/hooks/use-fetch-models"; +import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper"; +import { useSelector } from "metabase/lib/redux"; +import { + PLUGIN_COLLECTIONS, + PLUGIN_CONTENT_VERIFICATION, +} from "metabase/plugins"; +import { Box, Flex, Group, Icon, Stack, Title } from "metabase/ui"; + +import { + BrowseContainer, + BrowseHeader, + BrowseMain, + BrowseSection, + CenteredEmptyState, +} from "../components/BrowseContainer.styled"; + +import { ModelExplanationBanner } from "./ModelExplanationBanner"; +import { ModelsTable } from "./ModelsTable"; +import { RecentModels } from "./RecentModels"; +import type { ModelFilterSettings, ModelResult } from "./types"; +import { getMaxRecentModelCount, isRecentModel } from "./utils"; + +const { + contentVerificationEnabled, + ModelFilterControls, + getDefaultModelFilters, +} = PLUGIN_CONTENT_VERIFICATION; + +export const BrowseModels = () => { + const [modelFilters, setModelFilters] = useModelFilterSettings(); + const { isLoading, error, models, recentModels, hasVerifiedModels } = + useFilteredModels(modelFilters); + + const isEmpty = !isLoading && models.length === 0; + + return ( + <BrowseContainer> + <BrowseHeader role="heading" data-testid="browse-models-header"> + <BrowseSection> + <Flex + w="100%" + h="2.25rem" + direction="row" + justify="space-between" + align="center" + > + <Title order={1} color="text-dark"> + <Group spacing="sm"> + <Icon + size={24} + color="var(--mb-color-icon-primary)" + name="model" + /> + {t`Models`} + </Group> + </Title> + {hasVerifiedModels && ( + <ModelFilterControls + modelFilters={modelFilters} + setModelFilters={setModelFilters} + /> + )} + </Flex> + </BrowseSection> + </BrowseHeader> + <BrowseMain> + <BrowseSection> + <Stack mb="lg" spacing="md" w="100%"> + {isEmpty ? ( + <CenteredEmptyState + title={<Box mb=".5rem">{t`No models here yet`}</Box>} + message={ + <Box maw="24rem">{t`Models help curate data to make it easier to find answers to questions all in one place.`}</Box> + } + illustrationElement={ + <Box mb=".5rem"> + <img src={NoResults} /> + </Box> + } + /> + ) : ( + <> + <ModelExplanationBanner /> + <DelayedLoadingAndErrorWrapper + error={error} + loading={isLoading} + style={{ flex: 1 }} + loader={<RecentModels skeleton />} + > + <RecentModels models={recentModels} /> + </DelayedLoadingAndErrorWrapper> + <DelayedLoadingAndErrorWrapper + error={error} + loading={isLoading} + style={{ flex: 1 }} + loader={<ModelsTable skeleton />} + > + <ModelsTable models={models} /> + </DelayedLoadingAndErrorWrapper> + </> + )} + </Stack> + </BrowseSection> + </BrowseMain> + </BrowseContainer> + ); +}; + +function useModelFilterSettings() { + const defaultModelFilters = useSelector(getDefaultModelFilters); + return useState(defaultModelFilters); +} + +function useHasVerifiedModels() { + const result = useFetchModels( + contentVerificationEnabled + ? { + filter_items_in_personal_collection: "exclude", + model_ancestors: false, + limit: 0, + verified: true, + } + : skipToken, + ); + + if (!contentVerificationEnabled) { + return { + isLoading: false, + error: null, + result: false, + }; + } + + const total = result.data?.total ?? 0; + + return { + isLoading: result.isLoading, + error: result.error, + result: total > 0, + }; +} + +function useFilteredModels(modelFilters: ModelFilterSettings) { + const hasVerifiedModels = useHasVerifiedModels(); + + const filters = cleanModelFilters(modelFilters, hasVerifiedModels.result); + + const modelsResult = useFetchModels( + hasVerifiedModels.isLoading || hasVerifiedModels.error + ? skipToken + : { + filter_items_in_personal_collection: "exclude", + model_ancestors: false, + ...filters, + }, + ); + + const models = modelsResult.data?.data as ModelResult[] | undefined; + + const recentsCap = getMaxRecentModelCount(models?.length ?? 0); + + const recentModelsResult = useListRecentsQuery(undefined, { + refetchOnMountOrArgChange: true, + skip: recentsCap === 0, + }); + + const isLoading = + hasVerifiedModels.isLoading || + modelsResult.isLoading || + recentModelsResult.isLoading; + + const error = + hasVerifiedModels.error || modelsResult.error || recentModelsResult.error; + + return { + isLoading, + error, + hasVerifiedModels: hasVerifiedModels.result, + models: PLUGIN_COLLECTIONS.filterOutItemsFromInstanceAnalytics( + models ?? [], + ), + + recentModels: (recentModelsResult.data ?? []) + .filter(isRecentModel) + .filter( + model => !filters.verified || model.moderated_status === "verified", + ) + .slice(0, recentsCap), + }; +} + +function cleanModelFilters( + modelFilters: ModelFilterSettings, + hasVerifiedModels: boolean, +) { + const filters = { ...modelFilters }; + if (!hasVerifiedModels || !filters.verified) { + // we cannot pass false or undefined to the backend + // delete the key instead + delete filters.verified; + } + return filters; +} diff --git a/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx b/frontend/src/metabase/browse/models/BrowseModels.unit.spec.tsx similarity index 99% rename from frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx rename to frontend/src/metabase/browse/models/BrowseModels.unit.spec.tsx index e05f459ff2e5147c0f6b411fd317ef5667de8f08..d5d8969d015ed376d839d3a6d4b2d4201d613d91 100644 --- a/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx +++ b/frontend/src/metabase/browse/models/BrowseModels.unit.spec.tsx @@ -10,9 +10,8 @@ import { } from "metabase-types/api/mocks"; import { createMockSetupState } from "metabase-types/store/mocks"; -import { createMockModelResult, createMockRecentModel } from "../test-utils"; - import { BrowseModels } from "./BrowseModels"; +import { createMockModelResult, createMockRecentModel } from "./test-utils"; const defaultRootCollection = createMockCollection({ id: "root", diff --git a/frontend/src/metabase/browse/components/ModelExplanationBanner.tsx b/frontend/src/metabase/browse/models/ModelExplanationBanner.tsx similarity index 100% rename from frontend/src/metabase/browse/components/ModelExplanationBanner.tsx rename to frontend/src/metabase/browse/models/ModelExplanationBanner.tsx diff --git a/frontend/src/metabase/browse/components/ModelsTable.tsx b/frontend/src/metabase/browse/models/ModelsTable.tsx similarity index 96% rename from frontend/src/metabase/browse/components/ModelsTable.tsx rename to frontend/src/metabase/browse/models/ModelsTable.tsx index 815dd8619795274863ab907bac71b803fa17a9bf..8b4376eb5c7f2f4d92d1b211e72fc7726c06ffcb 100644 --- a/frontend/src/metabase/browse/components/ModelsTable.tsx +++ b/frontend/src/metabase/browse/models/ModelsTable.tsx @@ -24,18 +24,17 @@ import { FixedSizeIcon, Flex, Icon, Skeleton } from "metabase/ui"; import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; -import { trackModelClick } from "../analytics"; -import type { ModelResult } from "../types"; -import { getIcon } from "../utils"; - import { Cell, CollectionLink, CollectionTableCell, NameColumn, TableRow, -} from "./BrowseTable.styled"; -import { getModelDescription, sortModelOrMetric } from "./utils"; +} from "../components/BrowseTable.styled"; + +import { trackModelClick } from "./analytics"; +import type { ModelResult } from "./types"; +import { getIcon, getModelDescription, sortModels } from "./utils"; export interface ModelsTableProps { models?: ModelResult[]; @@ -69,7 +68,7 @@ export const ModelsTable = ({ ); const locale = useLocale(); - const sortedModels = sortModelOrMetric(models, sortingOptions, locale); + const sortedModels = sortModels(models, sortingOptions, locale); /** The name column has an explicitly set width. The remaining columns divide the remaining width. This is the percentage allocated to the collection column */ const collectionWidth = 38.5; diff --git a/frontend/src/metabase/browse/components/RecentModels.styled.tsx b/frontend/src/metabase/browse/models/RecentModels.styled.tsx similarity index 100% rename from frontend/src/metabase/browse/components/RecentModels.styled.tsx rename to frontend/src/metabase/browse/models/RecentModels.styled.tsx diff --git a/frontend/src/metabase/browse/components/RecentModels.tsx b/frontend/src/metabase/browse/models/RecentModels.tsx similarity index 96% rename from frontend/src/metabase/browse/components/RecentModels.tsx rename to frontend/src/metabase/browse/models/RecentModels.tsx index 66b750040c38d112f0bbb2856a4232e1954d98bf..c4aa34dd730b3218bb4e81c876a11ed914e8a78c 100644 --- a/frontend/src/metabase/browse/components/RecentModels.tsx +++ b/frontend/src/metabase/browse/models/RecentModels.tsx @@ -5,9 +5,8 @@ import { Box, Text } from "metabase/ui"; import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; import type { RecentCollectionItem } from "metabase-types/api"; -import { trackModelClick } from "../analytics"; - import { RecentModelsGrid } from "./RecentModels.styled"; +import { trackModelClick } from "./analytics"; export function RecentModels({ models = [], diff --git a/frontend/src/metabase/browse/models/analytics.ts b/frontend/src/metabase/browse/models/analytics.ts new file mode 100644 index 0000000000000000000000000000000000000000..f176dabfda3f97369627d3ea09e8b4696fafd501 --- /dev/null +++ b/frontend/src/metabase/browse/models/analytics.ts @@ -0,0 +1,8 @@ +import { trackSchemaEvent } from "metabase/lib/analytics"; +import type { CardId } from "metabase-types/api"; + +export const trackModelClick = (modelId: CardId) => + trackSchemaEvent("browse_data", { + event: "browse_data_model_clicked", + model_id: modelId, + }); diff --git a/frontend/src/metabase/browse/models/index.tsx b/frontend/src/metabase/browse/models/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ac8edc8ec027f65f3c0bfcfe615ac6576c40437 --- /dev/null +++ b/frontend/src/metabase/browse/models/index.tsx @@ -0,0 +1,7 @@ +export { BrowseModels } from "./BrowseModels"; +export type { + ModelFilterSettings, + ModelFilterControlsProps, + ModelResult, + RecentModel, +} from "./types"; diff --git a/frontend/src/metabase/browse/test-utils.ts b/frontend/src/metabase/browse/models/test-utils.ts similarity index 53% rename from frontend/src/metabase/browse/test-utils.ts rename to frontend/src/metabase/browse/models/test-utils.ts index 38ee0947246e2cabf0ed92458fb8c0b0d4a5e1d3..c3b0e8c7635df326386a8be0673f193d902d16ee 100644 --- a/frontend/src/metabase/browse/test-utils.ts +++ b/frontend/src/metabase/browse/models/test-utils.ts @@ -4,12 +4,7 @@ import { createMockSearchResult, } from "metabase-types/api/mocks"; -import type { - MetricResult, - ModelResult, - RecentMetric, - RecentModel, -} from "./types"; +import type { ModelResult, RecentModel } from "./types"; export const createMockModelResult = ( model: Partial<ModelResult> = {}, @@ -20,16 +15,3 @@ export const createMockRecentModel = ( model: Partial<RecentCollectionItem>, ): RecentModel => createMockRecentCollectionItem({ ...model, model: "dataset" }) as RecentModel; - -export const createMockMetricResult = ( - metric: Partial<MetricResult> = {}, -): MetricResult => - createMockSearchResult({ ...metric, model: "metric" }) as MetricResult; - -export const createMockRecentMetric = ( - metric: Partial<RecentMetric>, -): RecentMetric => - createMockRecentCollectionItem({ - ...metric, - model: "metric", - }) as RecentMetric; diff --git a/frontend/src/metabase/browse/models/types.tsx b/frontend/src/metabase/browse/models/types.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ac0b4db850aaf148d3489fbea0e9dc0d6fed4f71 --- /dev/null +++ b/frontend/src/metabase/browse/models/types.tsx @@ -0,0 +1,22 @@ +import type { RecentCollectionItem, SearchResult } from "metabase-types/api"; + +/** + * Model retrieved through the search endpoint + */ +export type ModelResult = SearchResult<number, "dataset">; + +/** + * Model retrieved through the recent views endpoint + */ +export interface RecentModel extends RecentCollectionItem { + model: "dataset"; +} + +export type ModelFilterSettings = { + verified?: boolean; +}; + +export type ModelFilterControlsProps = { + modelFilters: ModelFilterSettings; + setModelFilters: (settings: ModelFilterSettings) => void; +}; diff --git a/frontend/src/metabase/browse/models/utils.ts b/frontend/src/metabase/browse/models/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..e35852bc23e26f318d053a612b4a168613463414 --- /dev/null +++ b/frontend/src/metabase/browse/models/utils.ts @@ -0,0 +1,98 @@ +import { t } from "ttag"; +import _ from "underscore"; + +import { getCollectionPathAsString } from "metabase/collections/utils"; +import { entityForObject } from "metabase/lib/schema"; +import type { IconName } from "metabase/ui"; +import type { RecentItem, SearchResult } from "metabase-types/api"; +import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; + +import type { ModelResult, RecentModel } from "./types"; + +export const isModel = (item: SearchResult) => item.model === "dataset"; + +export const isRecentModel = (item: RecentItem): item is RecentModel => + item.model === "dataset"; + +export const getModelDescription = (item: ModelResult) => { + if (item.collection && !item.description?.trim()) { + return t`A model`; + } else { + return item.description; + } +}; + +const getValueForSorting = ( + model: ModelResult, + sort_column: keyof ModelResult, +): string => { + if (sort_column === "collection") { + return getCollectionPathAsString(model.collection) ?? ""; + } else { + return model[sort_column] ?? ""; + } +}; + +export const isValidSortColumn = ( + sort_column: string, +): sort_column is keyof ModelResult => { + return ["name", "collection", "description"].includes(sort_column); +}; + +export const getSecondarySortColumn = ( + sort_column: string, +): keyof ModelResult => { + return sort_column === "name" ? "collection" : "name"; +}; + +export function sortModels( + models: ModelResult[], + sortingOptions: SortingOptions, + localeCode: string = "en", +) { + const { sort_column, sort_direction } = sortingOptions; + + if (!isValidSortColumn(sort_column)) { + console.error("Invalid sort column", sort_column); + return models; + } + + const compare = (a: string, b: string) => + a.localeCompare(b, localeCode, { sensitivity: "base" }); + + return [...models].sort((modelA, modelB) => { + const a = getValueForSorting(modelA, sort_column); + const b = getValueForSorting(modelB, sort_column); + + let result = compare(a, b); + if (result === 0) { + const sort_column2 = getSecondarySortColumn(sort_column); + const a2 = getValueForSorting(modelA, sort_column2); + const b2 = getValueForSorting(modelB, sort_column2); + result = compare(a2, b2); + } + + return sort_direction === SortDirection.Asc ? result : -result; + }); +} + +/** Find the maximum number of recently viewed models to show. + * This is roughly proportional to the number of models the user + * has permission to see */ +export const getMaxRecentModelCount = ( + /** How many models the user has permission to see */ + modelCount: number, +) => { + if (modelCount > 20) { + return 8; + } + if (modelCount > 9) { + return 4; + } + return 0; +}; + +export const getIcon = (item: unknown): { name: IconName; color: string } => { + const entity = entityForObject(item); + return entity?.objectSelectors?.getIcon?.(item) || { name: "folder" }; +}; diff --git a/frontend/src/metabase/browse/models/utils.unit.spec.tsx b/frontend/src/metabase/browse/models/utils.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a33bd9014f67475091d0c78a21a38e1184eed25f --- /dev/null +++ b/frontend/src/metabase/browse/models/utils.unit.spec.tsx @@ -0,0 +1,200 @@ +import { createMockCollection } from "metabase-types/api/mocks"; +import { SortDirection } from "metabase-types/api/sorting"; + +import { createMockModelResult } from "./test-utils"; +import type { ModelResult } from "./types"; +import { getMaxRecentModelCount, sortModels } from "./utils"; + +describe("sortModels", () => { + let id = 0; + const modelMap: Record<string, ModelResult> = { + "model named A, with collection path X / Y / Z": createMockModelResult({ + id: id++, + name: "A", + collection: createMockCollection({ + name: "Z", + effective_ancestors: [ + createMockCollection({ name: "X" }), + createMockCollection({ name: "Y" }), + ], + }), + }), + "model named C, with collection path Y": createMockModelResult({ + id: id++, + name: "C", + collection: createMockCollection({ name: "Y" }), + }), + "model named B, with collection path D / E / F": createMockModelResult({ + id: id++, + name: "B", + collection: createMockCollection({ + name: "F", + effective_ancestors: [ + createMockCollection({ name: "D" }), + createMockCollection({ name: "E" }), + ], + }), + }), + }; + const mockSearchResults = Object.values(modelMap); + + it("can sort by name in ascending order", () => { + const sortingOptions = { + sort_column: "name", + sort_direction: SortDirection.Asc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted?.map(model => model.name)).toEqual(["A", "B", "C"]); + }); + + it("can sort by name in descending order", () => { + const sortingOptions = { + sort_column: "name", + sort_direction: SortDirection.Desc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted?.map(model => model.name)).toEqual(["C", "B", "A"]); + }); + + it("can sort by collection path in ascending order", () => { + const sortingOptions = { + sort_column: "collection", + sort_direction: SortDirection.Asc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted?.map(model => model.name)).toEqual(["B", "A", "C"]); + }); + + it("can sort by collection path in descending order", () => { + const sortingOptions = { + sort_column: "collection", + sort_direction: SortDirection.Desc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted?.map(model => model.name)).toEqual(["C", "A", "B"]); + }); + + describe("secondary sort", () => { + modelMap["model named C, with collection path Z"] = createMockModelResult({ + name: "C", + collection: createMockCollection({ name: "Z" }), + }); + modelMap["model named Bz, with collection path D / E / F"] = + createMockModelResult({ + name: "Bz", + collection: createMockCollection({ + name: "F", + effective_ancestors: [ + createMockCollection({ name: "D" }), + createMockCollection({ name: "E" }), + ], + }), + }); + const mockSearchResults = Object.values(modelMap); + + it("can sort by collection path, ascending, and then does a secondary sort by name", () => { + const sortingOptions = { + sort_column: "collection", + sort_direction: SortDirection.Asc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted).toEqual([ + modelMap["model named B, with collection path D / E / F"], + modelMap["model named Bz, with collection path D / E / F"], + modelMap["model named A, with collection path X / Y / Z"], + modelMap["model named C, with collection path Y"], + modelMap["model named C, with collection path Z"], + ]); + }); + + it("can sort by collection path, descending, and then does a secondary sort by name", () => { + const sortingOptions = { + sort_column: "collection", + sort_direction: SortDirection.Desc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted).toEqual([ + modelMap["model named C, with collection path Z"], + modelMap["model named C, with collection path Y"], + modelMap["model named A, with collection path X / Y / Z"], + modelMap["model named Bz, with collection path D / E / F"], + modelMap["model named B, with collection path D / E / F"], + ]); + }); + + it("can sort by collection path, ascending, and then does a secondary sort by name - with a localized sort order", () => { + const sortingOptions = { + sort_column: "collection", + sort_direction: SortDirection.Asc, + } as const; + + const addUmlauts = (model: ModelResult): ModelResult => ({ + ...model, + name: model.name.replace(/^B$/g, "Bä"), + collection: { + ...model.collection, + effective_ancestors: model.collection?.effective_ancestors?.map( + ancestor => ({ + ...ancestor, + name: ancestor.name.replace("X", "Ä"), + }), + ), + }, + }); + + const swedishModelMap = { + "model named A, with collection path Ä / Y / Z": addUmlauts( + modelMap["model named A, with collection path X / Y / Z"], + ), + "model named Bä, with collection path D / E / F": addUmlauts( + modelMap["model named B, with collection path D / E / F"], + ), + "model named Bz, with collection path D / E / F": addUmlauts( + modelMap["model named Bz, with collection path D / E / F"], + ), + "model named C, with collection path Y": addUmlauts( + modelMap["model named C, with collection path Y"], + ), + "model named C, with collection path Z": addUmlauts( + modelMap["model named C, with collection path Z"], + ), + }; + + const swedishResults = Object.values(swedishModelMap); + + // When sorting in Swedish, z comes before ä + const swedishLocaleCode = "sv"; + const sorted = sortModels( + swedishResults, + sortingOptions, + swedishLocaleCode, + ); + expect("ä".localeCompare("z", "sv", { sensitivity: "base" })).toEqual(1); + expect(sorted).toEqual([ + swedishModelMap["model named Bz, with collection path D / E / F"], // Model Bz sorts before Bä + swedishModelMap["model named Bä, with collection path D / E / F"], + swedishModelMap["model named C, with collection path Y"], + swedishModelMap["model named C, with collection path Z"], // Collection Z sorts before Ä + swedishModelMap["model named A, with collection path Ä / Y / Z"], + ]); + }); + }); +}); + +describe("getMaxRecentModelCount", () => { + it("returns 8 for modelCount greater than 20", () => { + expect(getMaxRecentModelCount(21)).toBe(8); + expect(getMaxRecentModelCount(100)).toBe(8); + }); + + it("returns 4 for modelCount greater than 9 and less than or equal to 20", () => { + expect(getMaxRecentModelCount(10)).toBe(4); + expect(getMaxRecentModelCount(20)).toBe(4); + }); + + it("returns 0 for modelCount of 9 or less", () => { + expect(getMaxRecentModelCount(0)).toBe(0); + expect(getMaxRecentModelCount(5)).toBe(0); + expect(getMaxRecentModelCount(9)).toBe(0); + }); +}); diff --git a/frontend/src/metabase/browse/components/BrowseSchemas.styled.tsx b/frontend/src/metabase/browse/schemas/BrowseSchemas.styled.tsx similarity index 100% rename from frontend/src/metabase/browse/components/BrowseSchemas.styled.tsx rename to frontend/src/metabase/browse/schemas/BrowseSchemas.styled.tsx diff --git a/frontend/src/metabase/browse/components/BrowseSchemas.tsx b/frontend/src/metabase/browse/schemas/BrowseSchemas.tsx similarity index 90% rename from frontend/src/metabase/browse/components/BrowseSchemas.tsx rename to frontend/src/metabase/browse/schemas/BrowseSchemas.tsx index b59c8cc706c87387f5c66df7918f85b557060bd6..1c9922ffec7042c180047c964516cc0d547302e4 100644 --- a/frontend/src/metabase/browse/components/BrowseSchemas.tsx +++ b/frontend/src/metabase/browse/schemas/BrowseSchemas.tsx @@ -16,12 +16,13 @@ import { BrowseContainer, BrowseMain, BrowseSection, -} from "./BrowseContainer.styled"; -import { BrowseDataHeader } from "./BrowseDataHeader"; -import { BrowseHeaderContent } from "./BrowseHeader.styled"; +} from "../components/BrowseContainer.styled"; +import { BrowseDataHeader } from "../components/BrowseDataHeader"; +import { BrowseHeaderContent } from "../components/BrowseHeader.styled"; + import { SchemaGridItem, SchemaLink } from "./BrowseSchemas.styled"; -const BrowseSchemas = ({ +const BrowseSchemasContainer = ({ schemas, params, }: { @@ -90,9 +91,8 @@ const BrowseSchemas = ({ ); }; -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default Schema.loadList({ +export const BrowseSchemas = Schema.loadList({ query: (state: any, { params: { slug } }: { params: { slug: string } }) => ({ dbId: Urls.extractEntityId(slug), }), -})(BrowseSchemas); +})(BrowseSchemasContainer); diff --git a/frontend/src/metabase/browse/schemas/index.tsx b/frontend/src/metabase/browse/schemas/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..36acba3f3707bad8613c4dc9beaed176e8d76db8 --- /dev/null +++ b/frontend/src/metabase/browse/schemas/index.tsx @@ -0,0 +1 @@ +export { BrowseSchemas } from "./BrowseSchemas"; diff --git a/frontend/src/metabase/browse/components/BrowseTables.tsx b/frontend/src/metabase/browse/tables/BrowseTables.tsx similarity index 81% rename from frontend/src/metabase/browse/components/BrowseTables.tsx rename to frontend/src/metabase/browse/tables/BrowseTables.tsx index 9007610cfe9664b7a824117684d4a0bfbddc6c10..409fe3104f0748c4cae9a091fd969e539441be04 100644 --- a/frontend/src/metabase/browse/components/BrowseTables.tsx +++ b/frontend/src/metabase/browse/tables/BrowseTables.tsx @@ -1,11 +1,10 @@ -import TableBrowser from "../containers/TableBrowser"; - import { BrowseContainer, BrowseMain, BrowseSection, -} from "./BrowseContainer.styled"; -import { BrowseDataHeader } from "./BrowseDataHeader"; +} from "../components/BrowseContainer.styled"; +import { BrowseDataHeader } from "../components/BrowseDataHeader"; +import TableBrowser from "../containers/TableBrowser"; export const BrowseTables = ({ params: { dbId, schemaName }, diff --git a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx b/frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.jsx similarity index 97% rename from frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx rename to frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.jsx index 468c9515960df0a248db5ef7f92e2ed7facb1103..60358f0368585130c36d0a1df5b33fe76d85f13b 100644 --- a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx +++ b/frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.jsx @@ -14,8 +14,8 @@ import { isVirtualCardId, } from "metabase-lib/v1/metadata/utils/saved-questions"; -import { trackTableClick } from "../../analytics"; -import { BrowseHeaderContent } from "../BrowseHeader.styled"; +import { BrowseHeaderContent } from "../../components/BrowseHeader.styled"; +import { trackTableClick } from "../analytics"; import { TableActionLink, diff --git a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.styled.tsx b/frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.styled.tsx similarity index 100% rename from frontend/src/metabase/browse/components/TableBrowser/TableBrowser.styled.tsx rename to frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.styled.tsx diff --git a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.unit.spec.js b/frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.unit.spec.js similarity index 100% rename from frontend/src/metabase/browse/components/TableBrowser/TableBrowser.unit.spec.js rename to frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.unit.spec.js diff --git a/frontend/src/metabase/browse/components/TableBrowser/index.js b/frontend/src/metabase/browse/tables/TableBrowser/index.js similarity index 100% rename from frontend/src/metabase/browse/components/TableBrowser/index.js rename to frontend/src/metabase/browse/tables/TableBrowser/index.js diff --git a/frontend/src/metabase/browse/analytics.ts b/frontend/src/metabase/browse/tables/analytics.ts similarity index 50% rename from frontend/src/metabase/browse/analytics.ts rename to frontend/src/metabase/browse/tables/analytics.ts index ffa3dcd1c64a1f2733a8588d998f23ace01ca83d..0e2fb444b7f90fa81a36dd04303f550c8af6a836 100644 --- a/frontend/src/metabase/browse/analytics.ts +++ b/frontend/src/metabase/browse/tables/analytics.ts @@ -1,11 +1,5 @@ import { trackSchemaEvent } from "metabase/lib/analytics"; -import type { CardId, ConcreteTableId } from "metabase-types/api"; - -export const trackModelClick = (modelId: CardId) => - trackSchemaEvent("browse_data", { - event: "browse_data_model_clicked", - model_id: modelId, - }); +import type { ConcreteTableId } from "metabase-types/api"; export const trackTableClick = (tableId: ConcreteTableId) => trackSchemaEvent("browse_data", { diff --git a/frontend/src/metabase/browse/tables/index.tsx b/frontend/src/metabase/browse/tables/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0d1921cbd3703f5c7f40a1e02c008b3f4bd7b389 --- /dev/null +++ b/frontend/src/metabase/browse/tables/index.tsx @@ -0,0 +1 @@ +export { BrowseTables } from "./BrowseTables"; diff --git a/frontend/src/metabase/browse/types.tsx b/frontend/src/metabase/browse/types.tsx deleted file mode 100644 index 7ecc3a879b6ab0dc61742e997a7a8746788412ee..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/types.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { - RecentCollectionItem, - RecentItem, - SearchResult, -} from "metabase-types/api"; - -/** Model retrieved through the search endpoint */ -export type ModelResult = SearchResult<number, "dataset">; - -/** Model retrieved through the recent views endpoint */ -export interface RecentModel extends RecentCollectionItem { - model: "dataset"; -} - -export const isRecentModel = (item: RecentItem): item is RecentModel => - item.model === "dataset"; - -/** A model retrieved through either endpoint. - * This type is needed so that our filtering functions can - * filter arrays of models retrieved from either endpoint. */ -export type FilterableModel = ModelResult | RecentModel; - -/** Metric retrieved through the search endpoint */ -export type MetricResult = SearchResult<number, "metric">; - -export interface RecentMetric extends RecentCollectionItem { - model: "metric"; -} diff --git a/frontend/src/metabase/browse/utils.ts b/frontend/src/metabase/browse/utils.ts deleted file mode 100644 index 4d0f22bf4100ba3d52fd3061a4e50a5e8e81e2d2..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/utils.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Dispatch, SetStateAction } from "react"; -import { t } from "ttag"; -import _ from "underscore"; - -import { - canonicalCollectionId, - coerceCollectionId, - isRootCollection, -} from "metabase/collections/utils"; -import { entityForObject } from "metabase/lib/schema"; -import type { IconName } from "metabase/ui"; -import type { CollectionEssentials } from "metabase-types/api"; - -import type { FilterableModel } from "./types"; - -export const getCollectionName = (collection: CollectionEssentials) => { - if (isRootCollection(collection)) { - return t`Our analytics`; - } - return collection?.name || t`Untitled collection`; -}; - -/** The root collection's id might be null or 'root' in different contexts. - * Use 'root' instead of null, for the sake of sorting */ -export const getCollectionIdForSorting = (collection: CollectionEssentials) => { - return coerceCollectionId(canonicalCollectionId(collection.id)); -}; - -export type AvailableModelFilters = Record< - string, - { - predicate: (value: FilterableModel) => boolean; - activeByDefault: boolean; - } ->; - -export type ModelFilterControlsProps = { - actualModelFilters: ActualModelFilters; - setActualModelFilters: Dispatch<SetStateAction<ActualModelFilters>>; -}; - -export type MetricFilterSettings = { - verified?: boolean; -}; - -export type MetricFilterControlsProps = { - metricFilters: MetricFilterSettings; - setMetricFilters: (settings: MetricFilterSettings) => void; -}; - -/** Mapping of filter names to true if the filter is active - * or false if it is inactive */ -export type ActualModelFilters = Record<string, boolean>; - -export const filterModels = <T extends FilterableModel>( - unfilteredModels: T[] | undefined, - actualModelFilters: ActualModelFilters, - availableModelFilters: AvailableModelFilters, -): T[] => { - return _.reduce( - actualModelFilters, - (acc, shouldFilterBeActive, filterName) => - shouldFilterBeActive - ? acc.filter(availableModelFilters[filterName].predicate) - : acc, - unfilteredModels || [], - ); -}; - -export const getIcon = (item: unknown): { name: IconName; color: string } => { - const entity = entityForObject(item); - return entity?.objectSelectors?.getIcon?.(item) || { name: "folder" }; -}; diff --git a/frontend/src/metabase/browse/utils.unit.spec.ts b/frontend/src/metabase/browse/utils.unit.spec.ts deleted file mode 100644 index 97e058c0684dc1680411f939ed3318bedd471e3d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/utils.unit.spec.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { defaultRootCollection } from "metabase/admin/permissions/pages/CollectionPermissionsPage/tests/setup"; -import type { SearchResult } from "metabase-types/api"; -import { createMockCollection } from "metabase-types/api/mocks"; - -import { createMockModelResult } from "./test-utils"; -import type { ModelResult } from "./types"; -import type { ActualModelFilters, AvailableModelFilters } from "./utils"; -import { filterModels } from "./utils"; - -const collectionAlpha = createMockCollection({ id: 0, name: "Alpha" }); -const collectionBeta = createMockCollection({ id: 1, name: "Beta" }); -const collectionCharlie = createMockCollection({ id: 2, name: "Charlie" }); -const collectionDelta = createMockCollection({ id: 3, name: "Delta" }); -const collectionZulu = createMockCollection({ id: 4, name: "Zulu" }); -const collectionAngstrom = createMockCollection({ id: 5, name: "Ångström" }); -const collectionOzgur = createMockCollection({ id: 6, name: "Özgür" }); - -const mockModels: ModelResult[] = [ - { - id: 0, - name: "Model 0", - collection: collectionAlpha, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-15T11:59:59.000Z", - }, - { - id: 1, - name: "Model 1", - collection: collectionAlpha, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-15T11:59:30.000Z", - }, - { - id: 2, - name: "Model 2", - collection: collectionAlpha, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-15T11:59:00.000Z", - }, - { - id: 3, - name: "Model 3", - collection: collectionBeta, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-15T11:50:00.000Z", - }, - { - id: 4, - name: "Model 4", - collection: collectionBeta, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-15T11:00:00.000Z", - }, - { - id: 5, - name: "Model 5", - collection: collectionBeta, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-14T22:00:00.000Z", - }, - { - id: 6, - name: "Model 6", - collection: collectionCharlie, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-14T12:00:00.000Z", - }, - { - id: 7, - name: "Model 7", - collection: collectionCharlie, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-10T12:00:00.000Z", - }, - { - id: 8, - name: "Model 8", - collection: collectionCharlie, - last_editor_common_name: "Bobby", - last_edited_at: "2024-11-15T12:00:00.000Z", - }, - { - id: 9, - name: "Model 9", - collection: collectionDelta, - last_editor_common_name: "Bobby", - last_edited_at: "2024-02-15T12:00:00.000Z", - }, - { - id: 10, - name: "Model 10", - collection: collectionDelta, - last_editor_common_name: "Bobby", - last_edited_at: "2023-12-15T12:00:00.000Z", - }, - { - id: 11, - name: "Model 11", - collection: collectionDelta, - last_editor_common_name: "Bobby", - last_edited_at: "2020-01-01T00:00:00.000Z", - }, - { - id: 12, - name: "Model 12", - collection: collectionZulu, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 13, - name: "Model 13", - collection: collectionZulu, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 14, - name: "Model 14", - collection: collectionZulu, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 15, - name: "Model 15", - collection: collectionAngstrom, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 16, - name: "Model 16", - collection: collectionAngstrom, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 17, - name: "Model 17", - collection: collectionAngstrom, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 18, - name: "Model 18", - collection: collectionOzgur, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 19, - name: "Model 19", - collection: collectionOzgur, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 20, - name: "Model 20", - collection: collectionOzgur, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 21, - name: "Model 20", - collection: defaultRootCollection, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 22, - name: "Model 21", - collection: defaultRootCollection, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, -].map(model => createMockModelResult(model)); - -describe("Browse utils", () => { - const diverseModels = mockModels.map((model, index) => ({ - ...model, - name: index % 2 === 0 ? `red ${index}` : `blue ${index}`, - moderated_status: index % 3 === 0 ? `good ${index}` : `bad ${index}`, - })); - const availableModelFilters: AvailableModelFilters = { - onlyRed: { - predicate: model => model.name.startsWith("red"), - activeByDefault: false, - }, - onlyGood: { - predicate: model => Boolean(model.moderated_status?.startsWith("good")), - activeByDefault: false, - }, - onlyBig: { - predicate: model => Boolean(model.description?.startsWith("big")), - activeByDefault: true, - }, - }; - - it("include a function that filters models, based on the object provided", () => { - const onlyRedAndGood: ActualModelFilters = { - onlyRed: true, - onlyGood: true, - onlyBig: false, - }; - const onlyRedAndGoodModels = filterModels( - diverseModels, - onlyRedAndGood, - availableModelFilters, - ); - const everySixthModel = diverseModels.reduce<SearchResult[]>( - (acc, model, index) => { - return index % 6 === 0 ? [...acc, model] : acc; - }, - [], - ); - // Since every other model is red and every third model is good, - // we expect every sixth model to be both red and good - expect(onlyRedAndGoodModels).toEqual(everySixthModel); - }); - - it("filterModels does not filter out models if no filters are active", () => { - const noActiveFilters: ActualModelFilters = { - onlyRed: false, - onlyGood: false, - onlyBig: false, - }; - const filteredModels = filterModels( - diverseModels, - noActiveFilters, - availableModelFilters, - ); - expect(filteredModels).toEqual(diverseModels); - }); -}); diff --git a/frontend/src/metabase/common/hooks/use-fetch-models.tsx b/frontend/src/metabase/common/hooks/use-fetch-models.tsx index 3a52f8feeef1f0b8d6b778a82eb20178dc7bcd36..931e4d9ca83a0c70b20cb97249df9ecb968a5617 100644 --- a/frontend/src/metabase/common/hooks/use-fetch-models.tsx +++ b/frontend/src/metabase/common/hooks/use-fetch-models.tsx @@ -1,12 +1,18 @@ -import { useSearchQuery } from "metabase/api"; +import { skipToken, useSearchQuery } from "metabase/api"; import type { SearchRequest } from "metabase-types/api"; -export const useFetchModels = (req: Partial<SearchRequest> = {}) => { - const modelsResult = useSearchQuery({ - models: ["dataset"], // 'model' in the sense of 'type of thing' - filter_items_in_personal_collection: "exclude", - model_ancestors: false, - ...req, - }); +export const useFetchModels = ( + req: Partial<SearchRequest> | typeof skipToken = {}, +) => { + const modelsResult = useSearchQuery( + req === skipToken + ? req + : { + models: ["dataset"], // 'model' in the sense of 'type of thing' + filter_items_in_personal_collection: "exclude", + model_ancestors: false, + ...req, + }, + ); return modelsResult; }; diff --git a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx index cec4a46cbf02b8bbd0d71537efffa055df6fb12a..65ae305a394ce75303b4f4533e84f7332c6f0337 100644 --- a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx +++ b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx @@ -15,8 +15,8 @@ import { waitForLoaderToBeRemoved, within, } from "__support__/ui"; -import { createMockModelResult } from "metabase/browse/test-utils"; -import type { ModelResult } from "metabase/browse/types"; +import type { ModelResult } from "metabase/browse/models"; +import { createMockModelResult } from "metabase/browse/models/test-utils"; import { ROOT_COLLECTION } from "metabase/entities/collections"; import * as Urls from "metabase/lib/urls"; import type { Card, Dashboard, DashboardId, User } from "metabase-types/api"; diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index f73face54ac2b97823d6de8eea27581aae700249..63a8579decebf1bd16e71127f91573e7b8b225de 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -25,12 +25,13 @@ import { } from "metabase/admin/permissions/types"; import type { ADMIN_SETTINGS_SECTIONS } from "metabase/admin/settings/selectors"; import type { - ActualModelFilters, - AvailableModelFilters, MetricFilterControlsProps, MetricFilterSettings, +} from "metabase/browse/metrics"; +import type { ModelFilterControlsProps, -} from "metabase/browse/utils"; + ModelFilterSettings, +} from "metabase/browse/models"; import { getIconBase } from "metabase/lib/icon"; import PluginPlaceholder from "metabase/plugins/components/PluginPlaceholder"; import type { SearchFilterComponent } from "metabase/search/types"; @@ -54,7 +55,6 @@ import type { GroupsPermissions, ModelCacheRefreshStatus, Revision, - SearchResult, User, UserListResult, } from "metabase-types/api"; @@ -506,21 +506,18 @@ export const PLUGIN_EMBEDDING = { }; export const PLUGIN_CONTENT_VERIFICATION = { + contentVerificationEnabled: false, VerifiedFilter: {} as SearchFilterComponent<"verified">, - availableModelFilters: {} as AvailableModelFilters, - ModelFilterControls: (() => null) as ComponentType<ModelFilterControlsProps>, - sortModelsByVerification: (_a: SearchResult, _b: SearchResult) => 0, sortCollectionsByVerification: ( _a: CollectionEssentials, _b: CollectionEssentials, ) => 0, - useModelFilterSettings: () => - [{}, _.noop] as [ - ActualModelFilters, - Dispatch<SetStateAction<ActualModelFilters>>, - ], - contentVerificationEnabled: false, + ModelFilterControls: (_props: ModelFilterControlsProps) => null, + getDefaultModelFilters: (_state: State): ModelFilterSettings => ({ + verified: false, + }), + getDefaultMetricFilters: (_state: State): MetricFilterSettings => ({ verified: false, }), diff --git a/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.unit.spec.tsx b/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.unit.spec.tsx index e0c55705ddb1e4c2344fdc66a11ff5fc2e4f9a62..66b2135c7bb11b6111cc1fb32dcb18ff84b531d1 100644 --- a/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.unit.spec.tsx +++ b/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.unit.spec.tsx @@ -16,13 +16,16 @@ import { waitForLoaderToBeRemoved, within, } from "__support__/ui"; +import type { RecentMetric } from "metabase/browse/metrics"; import { createMockMetricResult, - createMockModelResult, createMockRecentMetric, +} from "metabase/browse/metrics/test-utils"; +import type { RecentModel } from "metabase/browse/models"; +import { + createMockModelResult, createMockRecentModel, -} from "metabase/browse/test-utils"; -import type { RecentMetric, RecentModel } from "metabase/browse/types"; +} from "metabase/browse/models/test-utils"; import type { DataPickerValue } from "metabase/common/components/DataPicker"; import { checkNotNull } from "metabase/lib/types"; import type { IconName } from "metabase/ui"; diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 383d6822af8be6422c697cbb5e9f7ddfa0c8c824..0f133b7f557a6306a919948229cb14e5a5231fc1 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -9,6 +9,13 @@ 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 { + BrowseDatabases, + BrowseMetrics, + BrowseModels, + BrowseSchemas, + BrowseTables, +} from "metabase/browse"; import CollectionLanding from "metabase/collections/components/CollectionLanding"; import { MoveCollectionModal } from "metabase/collections/components/MoveCollectionModal"; import { TrashCollectionLanding } from "metabase/collections/components/TrashCollectionLanding"; @@ -50,11 +57,6 @@ import SearchApp from "metabase/search/containers/SearchApp"; import { Setup } from "metabase/setup/components/Setup"; import getCollectionTimelineRoutes from "metabase/timelines/collections/routes"; -import { BrowseDatabases } from "./browse/components/BrowseDatabases"; -import { BrowseMetrics } from "./browse/components/BrowseMetrics"; -import { BrowseModels } from "./browse/components/BrowseModels"; -import BrowseSchemas from "./browse/components/BrowseSchemas"; -import { BrowseTables } from "./browse/components/BrowseTables"; import { CanAccessMetabot, CanAccessSettings,