diff --git a/e2e/test/scenarios/question/saved.cy.spec.js b/e2e/test/scenarios/question/saved.cy.spec.js index 6354e0d54479c462d369c190b20ba5e013b7890b..304cbac69b13e49e13c78a9042463f5c110d3cbd 100644 --- a/e2e/test/scenarios/question/saved.cy.spec.js +++ b/e2e/test/scenarios/question/saved.cy.spec.js @@ -1,3 +1,4 @@ +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { ORDERS_COUNT_QUESTION_ID, ORDERS_QUESTION_ID, @@ -11,9 +12,11 @@ import { addSummaryGroupingField, appBar, collectionOnTheGoModal, + createQuestion, entityPickerModal, entityPickerModalTab, getAlertChannel, + main, modal, openNotebook, openOrdersTable, @@ -24,12 +27,15 @@ import { restore, rightSidebar, selectFilterOperator, + sidebar, sidesheet, summarize, tableHeaderClick, visitQuestion, } from "e2e/support/helpers"; +const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; + describe("scenarios > question > saved", () => { beforeEach(() => { restore(); @@ -317,6 +323,127 @@ describe("scenarios > question > saved", () => { ); }); }); + + describe("with hidden tables", () => { + beforeEach(() => { + cy.signInAsAdmin(); + }); + + const HIDDEN_TYPES = ["hidden", "technical", "cruft"]; + + function hideTable(name, visibilityType) { + cy.visit("/admin/datamodel"); + sidebar().findByText(name).click(); + main().findByText("Hidden").click(); + + if (visibilityType === "technical") { + main().findByText("Technical Data").click(); + } + if (visibilityType === "cruft") { + main().findByText("Irrelevant/Cruft").click(); + } + } + + HIDDEN_TYPES.forEach(visibilityType => { + it(`should show a View-only tag when the source table is marked as ${visibilityType}`, () => { + hideTable("Orders", visibilityType); + + visitQuestion(ORDERS_QUESTION_ID); + + queryBuilderHeader() + .findByText("View-only") + .should("be.visible") + .realHover(); + popover() + .findByText( + "One of the administrators hid the source table “Ordersâ€, making this question view-only.", + ) + .should("be.visible"); + }); + + it(`should show a View-only tag when a joined table is marked as ${visibilityType}`, () => { + cy.signInAsAdmin(); + hideTable("Products", visibilityType); + createQuestion( + { + name: "Joined question", + query: { + "source-table": ORDERS_ID, + joins: [ + { + "source-table": PRODUCTS_ID, + alias: "Orders", + condition: [ + "=", + ["field", ORDERS.PRODUCT_ID, null], + ["field", PRODUCTS.ID, { "join-alias": "Products" }], + ], + fields: "all", + }, + ], + }, + }, + { + visitQuestion: true, + }, + ); + queryBuilderHeader() + .findByText("View-only") + .should("be.visible") + .realHover(); + popover() + .findByText( + "One of the administrators hid the source table “Productsâ€, making this question view-only.", + ) + .should("be.visible"); + }); + }); + + function moveQuestionTo(newCollectionName, clickTab = false) { + openQuestionActions(); + cy.findByTestId("move-button").click(); + entityPickerModal().within(() => { + clickTab && cy.findByRole("tab", { name: /Collections/ }).click(); + cy.findByText(newCollectionName).click(); + cy.button("Move").click(); + }); + } + + it("should show a View-only tag when one of the source cards is unavailable", () => { + createQuestion( + { + name: "Products Question + Orders", + query: { + "source-table": `card__${ORDERS_QUESTION_ID}`, + joins: [ + { + "source-table": PRODUCTS_ID, + alias: "Orders Question", + fields: "all", + condition: [ + "=", + ["field", PRODUCTS.PRODUCT_ID, null], + ["field", ORDERS.ID, { "join-alias": "Orders" }], + ], + }, + ], + }, + }, + { + wrapId: true, + idAlias: "questionId", + }, + ); + + visitQuestion(ORDERS_QUESTION_ID); + moveQuestionTo(/Personal Collection/, true); + + cy.signInAsNormalUser(); + cy.get("@questionId").then(visitQuestion); + + queryBuilderHeader().findByText("View-only").should("be.visible"); + }); + }); }); //http://127.0.0.1:9080/api/session/00000000-0000-0000-0000-000000000000/requests diff --git a/frontend/src/metabase-lib/order_by.unit.spec.ts b/frontend/src/metabase-lib/order_by.unit.spec.ts index 5a49e657accbb526b76900bdeb61b19f26140db5..fb445e329a802d73a4285aef2a21dc4bcc1d0808 100644 --- a/frontend/src/metabase-lib/order_by.unit.spec.ts +++ b/frontend/src/metabase-lib/order_by.unit.spec.ts @@ -37,6 +37,7 @@ describe("order by", () => { longDisplayName: "Orders", isSourceTable: true, schema: "1:PUBLIC", + visibilityType: null, }, }), ); @@ -62,6 +63,7 @@ describe("order by", () => { longDisplayName: "Products", isSourceTable: false, schema: "1:PUBLIC", + visibilityType: null, }, }), ); diff --git a/frontend/src/metabase-lib/types.ts b/frontend/src/metabase-lib/types.ts index 55fc8c738295b95a1a710110b4df82e251e2f1c7..9afb6cf42eba530a21b3f26d396d142a6aad1e9c 100644 --- a/frontend/src/metabase-lib/types.ts +++ b/frontend/src/metabase-lib/types.ts @@ -7,6 +7,7 @@ import type { RowValue, SchemaId, TableId, + TableVisibilityType, TemporalUnit, } from "metabase-types/api"; @@ -135,6 +136,7 @@ export type TableDisplayInfo = { isQuestion?: boolean; isModel?: boolean; isMetric?: boolean; + visibilityType?: TableVisibilityType; }; export type CardDisplayInfo = TableDisplayInfo; diff --git a/frontend/src/metabase-lib/v1/metadata/Metadata.ts b/frontend/src/metabase-lib/v1/metadata/Metadata.ts index 4633a3e5597e57a675bb90a04b99385b9cc42fde..1968e941793fe401ab75feb84af4d7a34340d883 100644 --- a/frontend/src/metabase-lib/v1/metadata/Metadata.ts +++ b/frontend/src/metabase-lib/v1/metadata/Metadata.ts @@ -112,7 +112,7 @@ class Metadata { } /** - * @deprecated load data via RTK Query - useGetTableQuery or useGetTableMetadataQuery + * @deprecated load data via RTK Query - useGetTableQuery or useGetTableQueryMetadataQuery */ table(tableId: TableId | undefined | null): Table | null { return (tableId != null && this.tables[tableId]) || null; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/ViewTitleHeader.unit.spec.js b/frontend/src/metabase/query_builder/components/view/ViewHeader/ViewTitleHeader.unit.spec.js index e6ca3445d3f9b7e08ee71cd6f0d23ff392e38be4..b71668d8c8893bd5e7f6ef97b5eb4707b6ec3d89 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader/ViewTitleHeader.unit.spec.js +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/ViewTitleHeader.unit.spec.js @@ -12,7 +12,11 @@ import { COMMON_DATABASE_FEATURES } from "metabase-types/api/mocks"; import { ORDERS, ORDERS_ID, + PRODUCTS, + PRODUCTS_ID, SAMPLE_DB_ID, + createOrdersTable, + createProductsTable, createSampleDatabase, } from "metabase-types/api/mocks/presets"; import { createMockState } from "metabase-types/store/mocks"; @@ -20,6 +24,12 @@ import { createMockState } from "metabase-types/store/mocks"; import { ViewTitleHeader } from "./ViewTitleHeader"; console.warn = jest.fn(); +console.error = jest.fn(); + +const PRODUCTS_TABLE = createProductsTable(); +const HIDDEN_ORDERS_TABLE = createOrdersTable({ + visibility_type: "hidden", +}); const BASE_GUI_QUESTION = { display: "table", @@ -105,6 +115,7 @@ function setup({ isAdditionalInfoVisible = true, isDirty = false, isRunnable = true, + hideOrdersTable = false, ...props } = {}) { mockSettings(settings); @@ -126,6 +137,9 @@ function setup({ entities: createMockEntitiesState({ databases: [database], questions: [card], + tables: hideOrdersTable + ? [PRODUCTS_TABLE, HIDDEN_ORDERS_TABLE] + : undefined, }), }); @@ -185,6 +199,10 @@ function setupSavedNative(props = {}) { } describe("ViewTitleHeader", () => { + beforeEach(() => { + fetchMock.reset(); + }); + const TEST_CASE = { SAVED_GUI_QUESTION: { card: getSavedGUIQuestionCard(), @@ -573,3 +591,72 @@ describe("View Header | Read only permissions", () => { expect(screen.queryByTestId("saved-question-header-title")).toBeDisabled(); }); }); + +describe("View Header | Hidden tables", () => { + it("should show the View-only badge when the source table is hidden", async () => { + setup({ + hideOrdersTable: true, + card: getSavedGUIQuestionCard({ can_write: false }), + }); + expect(await screen.findByText("View-only")).toBeInTheDocument(); + }); + + it("should show the View-only badge when a joined table is hidden", async () => { + setup({ + hideOrdersTable: true, + card: getSavedGUIQuestionCard({ + can_write: false, + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": PRODUCTS_ID, + joins: [ + { + alias: "Orders", + fields: "all", + "source-table": ORDERS_ID, + condition: [ + "=", + ["field", PRODUCTS.ID, null], + ["field", ORDERS.PRODUCT_ID, null], + ], + }, + ], + }, + }, + }), + }); + expect(await screen.findByText("View-only")).toBeInTheDocument(); + }); +}); + +describe("View Header | Inaccessible Cards", () => { + it("should show the View-only badge when the source question is inaccessible", async () => { + setup({ + card: getSavedGUIQuestionCard({ + can_write: false, + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": "card_123", + joins: [ + { + alias: "Orders", + fields: "all", + "source-table": ORDERS_ID, + condition: [ + "=", + ["field", PRODUCTS.ID, null], + ["field", ORDERS.PRODUCT_ID, null], + ], + }, + ], + }, + }, + }), + }); + expect(await screen.findByText("View-only")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.module.css b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.module.css new file mode 100644 index 0000000000000000000000000000000000000000..92956617bdd84a4300ddf1693ec8ba8e1c727d5e --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.module.css @@ -0,0 +1,5 @@ +.badge { + background: var(--mb-color-bg-medium); + border-radius: 0.25rem; + user-select: none; +} diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.tsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.tsx index eab493ce947d142c6be7d45c20c5282b207df5f0..00e69f244a179188290cf3dce8f112d0315d5c82 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.tsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/SavedQuestionLeftSide.tsx @@ -11,12 +11,15 @@ import { ViewHeaderLeftSubHeading, ViewHeaderMainLeftContentContainer, } from "metabase/query_builder/components/view/ViewHeader/ViewTitleHeader.styled"; +import { Flex } from "metabase/ui"; import type Question from "metabase-lib/v1/Question"; import { HeadBreadcrumbs } from "../HeaderBreadcrumbs"; import { HeaderCollectionBadge } from "../HeaderCollectionBadge"; import { QuestionDataSource } from "../QuestionDataSource"; +import { ViewOnlyTag } from "./ViewOnly"; + interface SavedQuestionLeftSideProps { question: Question; isObjectDetail?: boolean; @@ -68,25 +71,29 @@ export function SavedQuestionLeftSide({ > <ViewHeaderMainLeftContentContainer> <SavedQuestionHeaderButtonContainer isModelOrMetric={isModelOrMetric}> - <HeadBreadcrumbs - divider={<HeaderDivider>/</HeaderDivider>} - parts={[ - ...(isAdditionalInfoVisible && isModelOrMetric - ? [ - <HeaderCollectionBadge - key="collection" - question={question} - />, - ] - : []), + <Flex align="center" gap="sm"> + <HeadBreadcrumbs + divider={<HeaderDivider>/</HeaderDivider>} + parts={[ + ...(isAdditionalInfoVisible && isModelOrMetric + ? [ + <HeaderCollectionBadge + key="collection" + question={question} + />, + ] + : []), - <SavedQuestionHeaderButton - key={question.displayName()} - question={question} - onSave={onHeaderChange} - />, - ]} - /> + <SavedQuestionHeaderButton + key={question.displayName()} + question={question} + onSave={onHeaderChange} + />, + ]} + /> + + <ViewOnlyTag question={question} /> + </Flex> </SavedQuestionHeaderButtonContainer> </ViewHeaderMainLeftContentContainer> {isAdditionalInfoVisible && ( diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/ViewOnly.tsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/ViewOnly.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4de8c0cbbd665bb257cc61da40da835cc1fbfc0 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/ViewOnly.tsx @@ -0,0 +1,38 @@ +import { t } from "ttag"; + +import { Flex, HoverCard, Icon, Text, rem } from "metabase/ui"; +import * as Lib from "metabase-lib"; +import type Question from "metabase-lib/v1/Question"; + +import CS from "./SavedQuestionLeftSide.module.css"; +import { useHiddenSourceTables } from "./hooks"; + +export function ViewOnlyTag({ question }: { question: Question }) { + const { isEditable } = Lib.queryDisplayInfo(question.query()); + const hiddenSourceTables = useHiddenSourceTables(question); + + if (isEditable) { + return null; + } + + const tableName = hiddenSourceTables[0]?.displayName; + + return ( + <HoverCard position="bottom-start" disabled={!tableName}> + <HoverCard.Target> + <Flex align="center" gap="xs" px={4} py={2} mt={4} className={CS.badge}> + <Icon name="lock_filled" size={12} /> + <Text size="xs" fw="bold"> + {t`View-only`} + </Text> + </Flex> + </HoverCard.Target> + <HoverCard.Dropdown> + <Text + maw={rem(360)} + p="md" + >{t`One of the administrators hid the source table “${tableName}â€, making this question view-only.`}</Text> + </HoverCard.Dropdown> + </HoverCard> + ); +} diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/ViewOnly.unit.spec.tsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/ViewOnly.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..585a3469d8383f3d182c4ebd6ea939f3c2de8a9e --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/ViewOnly.unit.spec.tsx @@ -0,0 +1,297 @@ +import userEvent from "@testing-library/user-event"; + +import { createMockEntitiesState } from "__support__/store"; +import { renderWithProviders, screen } from "__support__/ui"; +import { getMetadata } from "metabase/selectors/metadata"; +import * as Lib from "metabase-lib"; +import { createQuery } from "metabase-lib/test-helpers"; +import Question from "metabase-lib/v1/Question"; +import type { Card, Database, Table } from "metabase-types/api"; +import { createMockCard } from "metabase-types/api/mocks"; +import { + ORDERS, + ORDERS_ID, + PRODUCTS, + PRODUCTS_ID, + SAMPLE_DB_ID, + createOrdersTable, + createProductsTable, + createSampleDatabase, +} from "metabase-types/api/mocks/presets"; +import { createMockState } from "metabase-types/store/mocks"; + +import { ViewOnlyTag } from "./ViewOnly"; + +type SetupOpts = { + card: Card; + database?: Database; + tables?: Table[]; + questions?: Card[]; +}; + +function setup({ + card, + tables, + database = createSampleDatabase(), + questions = [], +}: SetupOpts) { + console.warn = jest.fn(); + + const storeInitialState = createMockState({ + entities: createMockEntitiesState({ + databases: [database], + questions: [...questions, card], + tables, + }), + }); + + const metadata = getMetadata(storeInitialState); + const isSaved = card.id != null; + const question = isSaved + ? metadata.question(card.id) + : new Question(card, metadata); + + if (!question) { + throw new Error("question is null"); + } + + renderWithProviders( + <div> + <ViewOnlyTag question={question} /> + </div>, + { + storeInitialState, + }, + ); +} + +async function expectNoPopover() { + userEvent.hover(screen.getByText("View-only")); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); +} + +async function expectPopoverToHaveText(text: string) { + userEvent.hover(screen.getByText("View-only")); + const dialog = await screen.findByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveTextContent(text); +} + +const HIDDEN_VISIBILITY_TYPES = ["hidden", "technical", "cruft"] as const; + +const ORDERS_QUERY = (function () { + const query = createQuery({ databaseId: SAMPLE_DB_ID }); + + const availableColumns = Lib.fieldableColumns(query, -1); + const columns = availableColumns.filter(column => { + const info = Lib.displayInfo(query, -1, column); + return info.table?.name === "ORDERS" || info.table?.name === "PRODUCTS"; + }); + + return Lib.withFields(query, -1, columns); +})(); + +const ORDERS_JOIN_PRODUCTS_QUERY = (function () { + let query = createQuery({ databaseId: SAMPLE_DB_ID }); + const joinTable = Lib.tableOrCardMetadata(query, PRODUCTS_ID); + + query = Lib.join( + query, + -1, + Lib.joinClause( + joinTable, + [ + Lib.joinConditionClause( + query, + -1, + Lib.joinConditionOperators(query, -1)[0], + Lib.joinConditionLHSColumns(query, -1)[0], + Lib.joinConditionRHSColumns(query, -1, joinTable)[0], + ), + ], + Lib.availableJoinStrategies(query, -1)[0], + ), + ); + + return query; +})(); + +function createCardFromQuery({ + query, + ...rest +}: Partial<Card> & { query: Lib.Query }): Card { + return createMockCard({ + ...rest, + dataset_query: Lib.toLegacyQuery(query), + }); +} + +describe("ViewOnlyTag", () => { + describe("cards", () => { + it("should show the View-only badge when the source card is inaccessible", () => { + setup({ + card: createCardFromQuery({ + query: createQuery({ + databaseId: SAMPLE_DB_ID, + query: { + database: SAMPLE_DB_ID, + type: "query", + query: { + "source-table": "card__123", + }, + }, + }), + }), + }); + + expect(screen.getByText("View-only")).toBeInTheDocument(); + expectNoPopover(); + }); + + it("should show the View-only badge when a joined card is inaccessible", () => { + setup({ + card: createCardFromQuery({ + query: createQuery({ + query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + joins: [ + { + alias: "Orders Question", + fields: "all", + // This card does not exist + "source-table": "card__123", + condition: [ + "=", + ["field", PRODUCTS.ID, null], + ["field", ORDERS.PRODUCT_ID, null], + ], + }, + ], + }, + }, + }), + }), + }); + + expect(screen.getByText("View-only")).toBeInTheDocument(); + expectNoPopover(); + }); + + it("should not show the View-only badge when the source card is accessible", () => { + const sourceCard = createMockCard({ + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + }, + }, + }); + setup({ + questions: [sourceCard], + card: createMockCard({ + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + // This card does not exist + "source-table": `card__${sourceCard.id}`, + }, + }, + }), + }); + + expect(screen.queryByText("View-only")).not.toBeInTheDocument(); + }); + + it("should not show the View-only badge when the joined card is accessible", () => { + const sourceCard = createMockCard({ + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": PRODUCTS_ID, + }, + }, + }); + setup({ + card: createMockCard({ + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + joins: [ + { + alias: "Orders Question", + fields: "all", + "source-table": `card__${sourceCard.id}`, + condition: [ + "=", + ["field", PRODUCTS.ID, null], + ["field", ORDERS.PRODUCT_ID, null], + ], + }, + ], + }, + }, + }), + }); + + expect(screen.queryByText("View-only")).not.toBeInTheDocument(); + }); + }); + + describe("tables", () => { + for (const visibility_type of HIDDEN_VISIBILITY_TYPES) { + it(`should show the View-only badge when the source table is ${visibility_type}`, async () => { + setup({ + card: createCardFromQuery({ query: ORDERS_JOIN_PRODUCTS_QUERY }), + tables: [ + createOrdersTable({ visibility_type }), + createProductsTable({ visibility_type: null }), + ], + }); + + expect(screen.getByText("View-only")).toBeInTheDocument(); + await expectPopoverToHaveText( + "One of the administrators hid the source table “Ordersâ€, making this question view-only.", + ); + }); + + it(`should show the View-only badge when a joined table is ${visibility_type}`, async () => { + setup({ + card: createCardFromQuery({ query: ORDERS_JOIN_PRODUCTS_QUERY }), + tables: [ + createOrdersTable({ visibility_type: null }), + createProductsTable({ visibility_type }), + ], + }); + + expect(screen.getByText("View-only")).toBeInTheDocument(); + await expectPopoverToHaveText( + "One of the administrators hid the source table “Productsâ€, making this question view-only.", + ); + }); + } + }); + + describe("implicit joins", () => { + for (const visibility_type of HIDDEN_VISIBILITY_TYPES) { + it(`should not show the View-only badge when an implictly joined table is ${visibility_type}`, async () => { + setup({ + card: createCardFromQuery({ query: ORDERS_QUERY }), + tables: [ + createOrdersTable({ visibility_type: null }), + createProductsTable({ visibility_type }), + ], + }); + + expect(screen.queryByText("View-only")).not.toBeInTheDocument(); + }); + } + }); +}); diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/hooks.ts b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/hooks.ts new file mode 100644 index 0000000000000000000000000000000000000000..02022ac279430d457b2656a98e6babc8d31f33ed --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/SavedQuestionLeftSide/hooks.ts @@ -0,0 +1,47 @@ +import { useSelector } from "metabase/lib/redux"; +import { getMetadataUnfiltered } from "metabase/selectors/metadata"; +import * as Lib from "metabase-lib"; +import type Question from "metabase-lib/v1/Question"; + +export function useHiddenSourceTables( + question: Question, +): Lib.TableDisplayInfo[] { + const datasetQuery = question.datasetQuery(); + const metadata = useSelector(getMetadataUnfiltered); + const metadataProvider = Lib.metadataProvider( + datasetQuery.database, + metadata, + ); + const query = Lib.fromLegacyQuery( + datasetQuery.database, + metadataProvider, + datasetQuery, + ); + + const sourceTableId = Lib.sourceTableOrCardId(query); + + const joinTablesInfo = Lib.stageIndexes(query).flatMap(stageIndex => + Lib.joins(query, stageIndex) + .map(join => Lib.joinedThing(query, join)) + .filter(joinTable => joinTable != null) + .map(joinTable => Lib.displayInfo(query, stageIndex, joinTable)), + ); + + if (sourceTableId) { + const sourceTableMetadata = Lib.tableOrCardMetadata( + metadataProvider, + sourceTableId, + ); + if (sourceTableMetadata) { + const sourceTableInfo = Lib.displayInfo(query, -1, sourceTableMetadata); + joinTablesInfo.unshift(sourceTableInfo); + } + } + + return joinTablesInfo.filter( + tableInfo => + !tableInfo.isSourceTable || + (tableInfo.visibilityType !== null && + tableInfo.visibilityType !== "normal"), + ); +} diff --git a/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts b/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts index 8ca44a9d5bd9039a9affeabde2ee3be3bf57ab7e..85371df4459d08bbeb9d7e8ec4bd52d9731f9d03 100644 --- a/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts +++ b/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts @@ -231,6 +231,8 @@ import location_component from "./location.svg?component"; import location_source from "./location.svg?source"; import lock_component from "./lock.svg?component"; import lock_source from "./lock.svg?source"; +import lock_filled_component from "./lock_filled.svg?component"; +import lock_filled_source from "./lock_filled.svg?source"; import mail_component from "./mail.svg?component"; import mail_source from "./mail.svg?source"; import mail_filled_component from "./mail_filled.svg?component"; @@ -859,6 +861,10 @@ export const Icons = { component: lock_component, source: lock_source, }, + lock_filled: { + component: lock_filled_component, + source: lock_filled_source, + }, mail: { component: mail_component, source: mail_source, diff --git a/frontend/src/metabase/ui/components/icons/Icon/icons/lock_filled.svg b/frontend/src/metabase/ui/components/icons/Icon/icons/lock_filled.svg new file mode 100644 index 0000000000000000000000000000000000000000..121de17eb1a8a4143e6823f76e8c50a513527920 --- /dev/null +++ b/frontend/src/metabase/ui/components/icons/Icon/icons/lock_filled.svg @@ -0,0 +1,3 @@ +<svg viewBox="0 0 12 12" fill="currentcolor" stroke="currentColor" clip-rule="evenodd" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <path d="M3.1875 5.9375H3.6875V5.4375V3.75C3.6875 2.47284 4.72284 1.4375 6 1.4375C7.27718 1.4375 8.3125 2.47284 8.3125 3.75V5.4375V5.9375H8.8125H9C9.44873 5.9375 9.8125 6.30127 9.8125 6.75V9.75C9.8125 10.1987 9.44873 10.5625 9 10.5625H3C2.55127 10.5625 2.1875 10.1987 2.1875 9.75V6.75C2.1875 6.30127 2.55127 5.9375 3 5.9375H3.1875ZM7.6875 5.9375H8.1875V5.4375V3.75C8.1875 2.54188 7.20812 1.5625 6 1.5625C4.79188 1.5625 3.8125 2.54188 3.8125 3.75V5.4375V5.9375H4.3125H7.6875Z" /> +</svg> diff --git a/src/metabase/lib/metadata/calculation.cljc b/src/metabase/lib/metadata/calculation.cljc index 536160f7993e4d9ac273c3cd00de940b56ff5848..e43e40799d21e44fb69c70ca6858061bf5ae0b0a 100644 --- a/src/metabase/lib/metadata/calculation.cljc +++ b/src/metabase/lib/metadata/calculation.cljc @@ -399,7 +399,8 @@ [query stage-number table] (merge (default-display-info query stage-number table) {:is-source-table (= (lib.util/source-table-id query) (:id table)) - :schema (:schema table)})) + :schema (:schema table) + :visibility-type (:visibility-type table)})) (def ColumnMetadataWithSource "Schema for the column metadata that should be returned by [[metadata]]."