diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index 13946c9b0856d623359009330fc9bc5dfc51d141..d501137b5709bf5c0bf70884741712e8545d8583 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -361,6 +361,9 @@ metabase.query-processor.timezone qp.timezone metabase.query-processor.util qp.util metabase.related related + metabase.search.config search.config + metabase.search.filter search.filter + metabase.search.util search.util metabase.search.scoring scoring metabase.server.middleware.auth mw.auth metabase.server.middleware.browser-cookie mw.browser-cookie diff --git a/e2e/test/scenarios/models/model-indexes.cy.spec.js b/e2e/test/scenarios/models/model-indexes.cy.spec.js index 73584aeb287bfdd0e782bc2815da4f7ff05657cc..c377ba0140b3667e511fcaae3e24d50656997f3d 100644 --- a/e2e/test/scenarios/models/model-indexes.cy.spec.js +++ b/e2e/test/scenarios/models/model-indexes.cy.spec.js @@ -126,7 +126,9 @@ describe("scenarios > model indexes", () => { .findByPlaceholderText("Search…") .type("marble shoes"); - cy.findByTestId("search-results-list").findByText(/didn't find anything/i); + cy.wait("@searchQuery"); + + cy.findByTestId("search-results-empty-state").should("be.visible"); }); it("should be able to search model index values and visit detail records", () => { diff --git a/e2e/test/scenarios/models/models.cy.spec.js b/e2e/test/scenarios/models/models.cy.spec.js index 8b11971c8825a36af635c3e83bb79c5ef83c5e84..6e7070764b8d3a9a2fe9fc897a041fcafd6b11ea 100644 --- a/e2e/test/scenarios/models/models.cy.spec.js +++ b/e2e/test/scenarios/models/models.cy.spec.js @@ -610,9 +610,38 @@ function testDataPickerSearch({ cy.findByPlaceholderText(inputPlaceholderText).type(query); cy.wait("@search"); - cy.findAllByText(/Model in/i).should(models ? "exist" : "not.exist"); - cy.findAllByText(/Saved question in/i).should(cards ? "exist" : "not.exist"); - cy.findAllByText(/Table in/i).should(tables ? "exist" : "not.exist"); + const searchResultItems = cy.findAllByTestId("search-result-item"); + + searchResultItems.then($results => { + const modelTypes = {}; + + for (const htmlElement of $results.toArray()) { + const type = htmlElement.getAttribute("data-model-type"); + if (type in modelTypes) { + modelTypes[type] += 1; + } else { + modelTypes[type] = 1; + } + } + + if (models) { + expect(modelTypes["dataset"]).to.be.greaterThan(0); + } else { + expect(Object.keys(modelTypes)).not.to.include("dataset"); + } + + if (cards) { + expect(modelTypes["card"]).to.be.greaterThan(0); + } else { + expect(Object.keys(modelTypes)).not.to.include("card"); + } + + if (tables) { + expect(modelTypes["table"]).to.be.greaterThan(0); + } else { + expect(Object.keys(modelTypes)).not.to.include("table"); + } + }); cy.icon("close").click(); } diff --git a/e2e/test/scenarios/onboarding/search/recently-viewed.cy.spec.js b/e2e/test/scenarios/onboarding/search/recently-viewed.cy.spec.js index 4627d94b30b3acdeb028343a2df802d62e61fc28..4e22e2deec6837f7912e509eaf1f9abbc7e15f3d 100644 --- a/e2e/test/scenarios/onboarding/search/recently-viewed.cy.spec.js +++ b/e2e/test/scenarios/onboarding/search/recently-viewed.cy.spec.js @@ -7,15 +7,11 @@ import { setTokenFeatures, } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; -import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { ORDERS_QUESTION_ID, ORDERS_DASHBOARD_ID, } from "e2e/support/cypress_sample_instance_data"; -const { PEOPLE_ID } = SAMPLE_DATABASE; - describe("search > recently viewed", () => { beforeEach(() => { restore(); @@ -44,24 +40,9 @@ describe("search > recently viewed", () => { }); it("shows list of recently viewed items", () => { - assertRecentlyViewedItem( - 0, - "Orders in a dashboard", - "Dashboard", - `/dashboard/${ORDERS_DASHBOARD_ID}-orders-in-a-dashboard`, - ); - assertRecentlyViewedItem( - 1, - "Orders", - "Question", - `/question/${ORDERS_QUESTION_ID}-orders`, - ); - assertRecentlyViewedItem( - 2, - "People", - "Table", - `/question#?db=${SAMPLE_DB_ID}&table=${PEOPLE_ID}`, - ); + assertRecentlyViewedItem(0, "Orders in a dashboard", "Dashboard"); + assertRecentlyViewedItem(1, "Orders", "Question"); + assertRecentlyViewedItem(2, "People", "Table"); }); it("allows to select an item from keyboard", () => { @@ -95,25 +76,15 @@ describeEE("search > recently viewed > enterprise features", () => { it("should show verified badge in the 'Recently viewed' list (metabase#18021)", () => { cy.findByPlaceholderText("Search…").click(); - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("Recently viewed") - .parent() - .within(() => { - cy.findByText("Orders").closest("a").find(".Icon-verified"); - }); + cy.findByTestId("recently-viewed-item").within(() => { + cy.icon("verified_filled").should("be.visible"); + }); }); }); -const assertRecentlyViewedItem = (index, title, type, link) => { - cy.findAllByTestId("recently-viewed-item") - .eq(index) - .parent() - .should("have.attr", "href", link); - +const assertRecentlyViewedItem = (index, title, type) => { cy.findAllByTestId("recently-viewed-item-title") .eq(index) .should("have.text", title); - cy.findAllByTestId("recently-viewed-item-type") - .eq(index) - .should("have.text", type); + cy.findAllByTestId("result-link-wrapper").eq(index).should("have.text", type); }; diff --git a/e2e/test/scenarios/onboarding/search/search.cy.spec.js b/e2e/test/scenarios/onboarding/search/search.cy.spec.js index 2df30ce7d25a29d5d091c99dc0514a9628c6f70a..aeb10033b41e7639468d6a2ed13e6528a39b7321 100644 --- a/e2e/test/scenarios/onboarding/search/search.cy.spec.js +++ b/e2e/test/scenarios/onboarding/search/search.cy.spec.js @@ -1,82 +1,130 @@ import { createAction, + describeEE, describeWithSnowplow, enableTracking, expectGoodSnowplowEvents, expectNoBadSnowplowEvents, + modal, popover, resetSnowplow, restore, setActionsEnabledForDB, + setTokenFeatures, + summarize, } from "e2e/support/helpers"; -import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data"; +import { + ADMIN_USER_ID, + NORMAL_USER_ID, + ORDERS_COUNT_QUESTION_ID, + ORDERS_QUESTION_ID, +} from "e2e/support/cypress_sample_instance_data"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const typeFilters = [ { label: "Question", - filterName: "card", - resultInfoText: "Saved question in", + type: "card", }, { label: "Dashboard", - filterName: "dashboard", - resultInfoText: "Dashboard in", + type: "dashboard", }, { label: "Collection", - filterName: "collection", - resultInfoText: "Collection", + type: "collection", }, { label: "Table", - filterName: "table", - resultInfoText: "Table in", + type: "table", }, { label: "Database", - filterName: "database", - resultInfoText: "Database", + type: "database", }, { label: "Model", - filterName: "dataset", - resultInfoText: "Model in", + type: "dataset", }, { label: "Action", - filterName: "action", + type: "action", }, ]; const { ORDERS_ID } = SAMPLE_DATABASE; +const NORMAL_USER_TEST_QUESTION = { + name: `Robert's Super Duper Reviews`, + query: { "source-table": ORDERS_ID, limit: 1 }, + collection_id: null, +}; + +const ADMIN_TEST_QUESTION = { + name: `Admin Super Duper Reviews`, + query: { "source-table": ORDERS_ID, limit: 1 }, + collection_id: null, +}; + +// Using these names in the `last_edited_by` section to reduce confusion +const LAST_EDITED_BY_ADMIN_QUESTION = NORMAL_USER_TEST_QUESTION; +const LAST_EDITED_BY_NORMAL_USER_QUESTION = ADMIN_TEST_QUESTION; + +const REVIEWS_TABLE_NAME = "Reviews"; + +const TEST_NATIVE_QUESTION_NAME = "GithubUptimeisMagnificentlyHigh"; + +const TEST_CREATED_AT_FILTERS = [ + ["Today", "thisday"], + ["Yesterday", "past1days"], + ["Previous Week", "past1weeks"], + ["Previous 7 Days", "past7days"], + ["Previous 30 Days", "past30days"], + ["Previous Month", "past1months"], + ["Previous 3 Months", "past3months"], + ["Previous 12 Months", "past12months"], +]; + describe("scenarios > search", () => { beforeEach(() => { restore(); cy.intercept("GET", "/api/search?q=*").as("search"); + cy.signInAsAdmin(); }); describe("universal search", () => { it("should work for admin (metabase#20018)", () => { - cy.signInAsAdmin(); - cy.visit("/"); getSearchBar().as("searchBox").type("product").blur(); - cy.findByTestId("search-results-list").within(() => { - getProductsSearchResults(); + cy.wait("@search"); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: "Products", + description: + "Includes a catalog of all the products ever sold by the famed Sample Company.", + collection: "Sample Database", + }, + ], + strict: false, }); cy.get("@searchBox").type("{enter}"); cy.wait("@search"); - cy.findAllByTestId("search-result-item") - .first() - .within(() => { - getProductsSearchResults(); - }); + expectSearchResultContent({ + expectedSearchResults: [ + { + name: "Products", + description: + "Includes a catalog of all the products ever sold by the famed Sample Company.", + }, + ], + strict: false, + }); }); it("should work for user with permissions (metabase#12332)", () => { @@ -122,41 +170,41 @@ describe("scenarios > search", () => { cy.get("@search.all").should("have.length", 1); }); }); + describe("accessing full page search with `Enter`", () => { + it("should not render full page search if user has not entered a text query", () => { + cy.intercept("GET", "/api/activity/recent_views").as("getRecentViews"); - describe("applying search filters", () => { - beforeEach(() => { - cy.signInAsAdmin(); - - setActionsEnabledForDB(SAMPLE_DB_ID); - - cy.createQuestion({ - name: "Orders Model", - query: { "source-table": ORDERS_ID }, - dataset: true, - }).then(({ body: { id } }) => { - createAction({ - name: "Update orders quantity", - description: "Set orders quantity to the same value", - type: "query", - model_id: id, - database_id: SAMPLE_DB_ID, - dataset_query: { - database: SAMPLE_DB_ID, - native: { - query: "UPDATE orders SET quantity = quantity", - }, - type: "native", - }, - parameters: [], - visualization_settings: { - type: "button", - }, - }); + cy.visit("/"); + + getSearchBar().click().type("{enter}"); + + cy.wait("@getRecentViews"); + + cy.findByTestId("search-results-floating-container").within(() => { + cy.findByText("Recently viewed").should("exist"); }); + cy.location("pathname").should("eq", "/"); }); - describe("hydrating search query from URL", () => { - it("should hydrate search with search text", () => { + it("should render full page search when search text is present and user clicks 'Enter'", () => { + cy.visit("/"); + + getSearchBar().click().type("orders{enter}"); + cy.wait("@search"); + + cy.findByTestId("search-app").within(() => { + cy.findByText('Results for "orders"').should("exist"); + }); + + cy.location().should(loc => { + expect(loc.pathname).to.eq("/search"); + expect(loc.search).to.eq("?q=orders"); + }); + }); + }); + describe("applying search filters", () => { + describe("no filters", () => { + it("hydrating search from URL", () => { cy.visit("/search?q=orders"); cy.wait("@search"); @@ -165,110 +213,864 @@ describe("scenarios > search", () => { cy.findByText('Results for "orders"').should("exist"); }); }); + }); - it("should hydrate search with search text and filter", () => { - const { filterName, resultInfoText } = typeFilters[0]; - cy.visit(`/search?q=orders&type=${filterName}`); - cy.wait("@search"); + describe("type filter", () => { + beforeEach(() => { + setActionsEnabledForDB(SAMPLE_DB_ID); - getSearchBar().should("have.value", "orders"); + cy.createQuestion({ + name: "Orders Model", + query: { "source-table": ORDERS_ID }, + dataset: true, + }).then(({ body: { id } }) => { + createAction({ + name: "Update orders quantity", + description: "Set orders quantity to the same value", + type: "query", + model_id: id, + database_id: SAMPLE_DB_ID, + dataset_query: { + database: SAMPLE_DB_ID, + native: { + query: "UPDATE orders SET quantity = quantity", + }, + type: "native", + }, + parameters: [], + visualization_settings: { + type: "button", + }, + }); + }); + }); - cy.findByTestId("search-app").within(() => { - cy.findByText('Results for "orders"').should("exist"); + typeFilters.forEach(({ label, type }) => { + it(`should hydrate search with search text and ${label} filter`, () => { + cy.visit(`/search?q=e&type=${type}`); + cy.wait("@search"); + + getSearchBar().should("have.value", "e"); + + cy.findByTestId("search-app").within(() => { + cy.findByText('Results for "e"').should("exist"); + }); + + const regex = new RegExp(`${type}$`); + cy.findAllByTestId("search-result-item").each(result => { + cy.wrap(result) + .should("have.attr", "aria-label") + .and("match", regex); + }); + + cy.findByTestId("type-search-filter").within(() => { + cy.findByText(label).should("exist"); + cy.findByLabelText("close icon").should("exist"); + }); }); - cy.findAllByTestId("result-link-info-text").each(result => { - cy.wrap(result).should("contain.text", resultInfoText); + it(`should filter results by ${label}`, () => { + cy.visit("/"); + + getSearchBar().clear().type("e{enter}"); + cy.wait("@search"); + + cy.findByTestId("type-search-filter").click(); + popover().within(() => { + cy.findByText(label).click(); + cy.findByText("Apply").click(); + }); + + const regex = new RegExp(`${type}$`); + cy.findAllByTestId("search-result-item").each(result => { + cy.wrap(result) + .should("have.attr", "aria-label") + .and("match", regex); + }); }); + }); + + it("should remove type filter when `X` is clicked on search filter", () => { + const { label, type } = typeFilters[0]; + cy.visit(`/search?q=e&type=${type}`); + cy.wait("@search"); cy.findByTestId("type-search-filter").within(() => { - cy.findByText("Question").should("exist"); + cy.findByText(label).should("exist"); + cy.findByLabelText("close icon").click(); + cy.findByText(label).should("not.exist"); + cy.findByText("Content type").should("exist"); + }); + + cy.url().should("not.contain", "type"); + + cy.findAllByTestId("search-result-item").then($results => { + const uniqueResults = new Set( + $results.toArray().map(el => { + const label = el.getAttribute("aria-label"); + return label.split(" ").slice(-1)[0]; + }), + ); + expect(uniqueResults.size).to.be.greaterThan(1); + }); + }); + }); + + describe("created_by filter", () => { + beforeEach(() => { + restore(); + // create a question from a normal and admin user, then we can query the question + // created by that user as an admin + cy.signInAsNormalUser(); + cy.createQuestion(NORMAL_USER_TEST_QUESTION); + cy.signOut(); + + cy.signInAsAdmin(); + cy.createQuestion(ADMIN_TEST_QUESTION); + }); + + it("should hydrate created_by filter", () => { + cy.visit( + `/search?created_by=${ADMIN_USER_ID}&created_by=${NORMAL_USER_ID}&q=reviews`, + ); + + cy.wait("@search"); + + cy.findByTestId("created_by-search-filter").within(() => { + cy.findByText("2 users selected").should("exist"); cy.findByLabelText("close icon").should("exist"); }); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: NORMAL_USER_TEST_QUESTION.name, + timestamp: "Created a few seconds ago by Robert Tableton", + collection: "Our analytics", + }, + { + name: ADMIN_TEST_QUESTION.name, + timestamp: "Created a few seconds ago by you", + collection: "Our analytics", + }, + ], + }); + }); + + it("should filter results by one user", () => { + cy.visit("/"); + + getSearchBar().clear(); + getSearchBar().type("reviews{enter}"); + cy.wait("@search"); + + expectSearchResultItemNameContent({ + itemNames: [ + NORMAL_USER_TEST_QUESTION.name, + ADMIN_TEST_QUESTION.name, + REVIEWS_TABLE_NAME, + ], + }); + + cy.findByTestId("created_by-search-filter").click(); + + popover().within(() => { + cy.findByText("Robert Tableton").click(); + cy.findByText("Apply").click(); + }); + cy.url().should("contain", "created_by"); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: NORMAL_USER_TEST_QUESTION.name, + timestamp: "Created a few seconds ago by Robert Tableton", + collection: "Our analytics", + }, + ], + }); + }); + it("should filter results by more than one user", () => { + cy.visit("/"); + + getSearchBar().clear().type("reviews{enter}"); + cy.wait("@search"); + + expectSearchResultItemNameContent({ + itemNames: [ + NORMAL_USER_TEST_QUESTION.name, + ADMIN_TEST_QUESTION.name, + REVIEWS_TABLE_NAME, + ], + }); + + cy.findByTestId("created_by-search-filter").click(); + + popover().within(() => { + cy.findByText("Robert Tableton").click(); + cy.findByText("Bobby Tables").click(); + cy.findByText("Apply").click(); + }); + cy.url().should("contain", "created_by"); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: NORMAL_USER_TEST_QUESTION.name, + timestamp: "Created a few seconds ago by Robert Tableton", + collection: "Our analytics", + }, + { + name: ADMIN_TEST_QUESTION.name, + timestamp: "Created a few seconds ago by you", + collection: "Our analytics", + }, + ], + }); + }); + + it("should be able to remove a user from the `created_by` filter", () => { + cy.visit( + `/search?q=reviews&created_by=${NORMAL_USER_ID}&created_by=${ADMIN_USER_ID}`, + ); + + cy.wait("@search"); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: NORMAL_USER_TEST_QUESTION.name, + timestamp: "Created a few seconds ago by Robert Tableton", + collection: "Our analytics", + }, + { + name: ADMIN_TEST_QUESTION.name, + timestamp: "Created a few seconds ago by you", + collection: "Our analytics", + }, + ], + }); + + cy.findByTestId("created_by-search-filter").click(); + popover().within(() => { + // remove Robert Tableton from the created_by filter + cy.findByTestId("search-user-select-box") + .findByText("Robert Tableton") + .click(); + cy.findByText("Apply").click(); + }); + + expectSearchResultItemNameContent({ + itemNames: [ADMIN_TEST_QUESTION.name], + }); + }); + + it("should remove created_by filter when `X` is clicked on filter", () => { + cy.visit(`/search?q=reviews&created_by=${NORMAL_USER_ID}`); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: NORMAL_USER_TEST_QUESTION.name, + timestamp: "Created a few seconds ago by Robert Tableton", + collection: "Our analytics", + }, + ], + }); + + cy.findByTestId("created_by-search-filter").within(() => { + cy.findByText("Robert Tableton").should("exist"); + cy.findByLabelText("close icon").click(); + }); + + expectSearchResultItemNameContent({ + itemNames: [ + NORMAL_USER_TEST_QUESTION.name, + ADMIN_TEST_QUESTION.name, + REVIEWS_TABLE_NAME, + ], + }); }); }); - describe("accessing full page search with `Enter`", () => { - it("should not render full page search if user has not entered a text query ", () => { - cy.intercept("GET", "/api/activity/recent_views").as("getRecentViews"); + describe("last_edited_by filter", () => { + beforeEach(() => { + cy.signInAsAdmin(); + // We'll create a question as a normal user, then edit it as an admin user + cy.createQuestion(LAST_EDITED_BY_NORMAL_USER_QUESTION).then( + ({ body: { id: questionId } }) => { + cy.signOut(); + cy.signInAsNormalUser(); + cy.visit(`/question/${questionId}`); + summarize(); + cy.findByTestId("sidebar-right").findByText("Done").click(); + cy.findByTestId("qb-header-action-panel") + .findByText("Save") + .click(); + modal().findByText("Save").click(); + }, + ); + + // We'll create a question as an admin user, then edit it as a normal user + cy.createQuestion(LAST_EDITED_BY_ADMIN_QUESTION).then( + ({ body: { id: questionId } }) => { + cy.signInAsAdmin(); + cy.visit(`/question/${questionId}`); + summarize(); + cy.findByTestId("sidebar-right").findByText("Done").click(); + cy.findByTestId("qb-header-action-panel") + .findByText("Save") + .click(); + modal().findByText("Save").click(); + }, + ); + }); + + it("should hydrate last_edited_by filter", () => { + cy.intercept("GET", "/api/user").as("getUsers"); + + cy.visit(`/search?q=reviews&last_edited_by=${NORMAL_USER_ID}`); + cy.wait("@search"); + + cy.findByTestId("last_edited_by-search-filter").within(() => { + cy.findByText("Robert Tableton").should("exist"); + cy.findByLabelText("close icon").should("exist"); + }); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + timestamp: "Updated a few seconds ago by Robert Tableton", + collection: "Our analytics", + }, + ], + }); + }); + + it("should filter last_edited results by one user", () => { cy.visit("/"); - getSearchBar().click().type("{enter}"); + getSearchBar().clear().type("reviews{enter}"); + cy.wait("@search"); - cy.wait("@getRecentViews"); + cy.findByTestId("last_edited_by-search-filter").click(); - cy.findByTestId("search-results-floating-container").within(() => { - cy.findByText("Recently viewed").should("exist"); + expectSearchResultItemNameContent({ + itemNames: [ + LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + LAST_EDITED_BY_ADMIN_QUESTION.name, + REVIEWS_TABLE_NAME, + ], + }); + + popover().within(() => { + cy.findByText("Robert Tableton").click(); + cy.findByText("Apply").click(); + }); + cy.url().should("contain", "last_edited_by"); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + timestamp: "Updated a few seconds ago by Robert Tableton", + collection: "Our analytics", + }, + ], }); - cy.location("pathname").should("eq", "/"); }); - it("should render full page search when search text is present and user clicks 'Enter'", () => { + it("should filter last_edited results by more than user", () => { cy.visit("/"); - getSearchBar().click().type("orders{enter}"); + getSearchBar().clear().type("reviews{enter}"); cy.wait("@search"); - cy.findByTestId("search-app").within(() => { - cy.findByText('Results for "orders"').should("exist"); + cy.findByTestId("last_edited_by-search-filter").click(); + + expectSearchResultItemNameContent({ + itemNames: [ + LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + LAST_EDITED_BY_ADMIN_QUESTION.name, + REVIEWS_TABLE_NAME, + ], + }); + + popover().within(() => { + cy.findByText("Robert Tableton").click(); + cy.findByText("Bobby Tables").click(); + cy.findByText("Apply").click(); }); + cy.url().should("contain", "last_edited_by"); - cy.location().should(loc => { - expect(loc.pathname).to.eq("/search"); - expect(loc.search).to.eq("?q=orders"); + expectSearchResultContent({ + expectedSearchResults: [ + { + name: LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + timestamp: "Updated a few seconds ago by Robert Tableton", + collection: "Our analytics", + }, + { + name: LAST_EDITED_BY_ADMIN_QUESTION.name, + timestamp: "Updated a few seconds ago by you", + collection: "Our analytics", + }, + ], + }); + }); + + it("should allow to remove a user from the `last_edited_by` filter", () => { + cy.visit( + `/search?q=reviews&last_edited_by=${NORMAL_USER_ID}&last_edited_by=${ADMIN_USER_ID}`, + ); + + cy.wait("@search"); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + timestamp: "Updated a few seconds ago by Robert Tableton", + collection: "Our analytics", + }, + { + name: LAST_EDITED_BY_ADMIN_QUESTION.name, + timestamp: "Updated a few seconds ago by you", + collection: "Our analytics", + }, + ], + }); + + cy.findByTestId("last_edited_by-search-filter").click(); + popover().within(() => { + // remove Robert Tableton from the last_edited_by filter + cy.findByTestId("search-user-select-box") + .findByText("Robert Tableton") + .click(); + cy.findByText("Apply").click(); + }); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: LAST_EDITED_BY_ADMIN_QUESTION.name, + timestamp: "Updated a few seconds ago by you", + collection: "Our analytics", + }, + ], + }); + }); + + it("should remove last_edited_by filter when `X` is clicked on filter", () => { + cy.visit( + `/search?q=reviews&last_edited_by=${NORMAL_USER_ID}&last_edited_by=${ADMIN_USER_ID}`, + ); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + timestamp: "Updated a few seconds ago by Robert Tableton", + collection: "Our analytics", + }, + { + name: LAST_EDITED_BY_ADMIN_QUESTION.name, + timestamp: "Updated a few seconds ago by you", + collection: "Our analytics", + }, + ], + }); + + cy.findByTestId("last_edited_by-search-filter").within(() => { + cy.findByText("2 users selected").should("exist"); + cy.findByLabelText("close icon").click(); + }); + + expectSearchResultItemNameContent({ + itemNames: [ + LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + LAST_EDITED_BY_ADMIN_QUESTION.name, + REVIEWS_TABLE_NAME, + ], }); }); }); - describe("search filters", () => { - describe("type filters", () => { - typeFilters.forEach(({ label, resultInfoText }) => { - it(`should filter results by ${label}`, () => { - cy.visit("/"); - - getSearchBar().clear().type("e{enter}"); - cy.wait("@search"); - - cy.findByTestId("type-search-filter").click(); - popover().within(() => { - cy.findByText(label).click(); - cy.findByText("Apply filters").click(); - }); - - cy.findAllByTestId("result-link-info-text").each(result => { - if (resultInfoText) { - cy.wrap(result).should("contain.text", resultInfoText); - } - }); + describe("created_at filter", () => { + beforeEach(() => { + cy.signInAsNormalUser(); + cy.createQuestion(NORMAL_USER_TEST_QUESTION); + cy.signOut(); + cy.signInAsAdmin(); + }); + + TEST_CREATED_AT_FILTERS.forEach(([label, filter]) => { + it(`should hydrate created_at=${filter}`, () => { + cy.visit(`/search?q=orders&created_at=${filter}`); + + cy.wait("@search"); + + cy.findByTestId("created_at-search-filter").within(() => { + cy.findByText(label).should("exist"); + cy.findByLabelText("close icon").should("exist"); }); }); + }); + + // we can only test the 'today' filter since we currently + // can't edit the created_at column of a question in our database + it(`should filter results by Today (created_at=thisday)`, () => { + cy.visit(`/search?q=Reviews`); + + expectSearchResultItemNameContent( + { + itemNames: [REVIEWS_TABLE_NAME, NORMAL_USER_TEST_QUESTION.name], + }, + { strict: false }, + ); + + cy.findByTestId("created_at-search-filter").click(); + popover().within(() => { + cy.findByText("Today").click(); + }); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: NORMAL_USER_TEST_QUESTION.name, + collection: "Our analytics", + timestamp: "Created a few seconds ago by Robert Tableton", + }, + ], + strict: false, + }); + }); + + it("should remove created_at filter when `X` is clicked on search filter", () => { + cy.visit(`/search?q=Reviews&created_at=thisday`); + cy.wait("@search"); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: NORMAL_USER_TEST_QUESTION.name, + collection: "Our analytics", + timestamp: "Created a few seconds ago by Robert Tableton", + }, + ], + strict: false, + }); + + cy.findByTestId("created_at-search-filter").within(() => { + cy.findByText("Today").should("exist"); + + cy.findByLabelText("close icon").click(); + + cy.findByText("Today").should("not.exist"); + cy.findByText("Creation date").should("exist"); + }); + + cy.url().should("not.contain", "created_at"); + + expectSearchResultItemNameContent( + { + itemNames: [REVIEWS_TABLE_NAME, NORMAL_USER_TEST_QUESTION.name], + }, + { strict: false }, + ); + }); + }); + + describe("last_edited_at filter", () => { + beforeEach(() => { + cy.signInAsAdmin(); + // We'll create a question as a normal user, then edit it as an admin user + cy.createQuestion(LAST_EDITED_BY_NORMAL_USER_QUESTION).then( + ({ body: { id: questionId } }) => { + cy.signOut(); + cy.signInAsNormalUser(); + cy.visit(`/question/${questionId}`); + summarize(); + cy.findByTestId("sidebar-right").findByText("Done").click(); + cy.findByTestId("qb-header-action-panel") + .findByText("Save") + .click(); + modal().findByText("Save").click(); + cy.signOut(); + cy.signInAsAdmin(); + }, + ); + }); + + TEST_CREATED_AT_FILTERS.forEach(([label, filter]) => { + it(`should hydrate last_edited_at=${filter}`, () => { + cy.visit(`/search?q=reviews&last_edited_at=${filter}`); - it("should remove type filter when `X` is clicked on search filter", () => { - const { filterName } = typeFilters[0]; - cy.visit(`/search?q=orders&type=${filterName}`); cy.wait("@search"); - cy.findByTestId("type-search-filter").within(() => { - cy.findByText("Question").should("exist"); - cy.findByLabelText("close icon").click(); - cy.findByText("Question").should("not.exist"); - cy.findByText("Content type").should("exist"); + cy.findByTestId("last_edited_at-search-filter").within(() => { + cy.findByText(label).should("exist"); + cy.findByLabelText("close icon").should("exist"); + }); + }); + }); + + // we can only test the 'today' filter since we currently + // can't edit the last_edited_at column of a question in our database + it(`should filter results by Today (last_edited_at=thisday)`, () => { + cy.visit(`/search?q=Reviews`); + + expectSearchResultItemNameContent({ + itemNames: [ + REVIEWS_TABLE_NAME, + LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + ], + }); + + cy.findByTestId("last_edited_at-search-filter").click(); + popover().within(() => { + cy.findByText("Today").click(); + }); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + collection: "Our analytics", + timestamp: "Updated a few seconds ago by Robert Tableton", + }, + ], + strict: false, + }); + }); + + it("should remove last_edited_at filter when `X` is clicked on search filter", () => { + cy.visit(`/search?q=Reviews&last_edited_at=thisday`); + cy.wait("@search"); + + expectSearchResultContent({ + expectedSearchResults: [ + { + name: LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + collection: "Our analytics", + timestamp: "Updated a few seconds ago by Robert Tableton", + }, + ], + strict: false, + }); + + cy.findByTestId("last_edited_at-search-filter").within(() => { + cy.findByText("Today").should("exist"); + + cy.findByLabelText("close icon").click(); + + cy.findByText("Today").should("not.exist"); + cy.findByText("Last edit date").should("exist"); + }); + + cy.url().should("not.contain", "last_edited_at"); + + expectSearchResultItemNameContent({ + itemNames: [ + REVIEWS_TABLE_NAME, + LAST_EDITED_BY_NORMAL_USER_QUESTION.name, + ], + }); + }); + }); + + describeEE("verified filter", () => { + beforeEach(() => { + setTokenFeatures("all"); + cy.createModerationReview({ + status: "verified", + moderated_item_type: "card", + moderated_item_id: ORDERS_COUNT_QUESTION_ID, + }); + }); + + it("should hydrate search with search text and verified filter", () => { + cy.visit("/search?q=orders&verified=true"); + cy.wait("@search"); + + getSearchBar().should("have.value", "orders"); + + cy.findByTestId("search-app").within(() => { + cy.findByText('Results for "orders"').should("exist"); + }); + + cy.findAllByTestId("search-result-item").each(result => { + cy.wrap(result).within(() => { + cy.findByLabelText("verified_filled icon").should("exist"); }); + }); + }); + + it("should filter results by verified items", () => { + cy.visit("/"); + + getSearchBar().clear().type("e{enter}"); + cy.wait("@search"); + + cy.findByTestId("verified-search-filter") + .findByText("Verified items only") + .click(); + + cy.wait("@search"); + + cy.findAllByTestId("search-result-item").each(result => { + cy.wrap(result).within(() => { + cy.findByLabelText("verified_filled icon").should("exist"); + }); + }); + }); + + it("should not filter results when verified items is off", () => { + cy.visit("/search?q=e&verified=true"); + + cy.wait("@search"); - cy.url().should("not.contain", "type"); + cy.findByTestId("verified-search-filter") + .findByText("Verified items only") + .click(); + cy.url().should("not.include", "verified=true"); - // Check that we're getting elements other than Questions by checking the - // result text and checking if there's more than one result-link-info-text text - cy.findAllByTestId("result-link-info-text").then($elements => { - const textContent = new Set( - $elements.toArray().map(el => el.textContent), - ); - expect(textContent.size).to.be.greaterThan(1); + let verifiedElementCount = 0; + let unverifiedElementCount = 0; + cy.findAllByTestId("search-result-item") + .each($el => { + if (!$el.find('[aria-label="verified_filled icon"]').length) { + unverifiedElementCount++; + } else { + verifiedElementCount++; + } + }) + .then(() => { + expect(verifiedElementCount).to.eq(1); + expect(unverifiedElementCount).to.be.gt(0); }); + }); + }); + + describe("native query filter", () => { + beforeEach(() => { + cy.signInAsAdmin(); + cy.createNativeQuestion({ + name: TEST_NATIVE_QUESTION_NAME, + native: { + query: "SELECT 'reviews';", + }, + }); + + cy.createNativeQuestion({ + name: "Native Query", + native: { + query: `SELECT '${TEST_NATIVE_QUESTION_NAME}';`, + }, + }); + }); + + it("should hydrate search with search text and native query filter", () => { + cy.visit( + `/search?q=${TEST_NATIVE_QUESTION_NAME}&search_native_query=true`, + ); + cy.wait("@search"); + + getSearchBar().should("have.value", TEST_NATIVE_QUESTION_NAME); + + cy.findByTestId("search-app").within(() => { + cy.findByText(`Results for "${TEST_NATIVE_QUESTION_NAME}"`).should( + "exist", + ); + }); + + expectSearchResultItemNameContent({ + itemNames: [TEST_NATIVE_QUESTION_NAME, "Native Query"], + }); + }); + + it("should include results that contain native query data when the toggle is on", () => { + cy.visit(`/search?q=${TEST_NATIVE_QUESTION_NAME}`); + cy.wait("@search"); + + expectSearchResultItemNameContent({ + itemNames: [TEST_NATIVE_QUESTION_NAME], + }); + + cy.findByTestId("search_native_query-search-filter") + .findByText("Search the contents of native queries") + .click(); + + cy.url().should("include", "search_native_query=true"); + + expectSearchResultItemNameContent({ + itemNames: [TEST_NATIVE_QUESTION_NAME, "Native Query"], + }); + }); + + it("should not include results that contain native query data if the toggle is off", () => { + cy.visit( + `/search?q=${TEST_NATIVE_QUESTION_NAME}&search_native_query=true`, + ); + cy.wait("@search"); + + expectSearchResultItemNameContent({ + itemNames: [TEST_NATIVE_QUESTION_NAME, "Native Query"], + }); + + cy.findByTestId("search_native_query-search-filter") + .findByText("Search the contents of native queries") + .click(); + + expectSearchResultItemNameContent({ + itemNames: [TEST_NATIVE_QUESTION_NAME], }); }); }); + + it("should persist filters when the user changes the text query", () => { + cy.visit("/search?q=orders"); + + // add created_by filter + cy.findByTestId("created_by-search-filter").click(); + popover().within(() => { + cy.findByText("Bobby Tables").click(); + cy.findByText("Apply").click(); + }); + + // add last_edited_by filter + cy.findByTestId("last_edited_by-search-filter").click(); + popover().within(() => { + cy.findByText("Bobby Tables").click(); + cy.findByText("Apply").click(); + }); + + // add type filter + cy.findByTestId("type-search-filter").click(); + popover().within(() => { + cy.findByText("Question").click(); + cy.findByText("Apply").click(); + }); + + expectSearchResultItemNameContent({ + itemNames: [ + "Orders", + "Orders, Count", + "Orders, Count, Grouped by Created At (year)", + ], + }); + + getSearchBar().clear().type("count{enter}"); + + expectSearchResultItemNameContent({ + itemNames: [ + "Orders, Count", + "Orders, Count, Grouped by Created At (year)", + ], + }); + }); }); }); @@ -294,14 +1096,77 @@ describeWithSnowplow("scenarios > search", () => { }); }); -function getProductsSearchResults() { - cy.findByText("Products"); - // This part about the description reproduces metabase#20018 - cy.findByText( - "Includes a catalog of all the products ever sold by the famed Sample Company.", - ); -} - function getSearchBar() { return cy.findByPlaceholderText("Search…"); } + +function expectSearchResultItemNameContent( + { itemNames }, + { strict } = { strict: true }, +) { + cy.findAllByTestId("search-result-item-name").then($searchResultLabel => { + const searchResultLabelList = $searchResultLabel + .toArray() + .map(el => el.textContent); + + if (strict) { + expect(searchResultLabelList).to.have.length(itemNames.length); + } + expect(searchResultLabelList).to.include.members(itemNames); + }); +} + +/** + * Checks the search results against expectedSearchValues, including descriptions, + * collection names, and timestamps, depending on the given data. + * + * @param {Object} options - Options for the test. + * @param {Object[]} options.expectedSearchResults - An array of search result items to compare against. + * @param {string} options.expectedSearchResults[].name - The name of the search result item. + * @param {string} options.expectedSearchResults[].description - The description of the search result item. + * @param {string} options.expectedSearchResults[].collection - The collection label of the search result item. + * @param {string} options.expectedSearchResults[].timestamp - The timestamp label of the search result item . + * @param {boolean} [strict=true] - Whether to check if the contents AND length of search results are the same + */ +function expectSearchResultContent({ expectedSearchResults, strict = true }) { + const searchResultItemSelector = "[data-testid=search-result-item]"; + + const searchResultItems = cy.get(searchResultItemSelector); + + searchResultItems.then($results => { + if (strict) { + // Check if the length of the search results is the same as the expected length + expect($results).to.have.length(expectedSearchResults.length); + } + }); + + for (const expectedSearchResult of expectedSearchResults) { + cy.contains(searchResultItemSelector, expectedSearchResult.name).within( + () => { + cy.findByTestId("search-result-item-name").should( + "have.text", + expectedSearchResult.name, + ); + + if (expectedSearchResult.description) { + cy.findByTestId("result-description").should( + "have.text", + expectedSearchResult.description, + ); + } + + if (expectedSearchResult.collection) { + cy.findAllByTestId("result-link-wrapper").first(() => { + cy.findByText(expectedSearchResult.collection).should("exist"); + }); + } + if (expectedSearchResult.timestamp) { + cy.findByTestId("revision-history-button").should( + "have.text", + expectedSearchResult.timestamp, + ); + } + }, + ); + } +} diff --git a/e2e/test/scenarios/organization/content-verification.cy.spec.js b/e2e/test/scenarios/organization/content-verification.cy.spec.js index c4c9de8077d03fe6be97d5b33892c8b687634cf2..c2372a48272efea59a87e0ea2984294d7dc9f884 100644 --- a/e2e/test/scenarios/organization/content-verification.cy.spec.js +++ b/e2e/test/scenarios/organization/content-verification.cy.spec.js @@ -180,7 +180,7 @@ describeEE("scenarios > premium > content verification", () => { .first() .within(() => { cy.findByText("Orders, Count"); - cy.icon("verified"); + cy.icon("verified_filled"); }); cy.visit("/collection/root"); diff --git a/e2e/test/scenarios/organization/official-collections.cy.spec.js b/e2e/test/scenarios/organization/official-collections.cy.spec.js index 9a8b6f3d1e6fac711acb079a77205fa60b7cfe88..ea3773e38c2fa9bd79fd912d65841fa0cfa8e5a4 100644 --- a/e2e/test/scenarios/organization/official-collections.cy.spec.js +++ b/e2e/test/scenarios/organization/official-collections.cy.spec.js @@ -199,7 +199,7 @@ function testOfficialBadgeInSearch({ cy.findByTestId("search-results-list").within(() => { assertSearchResultBadge(collection, { expectBadge, - selector: "h3", + selector: "[data-testid='search-result-item-name']", }); assertSearchResultBadge(question, { expectBadge }); assertSearchResultBadge(dashboard, { expectBadge }); @@ -283,7 +283,8 @@ function assertSearchResultBadge(itemName, opts) { const { expectBadge } = opts; cy.findByText(itemName, opts) .parentsUntil("[data-testid=search-result-item]") - .last() + .parent() + .first() .within(() => { cy.icon("badge").should(expectBadge ? "exist" : "not.exist"); }); diff --git a/e2e/test/scenarios/question/new.cy.spec.js b/e2e/test/scenarios/question/new.cy.spec.js index e6c9bb7c6de44549f2292f0479f34ace02db7ba9..790a5bd397c17906d2aa2d8d8fb3117f8956a3ab 100644 --- a/e2e/test/scenarios/question/new.cy.spec.js +++ b/e2e/test/scenarios/question/new.cy.spec.js @@ -72,13 +72,13 @@ describe("scenarios > question > new", () => { ); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.contains("Saved question in Our analytics"); + cy.contains("Our analytics"); cy.findAllByRole("link", { name: "Our analytics" }) .should("have.attr", "href") .and("eq", "/collection/root"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.contains("Table in Sample Database"); + cy.contains("Sample Database"); cy.findAllByRole("link", { name: "Sample Database" }) .should("have.attr", "href") .and("eq", `/browse/${SAMPLE_DB_ID}-sample-database`); diff --git a/e2e/test/scenarios/question/reproductions/19341-disabled-nested-queries.cy.spec.js b/e2e/test/scenarios/question/reproductions/19341-disabled-nested-queries.cy.spec.js index 415e3679242b88cc7ca75c0aa010ebb75acdaf40..5cd73eff3444fe7b8a52ad7a68a9dac84772c388 100644 --- a/e2e/test/scenarios/question/reproductions/19341-disabled-nested-queries.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/19341-disabled-nested-queries.cy.spec.js @@ -38,8 +38,17 @@ describe("issue 19341", () => { // Ensure the search doesn't list saved questions cy.findByPlaceholderText("Search for a table…").type("Ord"); cy.findByText("Loading...").should("not.exist"); - cy.findAllByText(/Saved question in/i).should("not.exist"); - cy.findAllByText(/Table in/i).should("exist"); + + cy.findAllByTestId("search-result-item").then($result => { + const searchResults = $result.toArray(); + const modelTypes = new Set( + searchResults.map(k => k.getAttribute("data-model-type")), + ); + + expect(modelTypes).not.to.include("card"); + expect(modelTypes).to.include("table"); + }); + cy.icon("close").click(); cy.findByText("Sample Database").click(); diff --git a/enterprise/backend/test/metabase_enterprise/audit_db_test.clj b/enterprise/backend/test/metabase_enterprise/audit_db_test.clj index f27210393d9bf55ba7693a8647e0a2bba8f79601..d953707735f193ea25e2f04e2418b2ec2ef0bd7a 100644 --- a/enterprise/backend/test/metabase_enterprise/audit_db_test.clj +++ b/enterprise/backend/test/metabase_enterprise/audit_db_test.clj @@ -42,11 +42,12 @@ (deftest audit-db-content-is-installed-when-found (mt/test-drivers #{:postgres} (with-audit-db-restoration - (with-redefs [audit-db/analytics-root-dir-resource (io/resource "instance_analytics_skip")] - (is (str/ends-with? (str audit-db/analytics-root-dir-resource) - "instance_analytics_skip")) - (is (= :metabase-enterprise.audit-db/installed (audit-db/ensure-audit-db-installed!))) - (is (= 13371337 (t2/select-one-fn :id 'Database {:where [:= :is_audit true]})) - "Audit DB is installed.") - (is (not= 0 (t2/count 'Card {:where [:= :database_id 13371337]})) - "Cards should be created for Audit DB when the content is there."))))) + (mt/with-model-cleanup [:model/Dashboard :model/Card] + (with-redefs [audit-db/analytics-root-dir-resource (io/resource "instance_analytics_skip")] + (is (str/ends-with? (str audit-db/analytics-root-dir-resource) + "instance_analytics_skip")) + (is (= :metabase-enterprise.audit-db/installed (audit-db/ensure-audit-db-installed!))) + (is (= 13371337 (t2/select-one-fn :id 'Database {:where [:= :is_audit true]})) + "Audit DB is installed.") + (is (not= 0 (t2/count 'Card {:where [:= :database_id 13371337]})) + "Cards should be created for Audit DB when the content is there.")))))) diff --git a/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionAuthorityLevelIcon.tsx b/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionAuthorityLevelIcon.tsx index 9043899fbb6ac6988b5bbc2e89425bfd1d82820f..a3c8617803e88e55afd83a6fe066dac7f5993527 100644 --- a/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionAuthorityLevelIcon.tsx +++ b/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionAuthorityLevelIcon.tsx @@ -1,37 +1,25 @@ -import type { IconProps } from "metabase/core/components/Icon"; +/* eslint-disable react/prop-types */ import { Icon } from "metabase/core/components/Icon"; import { color } from "metabase/lib/colors"; -import type { Collection } from "metabase-types/api"; - +import type { CollectionAuthorityLevelIcon as CollectionAuthorityLevelIconComponent } from "metabase/plugins/index"; import { AUTHORITY_LEVELS } from "../constants"; import { isRegularCollection } from "../utils"; -interface Props extends Omit<IconProps, "name" | "tooltip"> { - collection: Collection; - - // check OFFICIAL_COLLECTION authority level definition - // https://github.com/metabase/metabase/blob/d0ab6c0e2361dccfbfe961d61e1066ec2faf6c40/enterprise/frontend/src/metabase-enterprise/collections/constants.js#L14 - tooltip?: "default" | "belonging"; -} - -export function CollectionAuthorityLevelIcon({ - collection, - tooltip = "default", - ...iconProps -}: Props) { - if (isRegularCollection(collection)) { - return null; - } - const level = AUTHORITY_LEVELS[String(collection.authority_level)]; - return ( - <Icon - {...iconProps} - name={level.icon} - tooltip={level.tooltips?.[tooltip] || tooltip} - style={{ color: level.color ? color(level.color) : undefined }} - data-testid={`${level.type}-collection-marker`} - /> - ); -} +export const CollectionAuthorityLevelIcon: CollectionAuthorityLevelIconComponent = + ({ collection, tooltip = "default", ...iconProps }) => { + if (isRegularCollection(collection)) { + return null; + } + const level = AUTHORITY_LEVELS[String(collection.authority_level)]; + return ( + <Icon + {...iconProps} + name={level.icon} + tooltip={level.tooltips?.[tooltip] || tooltip} + style={{ color: level.color ? color(level.color) : undefined }} + data-testid={`${level.type}-collection-marker`} + /> + ); + }; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/VerifiedFilter/VerifiedFilter.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/VerifiedFilter/VerifiedFilter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e026d49c5e3473e65a6ca5605cc5eacb6305930 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/VerifiedFilter/VerifiedFilter.tsx @@ -0,0 +1,9 @@ +import { t } from "ttag"; +import type { SearchFilterComponent } from "metabase/search/types"; + +export const VerifiedFilter: SearchFilterComponent<"verified"> = { + label: () => t`Verified items only`, + type: "toggle", + fromUrl: value => value === "true", + toUrl: (value: boolean) => (value ? "true" : null), +}; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/VerifiedFilter/VerifiedFilter.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/VerifiedFilter/VerifiedFilter.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ff5c5e4352079710809178e8d37898e482f0ea0 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/VerifiedFilter/VerifiedFilter.unit.spec.tsx @@ -0,0 +1,42 @@ +import { VerifiedFilter } from "./VerifiedFilter"; + +const fromUrl = VerifiedFilter.fromUrl; +const toUrl = VerifiedFilter.toUrl; + +describe("fromUrl", () => { + it('should convert "true" string to boolean true', () => { + const value = "true"; + const result = fromUrl(value); + expect(result).toBe(true); + }); + + it("should convert any other string to boolean false", () => { + const falseValue = fromUrl("false"); + const invalidValue = fromUrl("invalid"); + + expect(falseValue).toBe(false); + expect(invalidValue).toBe(false); + }); + + it("should return null when value is null or undefined", () => { + const nullValue = fromUrl(null); + const undefinedValue = fromUrl(undefined); + + expect(nullValue).toBe(false); + expect(undefinedValue).toBe(false); + }); +}); + +describe("toUrl", () => { + it('should convert boolean true to "true" string', () => { + const value = true; + const result = toUrl(value); + expect(result).toBe("true"); + }); + + it("should convert boolean false to null", () => { + const value = false; + const result = toUrl(value); + expect(result).toBeNull(); + }); +}); diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/VerifiedFilter/index.ts b/enterprise/frontend/src/metabase-enterprise/content_verification/VerifiedFilter/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..96951fe59ba5e2374de66019e3ffe3bc9ce04040 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/VerifiedFilter/index.ts @@ -0,0 +1 @@ +export * from "./VerifiedFilter"; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts b/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f560bc5057963d8320d829536835e6b957110d3a --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts @@ -0,0 +1,7 @@ +import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins"; +import { hasPremiumFeature } from "metabase-enterprise/settings"; +import { VerifiedFilter } from "metabase-enterprise/content_verification/VerifiedFilter"; + +if (hasPremiumFeature("content_verification")) { + PLUGIN_CONTENT_VERIFICATION.VerifiedFilter = VerifiedFilter; +} diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationStatusIcon/ModerationStatusIcon.tsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationStatusIcon/ModerationStatusIcon.tsx index a48fe06dd2d6be025060f074b99629cc4af6e849..78445aedcb9d0a0dfe6b4bede24cfac7343879c9 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationStatusIcon/ModerationStatusIcon.tsx +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationStatusIcon/ModerationStatusIcon.tsx @@ -6,13 +6,15 @@ import { Icon } from "metabase/core/components/Icon"; type ModerationStatusIconProps = { status: string | null | undefined; + filled?: boolean; } & Partial<IconProps>; export const ModerationStatusIcon = ({ status, + filled = false, ...iconProps }: ModerationStatusIconProps) => { - const { name: iconName, color: iconColor } = getStatusIcon(status); + const { name: iconName, color: iconColor } = getStatusIcon(status, filled); return iconName ? ( <Icon name={iconName} color={color(iconColor)} {...iconProps} /> ) : null; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/constants.js b/enterprise/frontend/src/metabase-enterprise/moderation/constants.js index 3e4417c28b91e17c8ab43324aa5510ead9c447cc..4b56960553e9daf94177fafa05386c7b25798d0b 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/constants.js +++ b/enterprise/frontend/src/metabase-enterprise/moderation/constants.js @@ -7,6 +7,10 @@ export const MODERATION_STATUS_ICONS = { name: "verified", color: "brand", }, + verified_filled: { + name: "verified_filled", + color: "brand", + }, null: { name: "close", color: "text-light", diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/service.js b/enterprise/frontend/src/metabase-enterprise/moderation/service.js index 1bf8002cc23d09261be1dbbb17528ce2cb4d580d..87d76b795b418a041f0515cdf8e84a852353cd52 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/service.js +++ b/enterprise/frontend/src/metabase-enterprise/moderation/service.js @@ -24,11 +24,16 @@ export function removeReview({ itemId, itemType }) { } const noIcon = {}; -export function getStatusIcon(status) { + +export function getStatusIcon(status, filled = false) { if (isRemovedReviewStatus(status)) { return noIcon; } + if (status === "verified" && filled) { + return MODERATION_STATUS_ICONS[`${status}_filled`]; + } + return MODERATION_STATUS_ICONS[status] || noIcon; } diff --git a/enterprise/frontend/src/metabase-enterprise/plugins.js b/enterprise/frontend/src/metabase-enterprise/plugins.js index e1f989ce08a80129faa38c8de1eeb5a364510bdc..35e1ff822b83e111affc33b1c3a1ee54b726cb5c 100644 --- a/enterprise/frontend/src/metabase-enterprise/plugins.js +++ b/enterprise/frontend/src/metabase-enterprise/plugins.js @@ -16,6 +16,7 @@ import "./sandboxes"; import "./auth"; import "./caching"; import "./collections"; +import "./content_verification"; import "./whitelabel"; import "./embedding"; import "./snippets"; diff --git a/enterprise/frontend/src/metabase-enterprise/snippets/components/CollectionOptionsButton.jsx b/enterprise/frontend/src/metabase-enterprise/snippets/components/CollectionOptionsButton.jsx index c62bd7eba0ea7ee740c168b1dc1d169c86abc94f..28d6e01fabf4a06bf8f663e398479764a467f6e8 100644 --- a/enterprise/frontend/src/metabase-enterprise/snippets/components/CollectionOptionsButton.jsx +++ b/enterprise/frontend/src/metabase-enterprise/snippets/components/CollectionOptionsButton.jsx @@ -34,7 +34,7 @@ export default class CollectionOptionsButton extends Component { className="text-brand" sections={[{ items }]} onChange={item => { - item.onClick(); + item.onClick(item); closePopover(); }} /> diff --git a/frontend/src/metabase-types/api/activity.ts b/frontend/src/metabase-types/api/activity.ts index b1909254dddeb4fb78d7d78286031494c5f48a5e..c2e2ba914e96453f80f0d55d54fe5f071bc0865c 100644 --- a/frontend/src/metabase-types/api/activity.ts +++ b/frontend/src/metabase-types/api/activity.ts @@ -1,6 +1,8 @@ export type ModelType = "table" | "card" | "dataset" | "dashboard"; export interface ModelObject { + display_name?: string; + moderated_status?: string; name: string; } diff --git a/frontend/src/metabase-types/api/mocks/search.ts b/frontend/src/metabase-types/api/mocks/search.ts index dc540247a9f8788b820ef98e3635959d220c94ba..77ac0e539645762ff8af93af2f686cb17b5e9e87 100644 --- a/frontend/src/metabase-types/api/mocks/search.ts +++ b/frontend/src/metabase-types/api/mocks/search.ts @@ -36,6 +36,12 @@ export const createMockSearchResult = ( dashboard_count: null, context: null, scores: [createMockSearchScore()], + created_at: "2022-01-01T00:00:00.000Z", + creator_common_name: "Testy Tableton", + creator_id: 2, + last_edited_at: "2023-01-01T00:00:00.000Z", + last_editor_common_name: "Bobby Tables", + last_editor_id: 1, ...options, }; }; diff --git a/frontend/src/metabase-types/api/mocks/user.ts b/frontend/src/metabase-types/api/mocks/user.ts index 8cb0361bb6f981530f76e88016412076e57b86c5..f53b4ad47dcf0de07bff855f6c96861004abed88 100644 --- a/frontend/src/metabase-types/api/mocks/user.ts +++ b/frontend/src/metabase-types/api/mocks/user.ts @@ -23,7 +23,7 @@ export const createMockUser = (opts?: Partial<User>): User => ({ ...opts, }); -export const createMockerUserListResult = ( +export const createMockUserListResult = ( opts?: Partial<UserListResult>, ): UserListResult => ({ id: 1, diff --git a/frontend/src/metabase-types/api/search.ts b/frontend/src/metabase-types/api/search.ts index ec1d5c38312d00b41ba180fb15692540c93aaf49..b1a45f0e2cb6ccdd98737906b4b8a8e7134a70ff 100644 --- a/frontend/src/metabase-types/api/search.ts +++ b/frontend/src/metabase-types/api/search.ts @@ -1,3 +1,4 @@ +import type { UserId } from "metabase-types/api/user"; import type { CardId } from "./card"; import type { Collection } from "./collection"; import type { DatabaseId, InitialSyncStatus } from "./database"; @@ -71,6 +72,12 @@ export interface SearchResult { dashboard_count: number | null; context: any; // this might be a dead property scores: SearchScore[]; + last_edited_at: string | null; + last_editor_id: UserId | null; + last_editor_common_name: string | null; + creator_id: UserId | null; + creator_common_name: string | null; + created_at: string | null; } export interface SearchListQuery { diff --git a/frontend/src/metabase/components/EventSandbox/EventSandbox.tsx b/frontend/src/metabase/components/EventSandbox/EventSandbox.tsx index 7f1b5e5165a5710c897e26a29b91da759f8dea7b..b9336ed760291202ba5b386950ab0fee7cca3e2a 100644 --- a/frontend/src/metabase/components/EventSandbox/EventSandbox.tsx +++ b/frontend/src/metabase/components/EventSandbox/EventSandbox.tsx @@ -20,6 +20,7 @@ type EventSandboxProps = { enableMouseEvents?: boolean; disabled?: boolean; preventDefault?: boolean; + className?: string; }; // Prevent DOM events from bubbling through the React component tree @@ -30,6 +31,7 @@ function EventSandbox({ disabled, enableMouseEvents = false, preventDefault = false, + className, }: EventSandboxProps) { const stop = useCallback( (event: React.SyntheticEvent) => { @@ -56,6 +58,7 @@ function EventSandbox({ <React.Fragment>{children}</React.Fragment> ) : ( <div + className={className} onClick={stop} onContextMenu={stop} onDoubleClick={stop} diff --git a/frontend/src/metabase/components/LastEditInfoLabel/LastEditInfoLabel.jsx b/frontend/src/metabase/components/LastEditInfoLabel/LastEditInfoLabel.jsx index 6c1929df9b88a72461af04fca5871db80631d7cd..0f79e372826314434364a6297bf3e3139ad50943 100644 --- a/frontend/src/metabase/components/LastEditInfoLabel/LastEditInfoLabel.jsx +++ b/frontend/src/metabase/components/LastEditInfoLabel/LastEditInfoLabel.jsx @@ -18,13 +18,14 @@ function mapStateToProps(state) { LastEditInfoLabel.propTypes = { item: PropTypes.shape({ "last-edit-info": PropTypes.shape({ - id: PropTypes.number.isRequired, - email: PropTypes.string.isRequired, + id: PropTypes.number, + email: PropTypes.string, first_name: PropTypes.string, last_name: PropTypes.string, - timestamp: PropTypes.string.isRequired, + timestamp: PropTypes.string, }).isRequired, }), + prefix: PropTypes.string, user: PropTypes.shape({ id: PropTypes.number, }).isRequired, @@ -38,23 +39,43 @@ function formatEditorName(lastEditInfo) { return name || lastEditInfo.email; } -function LastEditInfoLabel({ item, user, onClick, className }) { +function LastEditInfoLabel({ + item, + user, + prefix = t`Edited`, + onClick, + className, +}) { const lastEditInfo = item["last-edit-info"]; const { id: editorId, timestamp } = lastEditInfo; - const time = moment(timestamp).fromNow(); + + const momentTimestamp = moment(timestamp); + const timeLabel = + timestamp && momentTimestamp.isValid() ? momentTimestamp.fromNow() : null; const editor = editorId === user.id ? t`you` : formatEditorName(lastEditInfo); + const editorLabel = editor ? t`by ${editor}` : null; + + const label = + timeLabel || editorLabel + ? [timeLabel, editorLabel].filter(Boolean).join(" ") + : null; - return ( - <Tooltip tooltip={<DateTime value={timestamp} />}> + return label ? ( + <Tooltip + tooltip={timestamp ? <DateTime value={timestamp} /> : null} + isEnabled={!!timeLabel} + > <TextButton size="small" className={className} onClick={onClick} data-testid="revision-history-button" - >{t`Edited ${time} by ${editor}`}</TextButton> + > + {prefix} {label} + </TextButton> </Tooltip> - ); + ) : null; } export default connect(mapStateToProps)(LastEditInfoLabel); diff --git a/frontend/src/metabase/containers/DataPicker/tests/common.tsx b/frontend/src/metabase/containers/DataPicker/tests/common.tsx index 23f30cd6b62eb856587ebfcee2e073d9c1de3ee6..76a93b95efe127d80b85844360ef2373a5af09ee 100644 --- a/frontend/src/metabase/containers/DataPicker/tests/common.tsx +++ b/frontend/src/metabase/containers/DataPicker/tests/common.tsx @@ -4,6 +4,8 @@ import { setupCollectionsEndpoints, setupDatabasesEndpoints, setupSearchEndpoints, + setupUsersEndpoints, + setupCollectionByIdEndpoint, } from "__support__/server-mocks"; import { renderWithProviders, waitForLoaderToBeRemoved } from "__support__/ui"; @@ -16,6 +18,7 @@ import { createMockCollectionItem, createMockDatabase, createMockTable, + createMockUser, } from "metabase-types/api/mocks"; import { createMockSettingsState } from "metabase-types/store/mocks"; @@ -211,8 +214,13 @@ export async function setup({ setupDatabasesEndpoints([], { hasSavedQuestions: false }); } + const collectionList = [SAMPLE_COLLECTION, EMPTY_COLLECTION]; setupCollectionsEndpoints({ - collections: [SAMPLE_COLLECTION, EMPTY_COLLECTION], + collections: collectionList, + }); + + setupCollectionByIdEndpoint({ + collections: collectionList, }); setupCollectionVirtualSchemaEndpoints(createMockCollection(ROOT_COLLECTION), [ @@ -240,6 +248,8 @@ export async function setup({ setupSearchEndpoints([SAMPLE_QUESTION_SEARCH_ITEM]); } + setupUsersEndpoints([createMockUser()]); + const settings = createMockSettingsState({ "enable-nested-queries": hasNestedQueriesEnabled, }); diff --git a/frontend/src/metabase/lib/urls/browse.ts b/frontend/src/metabase/lib/urls/browse.ts index 0ac4b8229724ea14290dfa534e71090f73120bd7..664e4d35dbf95dbd86ecce404a32f3a37de3195d 100644 --- a/frontend/src/metabase/lib/urls/browse.ts +++ b/frontend/src/metabase/lib/urls/browse.ts @@ -15,7 +15,11 @@ export function browseDatabase(database: Database) { return appendSlug(`/browse/${database.id}`, slugg(name)); } -export function browseSchema(table: Table) { +export function browseSchema(table: { + db_id?: Table["db_id"]; + schema_name: Table["schema_name"] | null; + db?: Pick<Database, "id">; +}) { const databaseId = table.db?.id || table.db_id; return `/browse/${databaseId}/schema/${encodeURIComponent( table.schema_name ?? "", diff --git a/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.jsx b/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.jsx deleted file mode 100644 index 0c6e3900366f52979b1b74288954b2f737212f60..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.jsx +++ /dev/null @@ -1,196 +0,0 @@ -import { Fragment, useState, useEffect } from "react"; -import PropTypes from "prop-types"; -import { t } from "ttag"; -import _ from "underscore"; -import { connect } from "react-redux"; -import { push } from "react-router-redux"; - -import RecentItems from "metabase/entities/recent-items"; -import Text from "metabase/components/type/Text"; -import * as Urls from "metabase/lib/urls"; -import { isSyncCompleted } from "metabase/lib/syncing"; -import { PLUGIN_MODERATION } from "metabase/plugins"; -import { - ResultLink, - ResultButton, - ResultSpinner, - Title, - TitleWrapper, - ItemIcon, -} from "metabase/search/components/SearchResult"; -import EmptyState from "metabase/components/EmptyState"; -import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; -import { useListKeyboardNavigation } from "metabase/hooks/use-list-keyboard-navigation"; - -import { getTranslatedEntityName } from "metabase/common/utils/model-names"; -import { - Root, - EmptyStateContainer, - Header, - RecentListItemContent, -} from "./RecentsList.styled"; - -const LOADER_THRESHOLD = 100; - -const propTypes = { - list: PropTypes.arrayOf( - PropTypes.shape({ - model_id: PropTypes.number, - model: PropTypes.string, - model_object: PropTypes.object, - }), - ), - loading: PropTypes.bool, - onChangeLocation: PropTypes.func, - onClick: PropTypes.func, - className: PropTypes.string, -}; - -const getItemUrl = item => (isItemActive(item) ? Urls.modelToUrl(item) : ""); - -function RecentsList({ list, loading, onChangeLocation, onClick, className }) { - const handleSelectItem = item => { - onClick?.({ - ...item.model_object, - model: item.model, - name: item.model_object.display_name ?? item.model_object.name, - }); - }; - - const { getRef, cursorIndex } = useListKeyboardNavigation({ - list, - onEnter: item => - onClick ? handleSelectItem(item) : onChangeLocation(getItemUrl(item)), - }); - - const [canShowLoader, setCanShowLoader] = useState(false); - const hasRecents = list?.length > 0; - - useEffect(() => { - const timer = setTimeout(() => setCanShowLoader(true), LOADER_THRESHOLD); - return () => clearTimeout(timer); - }, []); - - if (loading && !canShowLoader) { - return null; - } - - // we want to remove link behavior if we have an onClick handler - const ResultContainer = onClick ? ResultButton : ResultLink; - - return ( - <Root className={className}> - <Header>{t`Recently viewed`}</Header> - <LoadingAndErrorWrapper loading={loading} noWrapper> - <Fragment> - {hasRecents && ( - <ul> - {list.map((item, index) => { - const key = getItemKey(item); - const title = getItemName(item); - const type = getTranslatedEntityName(item.model); - const active = isItemActive(item); - const loading = isItemLoading(item); - const url = getItemUrl(item); - const moderatedStatus = getModeratedStatus(item); - - return ( - <li key={key} ref={getRef(item)}> - <ResultContainer - isSelected={cursorIndex === index} - active={active} - compact={true} - to={onClick ? undefined : url} - onClick={ - onClick ? () => handleSelectItem(item) : undefined - } - > - <RecentListItemContent - align="start" - data-testid="recently-viewed-item" - > - <ItemIcon - item={item} - type={item.model} - active={active} - /> - <div> - <TitleWrapper> - <Title - active={active} - data-testid="recently-viewed-item-title" - > - {title} - </Title> - <PLUGIN_MODERATION.ModerationStatusIcon - status={moderatedStatus} - size={12} - /> - </TitleWrapper> - <Text data-testid="recently-viewed-item-type"> - {type} - </Text> - </div> - {loading && <ResultSpinner size={24} borderWidth={3} />} - </RecentListItemContent> - </ResultContainer> - </li> - ); - })} - </ul> - )} - - {!hasRecents && ( - <EmptyStateContainer> - <EmptyState message={t`Nothing here`} icon="folder" /> - </EmptyStateContainer> - )} - </Fragment> - </LoadingAndErrorWrapper> - </Root> - ); -} - -RecentsList.propTypes = propTypes; - -const getItemKey = ({ model, model_id }) => { - return `${model}:${model_id}`; -}; - -const getItemName = ({ model_object }) => { - return model_object.display_name || model_object.name; -}; - -const getModeratedStatus = ({ model_object }) => { - return model_object.moderated_status; -}; - -const isItemActive = ({ model, model_object }) => { - switch (model) { - case "table": - return isSyncCompleted(model_object); - default: - return true; - } -}; - -const isItemLoading = ({ model, model_object }) => { - switch (model) { - case "database": - case "table": - return !isSyncCompleted(model_object); - default: - return false; - } -}; - -export default _.compose( - connect(null, { - onChangeLocation: push, - }), - RecentItems.loadList({ - wrapped: true, - reload: true, - loadingAndErrorWrapper: false, - }), -)(RecentsList); diff --git a/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.styled.tsx b/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.styled.tsx deleted file mode 100644 index 3b403f00263c3e930bd06ef8be8fbc9718e699f3..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.styled.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import styled from "@emotion/styled"; - -import { color } from "metabase/lib/colors"; -import { breakpointMinSmall } from "metabase/styled-components/theme"; - -export const Root = styled.div` - padding-top: 0.5rem; - padding-bottom: 0.5rem; - - background-color: ${color("bg-white")}; - line-height: 24px; - - box-shadow: 0 20px 20px ${color("shadow")}; - - ${breakpointMinSmall} { - border: 1px solid ${color("border")}; - border-radius: 6px; - box-shadow: 0 7px 20px ${color("shadow")}; - } -`; - -export const EmptyStateContainer = styled.div` - margin: 3rem 0; -`; - -export const Header = styled.h4` - padding: 0.5rem 1rem; -`; - -export const RecentListItemContent = styled.div` - display: flex; - align-items: flex-start; -`; diff --git a/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.tsx b/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0c98f74de28e0916eda868c952f7b9850ccdf013 --- /dev/null +++ b/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.tsx @@ -0,0 +1,70 @@ +import { useMemo } from "react"; +import { push } from "react-router-redux"; +import type { RecentItem, UnrestrictedLinkEntity } from "metabase-types/api"; +import { useRecentItemListQuery } from "metabase/common/hooks"; +import type { IconName } from "metabase/core/components/Icon"; +import RecentItems from "metabase/entities/recent-items"; +import { useDispatch } from "metabase/lib/redux"; +import { RecentsListContent } from "metabase/nav/components/search/RecentsList/RecentsListContent"; +import { + getItemName, + getItemUrl, +} from "metabase/nav/components/search/RecentsList/util"; +import { Paper } from "metabase/ui"; + +type RecentsListProps = { + onClick?: (elem: UnrestrictedLinkEntity) => void; + className?: string; +}; + +export interface WrappedRecentItem extends RecentItem { + getUrl: () => string; + getIcon: () => { + name: IconName; + size?: number; + width?: number; + height?: number; + }; +} + +export const RecentsList = ({ onClick, className }: RecentsListProps) => { + const { data = [], isLoading: isRecentsListLoading } = + useRecentItemListQuery(); + + const wrappedResults: WrappedRecentItem[] = useMemo( + () => data.map(item => RecentItems.wrapEntity(item)), + [data], + ); + + const dispatch = useDispatch(); + + const onChangeLocation = (item: RecentItem) => { + const url = getItemUrl(item); + if (url) { + dispatch(push(url)); + } + }; + + const onContainerClick = (item: RecentItem) => { + if (onClick) { + onClick({ + ...item.model_object, + model: item.model, + name: getItemName(item), + id: item.model_id, + }); + } else { + onChangeLocation(item); + } + }; + + return ( + <Paper withBorder className={className}> + <RecentsListContent + isLoading={isRecentsListLoading} + results={wrappedResults} + onClick={onContainerClick} + /> + </Paper> + ); +}; diff --git a/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.unit.spec.js b/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.unit.spec.js index d145bef295d546e87922ef3f1dbdf1c2c55576af..26de1ce03c56d04b3ec6e13c58bc4efa47fab592 100644 --- a/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.unit.spec.js +++ b/frontend/src/metabase/nav/components/search/RecentsList/RecentsList.unit.spec.js @@ -1,6 +1,6 @@ import fetchMock from "fetch-mock"; import { renderWithProviders, screen } from "__support__/ui"; -import RecentsList from "./RecentsList"; +import { RecentsList } from "./RecentsList"; const recentsData = [ { @@ -59,7 +59,7 @@ describe("RecentsList", () => { expect(screen.getByText("Recently viewed")).toBeInTheDocument(); const [questionType, dashboardType, tableType] = screen.queryAllByTestId( - "recently-viewed-item-type", + "result-link-wrapper", ); expect(screen.getByText("Question I visited")).toBeInTheDocument(); diff --git a/frontend/src/metabase/nav/components/search/RecentsList/RecentsListContent.tsx b/frontend/src/metabase/nav/components/search/RecentsList/RecentsListContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e92647886d4be137aa26fa470bdad84139601f9c --- /dev/null +++ b/frontend/src/metabase/nav/components/search/RecentsList/RecentsListContent.tsx @@ -0,0 +1,123 @@ +import { t } from "ttag"; +import type { RecentItem } from "metabase-types/api"; +import { getTranslatedEntityName } from "metabase/common/utils/model-names"; +import EmptyState from "metabase/components/EmptyState"; +import { useListKeyboardNavigation } from "metabase/hooks/use-list-keyboard-navigation"; +import { isSyncCompleted } from "metabase/lib/syncing"; +import type { WrappedRecentItem } from "metabase/nav/components/search/RecentsList"; +import { + SearchLoadingSpinner, + EmptyStateContainer, +} from "metabase/nav/components/search/SearchResults"; + +import { + ItemIcon, + LoadingSection, + ModerationIcon, + ResultNameSection, + ResultTitle, + SearchResultContainer, +} from "metabase/search/components/SearchResult"; +import { SearchResultLink } from "metabase/search/components/SearchResultLink"; +import { Group, Loader, Stack, Title } from "metabase/ui"; +import { getItemName, getItemUrl, isItemActive } from "./util"; + +type RecentsListContentProps = { + isLoading: boolean; + results: WrappedRecentItem[]; + onClick?: (item: RecentItem) => void; +}; + +export const RecentsListContent = ({ + isLoading, + results, + onClick, +}: RecentsListContentProps) => { + const { getRef, cursorIndex } = useListKeyboardNavigation< + WrappedRecentItem, + HTMLButtonElement + >({ + list: results, + onEnter: (item: WrappedRecentItem) => onClick?.(item), + }); + + if (isLoading) { + return <SearchLoadingSpinner />; + } + + if (results.length === 0) { + return ( + <Stack spacing="md" px="sm" py="md"> + <Title order={4} px="sm">{t`Recently viewed`}</Title> + <EmptyStateContainer> + <EmptyState message={t`Nothing here`} icon="folder" /> + </EmptyStateContainer> + </Stack> + ); + } + + return ( + <Stack spacing="md" px="sm" py="md" data-testid="recents-list-container"> + <Title order={4} px="sm">{t`Recently viewed`}</Title> + <Stack spacing={0}> + {results.map((item, index) => { + const isActive = isItemActive(item); + + return ( + <SearchResultContainer + data-testid="recently-viewed-item" + ref={getRef(item)} + key={getItemKey(item)} + component="button" + onClick={() => onClick?.(item)} + isActive={isActive} + isSelected={cursorIndex === index} + p="sm" + > + <ItemIcon active={isActive} item={item} type={item.model} /> + <ResultNameSection justify="center" spacing="xs"> + <Group spacing="xs" align="center" noWrap> + <ResultTitle + data-testid="recently-viewed-item-title" + truncate + href={getItemUrl(item) ?? undefined} + > + {getItemName(item)} + </ResultTitle> + <ModerationIcon + status={getModeratedStatus(item)} + filled + size={14} + /> + </Group> + <SearchResultLink> + {getTranslatedEntityName(item.model)} + </SearchResultLink> + </ResultNameSection> + {isItemLoading(item) && ( + <LoadingSection px="xs"> + <Loader /> + </LoadingSection> + )} + </SearchResultContainer> + ); + })} + </Stack> + </Stack> + ); +}; + +const getItemKey = ({ model, model_id }: RecentItem) => { + return `${model}:${model_id}`; +}; + +const getModeratedStatus = ({ model_object }: RecentItem) => { + return model_object.moderated_status; +}; + +const isItemLoading = ({ model, model_object }: RecentItem) => { + if (model !== "table") { + return false; + } + return !isSyncCompleted(model_object); +}; diff --git a/frontend/src/metabase/nav/components/search/RecentsList/index.ts b/frontend/src/metabase/nav/components/search/RecentsList/index.ts index a88f680378c4ab588abcf93870c5faf05ffca5be..4a871263725c8560217303b3d1e1e4cf8aceb79f 100644 --- a/frontend/src/metabase/nav/components/search/RecentsList/index.ts +++ b/frontend/src/metabase/nav/components/search/RecentsList/index.ts @@ -1 +1,2 @@ -export { default as RecentsList } from "./RecentsList"; +export { RecentsList } from "./RecentsList"; +export type { WrappedRecentItem } from "./RecentsList"; diff --git a/frontend/src/metabase/nav/components/search/RecentsList/util.ts b/frontend/src/metabase/nav/components/search/RecentsList/util.ts new file mode 100644 index 0000000000000000000000000000000000000000..65c28bc4be7becd2960150a1e8551a7c27bd8cb7 --- /dev/null +++ b/frontend/src/metabase/nav/components/search/RecentsList/util.ts @@ -0,0 +1,18 @@ +import type { RecentItem } from "metabase-types/api"; + +import { isSyncCompleted } from "metabase/lib/syncing"; +import * as Urls from "metabase/lib/urls"; + +export const getItemName = ({ model_object }: RecentItem) => { + return model_object.display_name || model_object.name; +}; + +export const isItemActive = ({ model, model_object }: RecentItem) => { + if (model !== "table") { + return true; + } + return isSyncCompleted(model_object); +}; + +export const getItemUrl = (item: RecentItem) => + isItemActive(item) ? Urls.modelToUrl(item) : ""; diff --git a/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.styled.tsx b/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.styled.tsx index 95d19b3f9bbc40ef6ddc14068da6c572d4417843..b4d9dd43c209252e61ef713451c62e3ae53d0a23 100644 --- a/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.styled.tsx +++ b/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.styled.tsx @@ -25,24 +25,35 @@ export const SearchBarRoot = styled.div` } `; -export const SearchInputContainer = styled.div<{ isActive: boolean }>` +export const SearchInputContainer = styled.div<{ + isActive: boolean; +}>` display: flex; flex: 1 1 auto; align-items: center; position: relative; - background-color: ${props => - props.isActive ? color("bg-medium") : color("bg-light")}; + ${({ isActive }) => { + if (isActive) { + return css` + background-color: ${color("bg-medium")}; + `; + } + return css` + background-color: ${color("white")}; + + &:hover { + background-color: ${color("bg-light")}; + } + `; + }} + border: 1px solid ${color("border")}; overflow: hidden; transition: background 150ms, width 0.2s; - &:hover { - background-color: ${color("bg-medium")}; - } - @media (prefers-reduced-motion) { transition: none; } @@ -69,7 +80,9 @@ export const SearchInputContainer = styled.div<{ isActive: boolean }>` } `; -export const SearchInput = styled.input<{ isActive: boolean }>` +export const SearchInput = styled.input<{ + isActive: boolean; +}>` background-color: transparent; border: none; color: ${({ theme }) => theme.colors.text[2]}; @@ -106,7 +119,9 @@ export const SearchInput = styled.input<{ isActive: boolean }>` const ICON_MARGIN = "10px"; -export const SearchIcon = styled(Icon)<{ isActive: boolean }>` +export const SearchIcon = styled(Icon)<{ + isActive: boolean; +}>` ${breakpointMaxSmall} { transition: margin 0.3s; diff --git a/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.tsx b/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.tsx index 569c7420ca4dd453ddd37a1d280b2fd166194acb..71c6fceb23ff470d41ffa5e63c181fee35aeaab0 100644 --- a/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.tsx +++ b/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.tsx @@ -1,5 +1,5 @@ import type { MouseEvent } from "react"; -import { useEffect, useCallback, useRef, useState } from "react"; +import { useEffect, useCallback, useRef, useState, useMemo } from "react"; import { t } from "ttag"; import { push } from "react-router-redux"; import { withRouter } from "react-router"; @@ -19,7 +19,11 @@ import { getSetting } from "metabase/selectors/settings"; import { RecentsList } from "metabase/nav/components/search/RecentsList"; import type { SearchAwareLocation, WrappedResult } from "metabase/search/types"; -import { getSearchTextFromLocation } from "metabase/search/utils"; +import { + getFiltersFromLocation, + getSearchTextFromLocation, + isSearchPageLocation, +} from "metabase/search/utils"; import { SearchResultsDropdown } from "metabase/nav/components/search/SearchResultsDropdown"; import { SearchInputContainer, @@ -52,6 +56,11 @@ function SearchBarView({ location, onSearchActive, onSearchInactive }: Props) { getSearchTextFromLocation(location), ); + const searchFilters = useMemo( + () => getFiltersFromLocation(location), + [location], + ); + const [isActive, { turnOn: setActive, turnOff: setInactive }] = useToggle(false); @@ -136,11 +145,18 @@ function SearchBarView({ location, onSearchActive, onSearchInactive }: Props) { }, [previousLocation, location, setInactive]); const goToSearchApp = useCallback(() => { + const shouldPersistFilters = isSearchPageLocation(previousLocation); + const filters = shouldPersistFilters ? searchFilters : {}; + + const query = { + q: searchText.trim(), + ...filters, + }; onChangeLocation({ pathname: "search", - query: { q: searchText.trim() }, + query, }); - }, [onChangeLocation, searchText]); + }, [onChangeLocation, previousLocation, searchFilters, searchText]); const handleInputKeyPress = useCallback( e => { diff --git a/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.unit.spec.tsx b/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.unit.spec.tsx index 95ccd47cda11868e943d608340b281d712b253a1..43c4a8012b4d38f52cdd8ce4ab37abd53eeede9d 100644 --- a/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.unit.spec.tsx +++ b/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.unit.spec.tsx @@ -5,15 +5,19 @@ import { screen, within, waitForLoaderToBeRemoved, + waitFor, } from "__support__/ui"; import { + setupCollectionsEndpoints, setupRecentViewsEndpoints, setupSearchEndpoints, + setupUsersEndpoints, } from "__support__/server-mocks"; import { createMockCollectionItem, createMockModelObject, createMockRecentItem, + createMockUser, } from "metabase-types/api/mocks"; import { createMockSettingsState, @@ -61,6 +65,8 @@ const setup = ({ setupSearchEndpoints(searchResultItems); setupRecentViewsEndpoints(recentViewsItems); + setupUsersEndpoints([createMockUser()]); + setupCollectionsEndpoints({ collections: [] }); const { history } = renderWithProviders( <Route path="*" component={SearchBar} />, @@ -120,9 +126,19 @@ describe("SearchBar", () => { userEvent.click(getSearchBar()); userEvent.type(getSearchBar(), "BC"); + // wait for dropdown to open + await waitForLoaderToBeRemoved(); + const resultItems = await screen.findAllByTestId("search-result-item"); expect(resultItems.length).toBe(2); + // wait for all of the elements of the search result to load + await waitFor(() => { + expect( + screen.queryByTestId("info-text-collection-loading-text"), + ).not.toBeInTheDocument(); + }); + // There are two search results, each with a link to `Our analytics`, // so we want to navigate to the search result, then the collection link. for (const cardName of ["Card ABC", "Card BCD"]) { @@ -133,7 +149,7 @@ describe("SearchBar", () => { ); expect(filteredElement).not.toBeUndefined(); - expect(filteredElement).toHaveFocus(); + expect(screen.getByText(cardName)).toHaveFocus(); userEvent.tab(); @@ -161,4 +177,34 @@ describe("SearchBar", () => { expect(getSearchBar()).toHaveValue(""); }); }); + + describe("persisting search filters", () => { + it("should keep URL search filters when changing the text query", () => { + const { history } = setup({ + initialRoute: "/search?q=foo&type=card", + }); + + userEvent.clear(getSearchBar()); + userEvent.type(getSearchBar(), "bar{enter}"); + + const location = history.getCurrentLocation(); + + expect(location.pathname).toEqual("search"); + expect(location.search).toEqual("?q=bar&type=card"); + }); + + it("should not keep URL search filters when not in the search app", () => { + const { history } = setup({ + initialRoute: "/collection/root?q=foo&type=card&type=dashboard", + }); + + userEvent.clear(getSearchBar()); + userEvent.type(getSearchBar(), "bar{enter}"); + + const location = history.getCurrentLocation(); + + expect(location.pathname).toEqual("search"); + expect(location.search).toEqual("?q=bar"); + }); + }); }); diff --git a/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.styled.tsx b/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.styled.tsx index 22ce7fea167bfbe3f2dd1f3e8222b68eacf53eec..ef8105274552444438ab8735721ba098fe31ba5a 100644 --- a/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.styled.tsx +++ b/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.styled.tsx @@ -1,11 +1,20 @@ import styled from "@emotion/styled"; +import { Stack } from "metabase/ui"; export const EmptyStateContainer = styled.div` margin-top: 4rem; margin-bottom: 2rem; `; -export const SearchResultsList = styled.ul` +export const SearchResultsList = styled(Stack)` + overflow: hidden; +`; + +export const ResultsContainer = styled.ul` overflow-y: auto; - padding: 0.5rem 0; + padding: 0.5rem; +`; + +export const ResultsFooter = styled.li` + list-style-type: none; `; diff --git a/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.tsx b/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.tsx index 0dad26fcaaedba52102d1185919924ccca3b03a8..08ef8046b21fba1b9210d1a33d9d10b017157d4c 100644 --- a/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.tsx +++ b/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { t } from "ttag"; import { push } from "react-router-redux"; import { useDebounce } from "react-use"; @@ -21,20 +21,40 @@ import type { } from "metabase-types/api"; import { EmptyStateContainer, + ResultsContainer, + ResultsFooter, SearchResultsList, } from "metabase/nav/components/search/SearchResults/SearchResults.styled"; +export type SearchResultsFooter = + | (({ + metadata, + isSelected, + }: { + metadata: Omit<SearchResultsType, "data">; + isSelected?: boolean; + }) => JSX.Element | null) + | null; + export type SearchResultsProps = { onEntitySelect?: (result: any) => void; forceEntitySelect?: boolean; searchText?: string; searchFilters?: SearchFilters; models?: SearchModelType[]; - footerComponent?: - | ((metadata: Omit<SearchResultsType, "data">) => JSX.Element | null) - | null; + footerComponent?: SearchResultsFooter; + onFooterSelect?: () => void; }; +export const SearchLoadingSpinner = () => ( + <Stack p="xl" align="center"> + <Loader size="lg" data-testid="loading-spinner" /> + <Text size="xl" color="text.0"> + {t`Loading…`} + </Text> + </Stack> +); + export const SearchResults = ({ onEntitySelect, forceEntitySelect = false, @@ -42,10 +62,12 @@ export const SearchResults = ({ searchFilters = {}, models, footerComponent, + onFooterSelect, }: SearchResultsProps) => { const dispatch = useDispatch(); const [debouncedSearchText, setDebouncedSearchText] = useState<string>(); + const isWaitingForDebounce = searchText !== debouncedSearchText; useDebounce( @@ -73,14 +95,33 @@ export const SearchResults = ({ enabled: !!debouncedSearchText, }); + const hasResults = list.length > 0; + const showFooter = hasResults && footerComponent && metadata; + + const dropdownItemList = useMemo(() => { + return showFooter ? [...list, footerComponent] : list; + }, [footerComponent, list, showFooter]); + + const onEnterSelect = (item?: CollectionItem | SearchResultsFooter) => { + if (showFooter && cursorIndex === dropdownItemList.length - 1) { + onFooterSelect?.(); + } + + if (item && typeof item !== "function") { + if (onEntitySelect) { + onEntitySelect(Search.wrapEntity(item, dispatch)); + } else if (item && item.getUrl) { + dispatch(push(item.getUrl())); + } + } + }; + const { reset, getRef, cursorIndex } = useListKeyboardNavigation< - CollectionItem, + CollectionItem | SearchResultsProps["footerComponent"], HTMLLIElement >({ - list, - onEnter: onEntitySelect - ? item => onEntitySelect(Search.wrapEntity(item, dispatch)) - : item => dispatch(push(item.getUrl())), + list: dropdownItemList, + onEnter: onEnterSelect, resetOnListChange: false, }); @@ -88,51 +129,47 @@ export const SearchResults = ({ reset(); }, [searchText, reset]); - const hasResults = list.length > 0; - const showFooter = hasResults && footerComponent && metadata; - if (isLoading || isWaitingForDebounce) { - return ( - <Stack p="xl" align="center"> - <Loader size="lg" data-testid="loading-spinner" /> - <Text size="xl" color="text.0"> - {t`Loading…`} - </Text> - </Stack> - ); + return <SearchLoadingSpinner />; } - return ( - <> - <SearchResultsList data-testid="search-results-list"> - {hasResults ? ( - list.map((item, index) => { - const isIndexedEntity = item.model === "indexed-entity"; - const onClick = - onEntitySelect && (isIndexedEntity || forceEntitySelect) - ? onEntitySelect - : undefined; - const ref = getRef(item); - const wrappedResult = Search.wrapEntity(item, dispatch); - - return ( - <li key={`${item.model}:${item.id}`} ref={ref}> - <SearchResult - result={wrappedResult} - compact={true} - isSelected={cursorIndex === index} - onClick={onClick} - /> - </li> - ); - }) - ) : ( - <EmptyStateContainer> - <EmptyState message={t`Didn't find anything`} icon="search" /> - </EmptyStateContainer> - )} - </SearchResultsList> - {showFooter && footerComponent(metadata)} - </> + return hasResults ? ( + <SearchResultsList data-testid="search-results-list" spacing={0}> + <ResultsContainer> + {list.map((item, index) => { + const isIndexedEntity = item.model === "indexed-entity"; + const onClick = + onEntitySelect && (isIndexedEntity || forceEntitySelect) + ? onEntitySelect + : undefined; + const ref = getRef(item); + const wrappedResult = Search.wrapEntity(item, dispatch); + + return ( + <li key={`${item.model}:${item.id}`} ref={ref}> + <SearchResult + result={wrappedResult} + compact={true} + showDescription={true} + isSelected={cursorIndex === index} + onClick={onClick} + /> + </li> + ); + })} + </ResultsContainer> + {showFooter && ( + <ResultsFooter ref={getRef(footerComponent)}> + {footerComponent({ + metadata, + isSelected: cursorIndex === dropdownItemList.length - 1, + })} + </ResultsFooter> + )} + </SearchResultsList> + ) : ( + <EmptyStateContainer data-testid="search-results-empty-state"> + <EmptyState message={t`Didn't find anything`} icon="search" /> + </EmptyStateContainer> ); }; diff --git a/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.unit.spec.tsx b/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.unit.spec.tsx index daa5a25f7241347ae887f56d83e6af9ab40511a3..1c51d30049c9353d4aa5960190e2eed723c3a13f 100644 --- a/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.unit.spec.tsx +++ b/frontend/src/metabase/nav/components/search/SearchResults/SearchResults.unit.spec.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/prop-types */ import userEvent from "@testing-library/user-event"; import { Route } from "react-router"; import { @@ -5,23 +6,29 @@ import { screen, waitForLoaderToBeRemoved, } from "__support__/ui"; -import { setupSearchEndpoints } from "__support__/server-mocks"; -import type { - SearchResult, - SearchResults as SearchResultsType, -} from "metabase-types/api"; -import { createMockSearchResult } from "metabase-types/api/mocks"; +import { + setupCollectionByIdEndpoint, + setupSearchEndpoints, + setupUsersEndpoints, +} from "__support__/server-mocks"; +import type { SearchResult } from "metabase-types/api"; +import { + createMockCollection, + createMockSearchResult, + createMockUser, +} from "metabase-types/api/mocks"; import { checkNotNull } from "metabase/core/utils/types"; +import type { SearchResultsFooter } from "metabase/nav/components/search/SearchResults"; import { SearchResults } from "metabase/nav/components/search/SearchResults"; type SearchResultsSetupProps = { searchResults?: SearchResult[]; forceEntitySelect?: boolean; searchText?: string; - footer?: ((metadata: Omit<SearchResultsType, "data">) => JSX.Element) | null; + footer?: SearchResultsFooter; }; -const TEST_FOOTER = (metadata: Omit<SearchResultsType, "data">) => ( +const TEST_FOOTER: SearchResultsFooter = ({ metadata }) => ( <div data-testid="footer"> <div data-testid="test-total">{metadata.total}</div> </div> @@ -41,6 +48,10 @@ const setup = async ({ footer = null, }: SearchResultsSetupProps = {}) => { setupSearchEndpoints(searchResults); + setupUsersEndpoints([createMockUser()]); + setupCollectionByIdEndpoint({ + collections: [createMockCollection()], + }); const onEntitySelect = jest.fn(); diff --git a/frontend/src/metabase/nav/components/search/SearchResults/index.ts b/frontend/src/metabase/nav/components/search/SearchResults/index.ts index 98ef71eaff9624e7d286d356892b5f697b023218..79c3e1de483d676401502446a9da57a931b96874 100644 --- a/frontend/src/metabase/nav/components/search/SearchResults/index.ts +++ b/frontend/src/metabase/nav/components/search/SearchResults/index.ts @@ -1 +1,3 @@ -export { SearchResults } from "./SearchResults"; +export { SearchResults, SearchLoadingSpinner } from "./SearchResults"; +export type { SearchResultsFooter } from "./SearchResults"; +export * from "./SearchResults.styled"; diff --git a/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.styled.tsx b/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.styled.tsx index 295d9730a1e66212a8545bf2b0a66801c6bfdd63..c0f522d76d6633bb64478b6c29ad349ae941d4d0 100644 --- a/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.styled.tsx +++ b/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.styled.tsx @@ -1,10 +1,12 @@ +import type { Theme } from "@emotion/react"; +import { css } from "@emotion/react"; import styled from "@emotion/styled"; import { breakpointMaxSmall, breakpointMinSmall, } from "metabase/styled-components/theme"; import { APP_BAR_HEIGHT } from "metabase/nav/constants"; -import type { PaperProps } from "metabase/ui"; +import type { PaperProps, GroupProps } from "metabase/ui"; import { Group, Paper } from "metabase/ui"; export const SearchResultsContainer = styled(Paper)<PaperProps>` @@ -20,12 +22,20 @@ export const SearchResultsContainer = styled(Paper)<PaperProps>` } `; -export const SearchDropdownFooter = styled(Group)` +const selectedStyles = ({ theme }: { theme: Theme }) => css` + color: ${theme.colors.brand[1]}; + background-color: ${theme.colors.brand[0]}; + cursor: pointer; + transition: all 0.2s ease-in-out; +`; + +export const SearchDropdownFooter = styled(Group, { + shouldForwardProp: propName => propName !== "isSelected", +})<{ isSelected?: boolean } & GroupProps>` border-top: 1px solid ${({ theme }) => theme.colors.border[0]}; + ${({ theme, isSelected }) => isSelected && selectedStyles({ theme })} &:hover { - color: ${({ theme }) => theme.colors.brand[1]}; - cursor: pointer; - transition: color 0.2s ease-in-out; + ${({ theme }) => selectedStyles({ theme })} } `; diff --git a/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.tsx b/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.tsx index 046f42b351b50f80e003b9d034cfe9a1b1986e36..8a0702eaee02792bb366f23f25d0e0f63b6826d8 100644 --- a/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.tsx +++ b/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.tsx @@ -1,13 +1,14 @@ -import { jt } from "ttag"; +import { jt, t } from "ttag"; import { SearchResults } from "metabase/nav/components/search/SearchResults"; import type { WrappedResult } from "metabase/search/types"; import { Text } from "metabase/ui"; import { Icon } from "metabase/core/components/Icon"; -import type { SearchResultsProps } from "metabase/nav/components/search/SearchResults/SearchResults"; +import type { SearchResultsFooter } from "metabase/nav/components/search/SearchResults"; import { SearchDropdownFooter, SearchResultsContainer, } from "./SearchResultsDropdown.styled"; +import { MIN_RESULTS_FOR_FOOTER_TEXT } from "./constants"; export type SearchResultsDropdownProps = { searchText: string; @@ -20,8 +21,13 @@ export const SearchResultsDropdown = ({ onSearchItemSelect, goToSearchApp, }: SearchResultsDropdownProps) => { - const renderFooter: SearchResultsProps["footerComponent"] = metadata => - metadata.total > 1 ? ( + const renderFooter: SearchResultsFooter = ({ metadata, isSelected }) => { + const resultText = + metadata.total > MIN_RESULTS_FOR_FOOTER_TEXT + ? jt`View and filter all ${metadata.total} results` + : t`View and filter results`; + + return metadata.total > 0 ? ( <SearchDropdownFooter data-testid="search-dropdown-footer" position="apart" @@ -29,15 +35,15 @@ export const SearchResultsDropdown = ({ px="lg" py="0.625rem" onClick={goToSearchApp} + isSelected={isSelected} > - <Text - weight={700} - size="sm" - c="inherit" - >{jt`View and filter all ${metadata.total} results`}</Text> + <Text weight={700} size="sm" c="inherit"> + {resultText} + </Text> <Icon name="arrow_right" size={14} /> </SearchDropdownFooter> ) : null; + }; return ( <SearchResultsContainer @@ -48,6 +54,7 @@ export const SearchResultsDropdown = ({ searchText={searchText.trim()} onEntitySelect={onSearchItemSelect} footerComponent={renderFooter} + onFooterSelect={goToSearchApp} /> </SearchResultsContainer> ); diff --git a/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.unit.spec.tsx b/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.unit.spec.tsx index a1392d517e0fa65fb2271de5ba4c6df2d60fad15..43c3886123b438bc4295f9b373970dd6004fbd02 100644 --- a/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.unit.spec.tsx +++ b/frontend/src/metabase/nav/components/search/SearchResultsDropdown/SearchResultsDropdown.unit.spec.tsx @@ -5,15 +5,41 @@ import { screen, waitForLoaderToBeRemoved, } from "__support__/ui"; -import { createMockSearchResult } from "metabase-types/api/mocks"; -import { setupSearchEndpoints } from "__support__/server-mocks"; +import { + createMockCollection, + createMockSearchResult, + createMockUser, +} from "metabase-types/api/mocks"; +import { + setupCollectionByIdEndpoint, + setupSearchEndpoints, + setupUsersEndpoints, +} from "__support__/server-mocks"; import type { SearchResult } from "metabase-types/api"; import { checkNotNull } from "metabase/core/utils/types"; import { SearchResultsDropdown } from "./SearchResultsDropdown"; +// Mock MIN_RESULTS_FOR_FOOTER_TEXT so we don't have to generate a ton of elements for the footer test +jest.mock( + "metabase/nav/components/search/SearchResultsDropdown/constants", + () => ({ + MIN_RESULTS_FOR_FOOTER_TEXT: 1, + }), +); + +const TEST_COLLECTION = createMockCollection(); + const TEST_SEARCH_RESULTS = [ - createMockSearchResult({ id: 1, name: "Test 1" }), - createMockSearchResult({ id: 2, name: "Test 2" }), + createMockSearchResult({ + id: 1, + name: "Test 1", + collection: TEST_COLLECTION, + }), + createMockSearchResult({ + id: 2, + name: "Test 2", + collection: TEST_COLLECTION, + }), createMockSearchResult({ id: 3, name: "Indexed Entity", @@ -29,6 +55,10 @@ const setup = async ({ const goToSearchApp = jest.fn(); setupSearchEndpoints(searchResults); + setupUsersEndpoints([createMockUser()]); + setupCollectionByIdEndpoint({ + collections: [TEST_COLLECTION], + }); const { history } = renderWithProviders( <Route @@ -58,11 +88,9 @@ describe("SearchResultsDropdown", () => { expect(searchItem).toHaveTextContent("Test 1"); - const href = checkNotNull(searchItem.getAttribute("href")); - userEvent.click(searchItem); - expect(history.getCurrentLocation().pathname).toEqual(href); + expect(history.getCurrentLocation().pathname).toEqual("/question/1-test-1"); }); it("should call goToSearchApp when the footer is clicked", async () => { @@ -88,8 +116,21 @@ describe("SearchResultsDropdown", () => { ).not.toBeInTheDocument(); }); - it("should render the footer if there are search results", async () => { - await setup(); + it("should only render 'View all results' if there are less than MAX_SEARCH_RESULTS_FOR_FOOTER results for the footer", async () => { + await setup({ searchResults: TEST_SEARCH_RESULTS.slice(0, 1) }); + expect(screen.getByTestId("search-dropdown-footer")).toBeInTheDocument(); + expect(screen.getByText("View and filter results")).toBeInTheDocument(); + }); + + it("should render 'View all X results' if there are more than MAX_SEARCH_RESULTS_FOR_FOOTER results for the footer", async () => { + await setup({ + searchText: "e", + }); expect(screen.getByTestId("search-dropdown-footer")).toBeInTheDocument(); + expect( + screen.getByText( + `View and filter all ${TEST_SEARCH_RESULTS.length} results`, + ), + ).toBeInTheDocument(); }); }); diff --git a/frontend/src/metabase/nav/components/search/SearchResultsDropdown/constants.ts b/frontend/src/metabase/nav/components/search/SearchResultsDropdown/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8960e8eadec11f18867cc9c23baf139326cf159 --- /dev/null +++ b/frontend/src/metabase/nav/components/search/SearchResultsDropdown/constants.ts @@ -0,0 +1 @@ +export const MIN_RESULTS_FOR_FOOTER_TEXT = 4; diff --git a/frontend/src/metabase/parameters/utils/date-formatting.ts b/frontend/src/metabase/parameters/utils/date-formatting.ts index e74acd1ec91bb08e8626e6d6b46386c5f9a3700e..05d64e483a1cb955b310067966b558bf47fbe012 100644 --- a/frontend/src/metabase/parameters/utils/date-formatting.ts +++ b/frontend/src/metabase/parameters/utils/date-formatting.ts @@ -100,7 +100,7 @@ const prefixedOperators = new Set([ "not-empty", ]); -function getFilterTitle(filter: any[]) { +export function getFilterTitle(filter: any[]) { const values = generateTimeFilterValuesDescriptions(filter); const desc = values.length > 2 diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index cef4b790072dac8ca25afc956fec96b558cd5177..eb232f93b91d809966c495b6c0d69f962cd4c626 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -24,6 +24,7 @@ import type { } from "metabase-types/api"; import type { AdminPathKey, State } from "metabase-types/store"; import type { ADMIN_SETTINGS_SECTIONS } from "metabase/admin/settings/selectors"; +import type { SearchFilterComponent } from "metabase/search/types"; import type Question from "metabase-lib/Question"; import type Database from "metabase-lib/metadata/Database"; @@ -159,8 +160,11 @@ export const PLUGIN_COLLECTIONS = { ): AuthorityLevelMenuItem[] => [], }; -type CollectionAuthorityLevelIcon = ComponentType< - Omit<IconProps, "name" | "tooltip"> & { collection: Collection } +export type CollectionAuthorityLevelIcon = ComponentType< + Omit<IconProps, "name" | "tooltip"> & { + collection: Pick<Collection, "authority_level">; + tooltip?: "default" | "belonging"; + } >; type FormCollectionAuthorityLevelPicker = ComponentType< @@ -286,3 +290,7 @@ export const PLUGIN_MODEL_PERSISTENCE = { export const PLUGIN_EMBEDDING = { isEnabled: () => false, }; + +export const PLUGIN_CONTENT_VERIFICATION = { + VerifiedFilter: {} as SearchFilterComponent<"verified">, +}; diff --git a/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx b/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx index bcf837801d1d3de6985120a30930cbdff4005c72..afab2e4064c643d31c365ff6caa0cb29ff77326c 100644 --- a/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx +++ b/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx @@ -3,7 +3,7 @@ import PropTypes from "prop-types"; import { t } from "ttag"; import { Icon } from "metabase/core/components/Icon"; -import { SearchResult } from "metabase/search/components/SearchResult"; +import { SearchResult } from "metabase/search/components/SearchResult/SearchResult"; import { DEFAULT_SEARCH_LIMIT } from "metabase/lib/constants"; import Search from "metabase/entities/search"; @@ -55,7 +55,7 @@ export function SearchResults({ result={item} onClick={onSelect} compact - hasDescription={false} + showDescription={false} /> </li> ))} diff --git a/frontend/src/metabase/search/components/CollectionBadge.styled.tsx b/frontend/src/metabase/search/components/CollectionBadge.styled.tsx index 59044907a05b1839d2e2245d5e036fcf2004f15b..9892f907d661ad1bf5b789e280b8453793215650 100644 --- a/frontend/src/metabase/search/components/CollectionBadge.styled.tsx +++ b/frontend/src/metabase/search/components/CollectionBadge.styled.tsx @@ -2,10 +2,6 @@ import styled from "@emotion/styled"; import { color } from "metabase/lib/colors"; import Link from "metabase/core/components/Link"; -import { PLUGIN_COLLECTION_COMPONENTS } from "metabase/plugins"; - -const { CollectionAuthorityLevelIcon } = PLUGIN_COLLECTION_COMPONENTS; - export const CollectionBadgeRoot = styled.div` display: inline-block; `; @@ -14,15 +10,8 @@ export const CollectionLink = styled(Link)` display: flex; align-items: center; text-decoration: dashed; + &:hover { color: ${color("brand")}; } `; - -export const AuthorityLevelIcon = styled(CollectionAuthorityLevelIcon)` - padding-right: 2px; -`; - -AuthorityLevelIcon.defaultProps = { - size: 13, -}; diff --git a/frontend/src/metabase/search/components/DropdownSidebarFilter/DropdownSidebarFilter.styled.tsx b/frontend/src/metabase/search/components/DropdownSidebarFilter/DropdownSidebarFilter.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c1b8b67db255182e91660d17005258696ccd1bb0 --- /dev/null +++ b/frontend/src/metabase/search/components/DropdownSidebarFilter/DropdownSidebarFilter.styled.tsx @@ -0,0 +1,54 @@ +import styled from "@emotion/styled"; +import { Group } from "metabase/ui"; + +import FieldSet from "metabase/components/FieldSet"; +import EventSandbox from "metabase/components/EventSandbox"; +import { Icon } from "metabase/core/components/Icon"; + +export const DropdownFieldSet = styled(FieldSet)<{ + fieldHasValueOrFocus?: boolean; +}>` + min-width: 0; + text-overflow: ellipsis; + overflow: hidden; + + border: 2px solid + ${({ theme, fieldHasValueOrFocus }) => + fieldHasValueOrFocus ? theme.colors.brand[1] : theme.colors.border[0]}; + + margin: 0; + padding: 0.5rem 0.75rem; + + cursor: pointer; + + legend { + min-width: 0; + max-width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + + text-transform: none; + position: relative; + height: 2px; + line-height: 0; + margin-left: -0.45em; + padding: 0 0.5em; + } + + &, + legend { + color: ${({ theme, fieldHasValueOrFocus }) => + fieldHasValueOrFocus && theme.colors.brand[1]}; + } +`; + +export const DropdownLabelIcon = styled(Icon)` + overflow: visible; +`; +export const GroupOverflowHidden = styled(Group)` + overflow: hidden; +`; + +export const SearchEventSandbox = styled(EventSandbox)` + display: contents; +`; diff --git a/frontend/src/metabase/search/components/DropdownSidebarFilter/DropdownSidebarFilter.tsx b/frontend/src/metabase/search/components/DropdownSidebarFilter/DropdownSidebarFilter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1b4e908fb464bd97df712027175ed5ef0093053f --- /dev/null +++ b/frontend/src/metabase/search/components/DropdownSidebarFilter/DropdownSidebarFilter.tsx @@ -0,0 +1,163 @@ +/* eslint-disable react/prop-types */ +import { isEmpty } from "underscore"; +import type { MouseEvent } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; +import type { + FilterTypeKeys, + SearchFilterComponentProps, + SearchFilterDropdown, + SearchFilterPropTypes, +} from "metabase/search/types"; +import { Text, Box, Center, Button, Stack } from "metabase/ui"; +import type { IconName } from "metabase/core/components/Icon"; +import { Icon } from "metabase/core/components/Icon"; +import Popover from "metabase/components/Popover"; +import { useSelector } from "metabase/lib/redux"; +import { getIsNavbarOpen } from "metabase/selectors/app"; +import useIsSmallScreen from "metabase/hooks/use-is-small-screen"; +import { isNotNull } from "metabase/core/utils/types"; +import { + GroupOverflowHidden, + DropdownFieldSet, + DropdownLabelIcon, + SearchEventSandbox, +} from "./DropdownSidebarFilter.styled"; + +export type DropdownSidebarFilterProps<T extends FilterTypeKeys = any> = { + filter: SearchFilterDropdown<T>; +} & SearchFilterComponentProps<T>; + +export const DropdownSidebarFilter = ({ + filter: { label, iconName, DisplayComponent, ContentComponent }, + "data-testid": dataTestId, + value, + onChange, +}: DropdownSidebarFilterProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const isNavbarOpen = useSelector(getIsNavbarOpen); + const isSmallScreen = useIsSmallScreen(); + + const dropdownRef = useRef<HTMLDivElement>(null); + const [popoverWidth, setPopoverWidth] = useState<string>(); + + const fieldHasValue = Array.isArray(value) + ? !isEmpty(value) + : isNotNull(value); + + const handleResize = () => { + if (dropdownRef.current) { + const { width } = dropdownRef.current.getBoundingClientRect(); + setPopoverWidth(`${width}px`); + } + }; + + useLayoutEffect(() => { + if (!popoverWidth) { + handleResize(); + } + window.addEventListener("resize", handleResize, false); + return () => window.removeEventListener("resize", handleResize, false); + }, [dropdownRef, popoverWidth]); + + useLayoutEffect(() => { + if (isNavbarOpen && isSmallScreen) { + setIsPopoverOpen(false); + } + }, [isNavbarOpen, isSmallScreen]); + + const onApplyFilter = (value: SearchFilterPropTypes) => { + onChange(value); + setIsPopoverOpen(false); + }; + + const onClearFilter = (e: MouseEvent) => { + if (fieldHasValue) { + e.stopPropagation(); + onChange(null); + setIsPopoverOpen(false); + } + }; + + const onPopoverClose = () => { + setIsPopoverOpen(false); + }; + + const getDropdownIcon = (): IconName => { + if (fieldHasValue) { + return "close"; + } else { + return isPopoverOpen ? "chevronup" : "chevrondown"; + } + }; + + return ( + <Box + data-testid={dataTestId} + ref={dropdownRef} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + w="100%" + mt={fieldHasValue ? "0.25rem" : 0} + > + <DropdownFieldSet + noPadding + legend={fieldHasValue ? label() : null} + fieldHasValueOrFocus={fieldHasValue} + > + <GroupOverflowHidden position="apart" noWrap w="100%"> + {fieldHasValue ? ( + <DisplayComponent value={value} /> + ) : ( + <GroupOverflowHidden noWrap> + {iconName && <DropdownLabelIcon size={16} name={iconName} />} + <Text weight={700} truncate> + {label()} + </Text> + </GroupOverflowHidden> + )} + <Button + data-testid="sidebar-filter-dropdown-button" + compact + mr="0.25rem" + size="xs" + c="inherit" + variant="subtle" + onClick={onClearFilter} + leftIcon={ + <Center m="-0.25rem"> + <Icon size={16} name={getDropdownIcon()} /> + </Center> + } + /> + </GroupOverflowHidden> + </DropdownFieldSet> + + <Popover + isOpen={isPopoverOpen} + onClose={onPopoverClose} + target={dropdownRef.current} + ignoreTrigger + autoWidth + sizeToFit + pinInitialAttachment + horizontalAttachments={["right"]} + > + {({ maxHeight }: { maxHeight: number }) => + popoverWidth && ( + <SearchEventSandbox> + {popoverWidth && ( + <Stack mah={maxHeight}> + <ContentComponent + value={value} + onChange={selected => onApplyFilter(selected)} + width={popoverWidth} + /> + </Stack> + )} + </SearchEventSandbox> + ) + } + </Popover> + </Box> + ); +}; diff --git a/frontend/src/metabase/search/components/SidebarFilter/SidebarFilter.unit.spec.tsx b/frontend/src/metabase/search/components/DropdownSidebarFilter/DropdownSidebarFilter.unit.spec.tsx similarity index 65% rename from frontend/src/metabase/search/components/SidebarFilter/SidebarFilter.unit.spec.tsx rename to frontend/src/metabase/search/components/DropdownSidebarFilter/DropdownSidebarFilter.unit.spec.tsx index 6d7391fd09d6619851f3e14442fa0086c136d51c..8eacbdf4c193987fcb2d241001f40159adad8403 100644 --- a/frontend/src/metabase/search/components/SidebarFilter/SidebarFilter.unit.spec.tsx +++ b/frontend/src/metabase/search/components/DropdownSidebarFilter/DropdownSidebarFilter.unit.spec.tsx @@ -2,39 +2,47 @@ import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { renderWithProviders, screen, within } from "__support__/ui"; -import type { SearchSidebarFilterComponent } from "metabase/search/types"; -import type { SearchSidebarFilterProps } from "./SidebarFilter"; -import { SidebarFilter } from "./SidebarFilter"; +import type { SearchFilterComponent } from "metabase/search/types"; +import type { DropdownSidebarFilterProps } from "./DropdownSidebarFilter"; +import { DropdownSidebarFilter } from "./DropdownSidebarFilter"; -const mockFilter: SearchSidebarFilterComponent = { - title: "Mock Filter", +const mockFilter: SearchFilterComponent = { + label: () => "Mock Filter", iconName: "filter", + type: "dropdown", DisplayComponent: ({ value }) => ( <div data-testid="mock-display-component"> {!value || value.length === 0 ? "Display" : value} </div> ), - ContentComponent: ({ value, onChange }) => ( - <div data-testid="mock-content-component"> - <button onClick={() => onChange(["new value"])}>Update</button> - <div>{value}</div> - </div> - ), + ContentComponent: ({ value, onChange }) => { + const [filterValue, setFilterValue] = useState(value); + + return ( + <div data-testid="mock-content-component"> + <button onClick={() => setFilterValue(["new value"])}>Update</button> + <div>{filterValue}</div> + <button onClick={() => onChange(filterValue)}>Apply</button> + </div> + ); + }, + fromUrl: value => value, + toUrl: value => value, }; const MockSearchSidebarFilter = ({ filter, value, onChange, -}: SearchSidebarFilterProps) => { +}: DropdownSidebarFilterProps) => { const [selectedValues, setSelectedValues] = useState(value); - const onFilterChange = (elem: SearchSidebarFilterProps["value"]) => { + const onFilterChange = (elem: DropdownSidebarFilterProps["value"]) => { setSelectedValues(elem); onChange(elem); }; return ( - <SidebarFilter + <DropdownSidebarFilter filter={filter} value={selectedValues} onChange={onFilterChange} @@ -42,14 +50,14 @@ const MockSearchSidebarFilter = ({ ); }; -const setup = (options: Partial<SearchSidebarFilterProps> = {}) => { - const defaultProps: SearchSidebarFilterProps = { +const setup = (options: Partial<DropdownSidebarFilterProps> = {}) => { + const defaultProps: DropdownSidebarFilterProps = { filter: mockFilter, value: [], onChange: jest.fn(), }; - const props: SearchSidebarFilterProps = { ...defaultProps, ...options }; + const props: DropdownSidebarFilterProps = { ...defaultProps, ...options }; renderWithProviders(<MockSearchSidebarFilter {...props} />); @@ -58,7 +66,7 @@ const setup = (options: Partial<SearchSidebarFilterProps> = {}) => { }; }; -describe("SidebarFilter", () => { +describe("DropdownSidebarFilter", () => { it("should render filter title, filter icon, chevrondown, but no legend text when no value is selected", () => { setup(); @@ -73,7 +81,7 @@ describe("SidebarFilter", () => { setup({ value: ["value1"] }); expect(screen.getByTestId("field-set-legend")).toHaveTextContent( - mockFilter.title, + mockFilter.label(), ); expect(screen.getByTestId("mock-display-component")).toBeInTheDocument(); @@ -97,7 +105,8 @@ describe("SidebarFilter", () => { userEvent.click(screen.getByRole("button", { name: "Update" })); expect(screen.getByText("new value")).toBeInTheDocument(); - userEvent.click(screen.getByRole("button", { name: "Apply filters" })); + userEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(onChange).toHaveBeenCalledWith(["new value"]); expect(screen.getByTestId("mock-display-component")).toHaveTextContent( @@ -112,22 +121,33 @@ describe("SidebarFilter", () => { it("should revert filter selections when popover is closed", async () => { const { onChange } = setup({ value: ["old value"] }); - userEvent.click(screen.getByText("Mock Filter")); + userEvent.click(screen.getByText("old value")); userEvent.click(screen.getByRole("button", { name: "Update" })); expect(screen.getByText("new value")).toBeInTheDocument(); - userEvent.click(screen.getByText("Mock Filter")); + userEvent.click(screen.getByText("old value")); expect(onChange).not.toHaveBeenCalled(); expect(screen.getByTestId("mock-display-component")).toHaveTextContent( "old value", ); + + userEvent.click(screen.getByText("old value")); + expect(screen.getByTestId("mock-display-component")).toHaveTextContent( + "old value", + ); }); it("should reset filter selections when clear button is clicked", async () => { const { onChange } = setup({ value: ["old value"] }); - userEvent.click(screen.getByTestId("sidebar-filter-dropdown-button")); + userEvent.click( + screen.getByTestId("sidebar-filter-dropdown-button"), + undefined, + // There's a problem with buttons in fieldsets so we have to skip pointer events check for now + // https://github.com/testing-library/user-event/issues/662 + { skipPointerEventsCheck: true }, + ); - expect(onChange).toHaveBeenCalledWith(undefined); + expect(onChange).toHaveBeenCalledWith(null); expect(screen.getByText("Mock Filter")).toBeInTheDocument(); expect(screen.getByLabelText("filter icon")).toBeInTheDocument(); expect(screen.getByLabelText("chevrondown icon")).toBeInTheDocument(); diff --git a/frontend/src/metabase/search/components/DropdownSidebarFilter/index.ts b/frontend/src/metabase/search/components/DropdownSidebarFilter/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b997ef19ce4380f3a1b976168bffb9ebb53387e --- /dev/null +++ b/frontend/src/metabase/search/components/DropdownSidebarFilter/index.ts @@ -0,0 +1 @@ +export { DropdownSidebarFilter } from "./DropdownSidebarFilter"; diff --git a/frontend/src/metabase/search/components/InfoText.tsx b/frontend/src/metabase/search/components/InfoText.tsx deleted file mode 100644 index 78cc2530a8936698fbbcc554034e7ee508283484..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/search/components/InfoText.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { t, jt } from "ttag"; - -import * as Urls from "metabase/lib/urls"; - -import { Icon } from "metabase/core/components/Icon"; -import Link from "metabase/core/components/Link"; - -import Schema from "metabase/entities/schemas"; -import Database from "metabase/entities/databases"; -import Table from "metabase/entities/tables"; -import { PLUGIN_COLLECTIONS } from "metabase/plugins"; -import { getTranslatedEntityName } from "metabase/common/utils/model-names"; - -import type { Collection } from "metabase-types/api"; -import type { WrappedResult } from "metabase/search/types"; -import type TableType from "metabase-lib/metadata/Table"; - -import { CollectionBadge } from "./CollectionBadge"; - -export function InfoText({ result }: { result: WrappedResult }) { - let textContent: string | string[] | JSX.Element; - - switch (result.model) { - case "card": - textContent = jt`Saved question in ${formatCollection( - result, - result.getCollection(), - )}`; - break; - case "dataset": - textContent = jt`Model in ${formatCollection( - result, - result.getCollection(), - )}`; - break; - case "collection": - textContent = getCollectionInfoText(result.collection); - break; - case "database": - textContent = t`Database`; - break; - case "table": - textContent = <TablePath result={result} />; - break; - case "segment": - textContent = jt`Segment of ${(<TableLink result={result} />)}`; - break; - case "metric": - textContent = jt`Metric for ${(<TableLink result={result} />)}`; - break; - case "action": - textContent = jt`for ${result.model_name}`; - break; - case "indexed-entity": - textContent = jt`in ${result.model_name}`; - break; - default: - textContent = jt`${getTranslatedEntityName( - result.model, - )} in ${formatCollection(result, result.getCollection())}`; - break; - } - - return <>{textContent}</>; -} - -function formatCollection( - result: WrappedResult, - collection: Partial<Collection>, -) { - return ( - collection.id && ( - <CollectionBadge key={result.model} collection={collection} /> - ) - ); -} - -function getCollectionInfoText(collection: Partial<Collection>) { - if ( - PLUGIN_COLLECTIONS.isRegularCollection(collection) || - !collection.authority_level - ) { - return t`Collection`; - } - const level = PLUGIN_COLLECTIONS.AUTHORITY_LEVEL[collection.authority_level]; - return `${level.name} ${t`Collection`}`; -} - -function TablePath({ result }: { result: WrappedResult }) { - return ( - <> - {jt`Table in ${( - <span key="table-path"> - <Database.Link id={result.database_id} />{" "} - {result.table_schema && ( - <Schema.ListLoader - query={{ dbId: result.database_id }} - loadingAndErrorWrapper={false} - > - {({ list }: { list: typeof Schema[] }) => - list?.length > 1 ? ( - <span> - <Icon name="chevronright" size={10} /> - {/* we have to do some {} manipulation here to make this look like the table object that browseSchema was written for originally */} - <Link - to={Urls.browseSchema({ - db: { id: result.database_id }, - schema_name: result.table_schema, - } as TableType)} - > - {result.table_schema} - </Link> - </span> - ) : null - } - </Schema.ListLoader> - )} - </span> - )}`} - </> - ); -} - -function TableLink({ result }: { result: WrappedResult }) { - return ( - <Link to={Urls.tableRowsQuery(result.database_id, result.table_id)}> - <Table.Loader id={result.table_id} loadingAndErrorWrapper={false}> - {({ table }: { table: TableType }) => - table ? <span>{table.display_name}</span> : null - } - </Table.Loader> - </Link> - ); -} diff --git a/frontend/src/metabase/search/components/InfoText.unit.spec.js b/frontend/src/metabase/search/components/InfoText.unit.spec.js deleted file mode 100644 index 918422c06c259aa3793392d662d71c6bbb1581c0..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/search/components/InfoText.unit.spec.js +++ /dev/null @@ -1,104 +0,0 @@ -import fetchMock from "fetch-mock"; -import { renderWithProviders, screen } from "__support__/ui"; - -import { InfoText } from "./InfoText"; - -const collection = { id: 1, name: "Collection Name" }; -const table = { id: 1, display_name: "Table Name" }; -const database = { id: 1, name: "Database Name" }; - -async function setup(result) { - fetchMock.get("path:/api/table/1", table); - fetchMock.get("path:/api/database/1", database); - - renderWithProviders(<InfoText result={result} />); -} - -describe("InfoText", () => { - it("shows collection info for a question", async () => { - await setup({ - model: "card", - getCollection: () => collection, - }); - expect(screen.getByText("Saved question in")).toHaveTextContent( - "Saved question in Collection Name", - ); - }); - - it("shows collection info for a collection", async () => { - const collection = { id: 1, name: "Collection Name" }; - await setup({ - model: "collection", - collection, - }); - expect(screen.getByText("Collection")).toBeInTheDocument(); - }); - - it("shows Database for databases", async () => { - await setup({ - model: "database", - }); - expect(screen.getByText("Database")).toBeInTheDocument(); - }); - - it("shows segment's table name", async () => { - await setup({ - model: "segment", - table_id: 1, - database_id: 1, - }); - - expect(await screen.findByText("Table Name")).toBeInTheDocument(); - expect(await screen.findByText("Segment of")).toHaveTextContent( - "Segment of Table Name", - ); - }); - - it("shows metric's table name", async () => { - await setup({ - model: "metric", - table_id: 1, - database_id: 1, - }); - - expect(await screen.findByText("Table Name")).toBeInTheDocument(); - expect(await screen.findByText("Metric for")).toHaveTextContent( - "Metric for Table Name", - ); - }); - - it("shows table's schema", async () => { - await setup({ - model: "table", - table_id: 1, - database_id: 1, - }); - - expect(await screen.findByText("Database Name")).toBeInTheDocument(); - expect(await screen.findByText("Table in")).toHaveTextContent( - "Table in Database Name", - ); - }); - - it("shows pulse's collection", async () => { - await setup({ - model: "pulse", - getCollection: () => collection, - }); - - expect(screen.getByText("Pulse in")).toHaveTextContent( - "Pulse in Collection Name", - ); - }); - - it("shows dashboard's collection", async () => { - await setup({ - model: "dashboard", - getCollection: () => collection, - }); - - expect(screen.getByText("Dashboard in")).toHaveTextContent( - "Dashboard in Collection Name", - ); - }); -}); diff --git a/frontend/src/metabase/search/components/InfoText/InfoText.styled.tsx b/frontend/src/metabase/search/components/InfoText/InfoText.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..80a8dfab84ad895a7db0f51410ac3dae9801cb5d --- /dev/null +++ b/frontend/src/metabase/search/components/InfoText/InfoText.styled.tsx @@ -0,0 +1,32 @@ +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; +import LastEditInfoLabel from "metabase/components/LastEditInfoLabel"; +import { color } from "metabase/lib/colors"; +import { breakpointMaxSmall } from "metabase/styled-components/theme"; + +export const LastEditedInfoText = styled(LastEditInfoLabel)` + ${({ theme }) => { + return css` + color: ${theme.colors.text[1]}; + font-size: ${theme.fontSizes.sm}; + font-weight: 500; + + cursor: pointer; + + &:hover { + color: ${theme.colors.brand[1]}; + } + `; + }} + ${breakpointMaxSmall} { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + max-width: 50%; + } +`; + +export const LastEditedInfoTooltip = styled(LastEditInfoLabel)` + color: ${color("white")}; +`; diff --git a/frontend/src/metabase/search/components/InfoText/InfoText.tsx b/frontend/src/metabase/search/components/InfoText/InfoText.tsx new file mode 100644 index 0000000000000000000000000000000000000000..01a83b5a1cc026c87a63504b79df5e5d47a79908 --- /dev/null +++ b/frontend/src/metabase/search/components/InfoText/InfoText.tsx @@ -0,0 +1,209 @@ +/* eslint-disable react/prop-types */ +import moment from "moment-timezone"; +import { t } from "ttag"; +import { isNull } from "underscore"; +import { useDatabaseQuery, useTableQuery } from "metabase/common/hooks"; +import { + browseDatabase, + browseSchema, + tableRowsQuery, +} from "metabase/lib/urls"; +import Tooltip from "metabase/core/components/Tooltip"; +import { getRelativeTime } from "metabase/lib/time"; +import { isNotNull } from "metabase/core/utils/types"; +import type { UserListResult } from "metabase-types/api"; +import { useUserListQuery } from "metabase/common/hooks/use-user-list-query"; +import { Icon } from "metabase/core/components/Icon"; +import { SearchResultLink } from "metabase/search/components/SearchResultLink"; +import type { WrappedResult } from "metabase/search/types"; +import { Group, Box, Text } from "metabase/ui"; +import type Database from "metabase-lib/metadata/Database"; +import type { InfoTextData } from "./get-info-text"; +import { getInfoText } from "./get-info-text"; +import { LastEditedInfoText, LastEditedInfoTooltip } from "./InfoText.styled"; + +export type InfoTextProps = { + result: WrappedResult; + isCompact?: boolean; +}; + +const LinkSeparator = ( + <Box component="span" c="text.1"> + <Icon name="chevronright" size={8} /> + </Box> +); + +const InfoTextSeparator = ( + <Text span size="sm" mx="xs" c="text.1"> + • + </Text> +); + +const LoadingText = ({ "data-testid": dataTestId = "loading-text" }) => ( + <Text + color="text-1" + span + size="sm" + truncate + data-testid={dataTestId} + >{t`Loading…`}</Text> +); + +export const InfoTextTableLink = ({ result }: InfoTextProps) => { + const { data: table, isLoading } = useTableQuery({ + id: result.table_id, + }); + + const link = tableRowsQuery(result.database_id, result.table_id); + const label = table?.display_name ?? null; + + if (isLoading) { + return <LoadingText data-testid="info-text-asset-link-loading-text" />; + } + + return ( + <SearchResultLink key={label} href={link}> + {label} + </SearchResultLink> + ); +}; + +export const DatabaseLink = ({ database }: { database: Database }) => ( + <SearchResultLink key={database.name} href={browseDatabase(database)}> + {database.name} + </SearchResultLink> +); +export const TableLink = ({ result }: { result: WrappedResult }) => { + const link = browseSchema({ + db: { id: result.database_id }, + schema_name: result.table_schema, + }); + + return ( + <> + <SearchResultLink key={result.table_schema} href={link}> + {result.table_schema} + </SearchResultLink> + </> + ); +}; + +export const InfoTextTablePath = ({ result }: InfoTextProps) => { + const { data: database, isLoading: isDatabaseLoading } = useDatabaseQuery({ + id: result.database_id, + }); + + const showDatabaseLink = + !isDatabaseLoading && database && database.name !== null; + const showTableLink = showDatabaseLink && !!result.table_schema; + + if (isDatabaseLoading) { + return <LoadingText data-testid="info-text-asset-link-loading-text" />; + } + + return ( + <> + {showDatabaseLink && <DatabaseLink database={database} />} + {showTableLink && ( + <> + {LinkSeparator} + <TableLink result={result} /> + </> + )} + </> + ); +}; + +export const InfoTextAssetLink = ({ result }: InfoTextProps) => { + if (result.model === "table") { + return <InfoTextTablePath result={result} />; + } + + if (result.model === "segment" || result.model === "metric") { + return <InfoTextTableLink result={result} />; + } + + const { label, link, icon }: InfoTextData = getInfoText(result); + + return label ? ( + <SearchResultLink key={label} href={link} leftIcon={icon}> + {label} + </SearchResultLink> + ) : null; +}; + +export const InfoTextEditedInfo = ({ result, isCompact }: InfoTextProps) => { + const { data: users = [], isLoading } = useUserListQuery(); + + const isUpdated = + isNotNull(result.last_edited_at) && + !moment(result.last_edited_at).isSame(result.created_at, "seconds"); + + const { prefix, timestamp, userId } = isUpdated + ? { + prefix: t`Updated`, + timestamp: result.last_edited_at, + userId: result.last_editor_id, + } + : { + prefix: t`Created`, + timestamp: result.created_at, + userId: result.creator_id, + }; + + const user = users.find((user: UserListResult) => user.id === userId); + + const lastEditedInfoData = { + item: { + "last-edit-info": { + id: user?.id, + email: user?.email, + first_name: user?.first_name, + last_name: user?.last_name, + timestamp, + }, + }, + prefix, + }; + + if (isLoading) { + return ( + <> + {InfoTextSeparator} + <LoadingText data-testid="last-edited-info-loading-text" /> + </> + ); + } + + if (isNull(timestamp) && isNull(userId)) { + return null; + } + + const getEditedInfoText = () => { + if (isCompact) { + const formattedDuration = timestamp && getRelativeTime(timestamp); + return ( + <Tooltip tooltip={<LastEditedInfoTooltip {...lastEditedInfoData} />}> + <Text span size="sm" c="text.1" truncate> + {formattedDuration} + </Text> + </Tooltip> + ); + } + return <LastEditedInfoText {...lastEditedInfoData} />; + }; + + return ( + <> + {InfoTextSeparator} + {getEditedInfoText()} + </> + ); +}; + +export const InfoText = ({ result, isCompact }: InfoTextProps) => ( + <Group noWrap spacing="xs"> + <InfoTextAssetLink result={result} /> + <InfoTextEditedInfo result={result} isCompact={isCompact} /> + </Group> +); diff --git a/frontend/src/metabase/search/components/InfoText/InfoText.unit.spec.tsx b/frontend/src/metabase/search/components/InfoText/InfoText.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5bcc0c6dda38cf8a4cf02658d0ae96b80ebcbdfb --- /dev/null +++ b/frontend/src/metabase/search/components/InfoText/InfoText.unit.spec.tsx @@ -0,0 +1,350 @@ +import { waitFor } from "@testing-library/react"; +import moment from "moment"; +import { + setupCollectionByIdEndpoint, + setupDatabaseEndpoints, + setupTableEndpoints, + setupUsersEndpoints, +} from "__support__/server-mocks"; +import { renderWithProviders, screen } from "__support__/ui"; +import type { SearchModelType, SearchResult } from "metabase-types/api"; +import { + createMockCollection, + createMockDatabase, + createMockSearchResult, + createMockTable, + createMockUser, +} from "metabase-types/api/mocks"; +import type { IconName } from "metabase/core/components/Icon"; +import type { WrappedResult } from "metabase/search/types"; +import { InfoText } from "./InfoText"; + +const MOCK_COLLECTION = createMockCollection({ + id: 1, + name: "Collection Name", +}); +const MOCK_TABLE = createMockTable({ id: 1, display_name: "Table Name" }); +const MOCK_DATABASE = createMockDatabase({ id: 1, name: "Database Name" }); +const MOCK_USER = createMockUser(); +const MOCK_OTHER_USER = createMockUser({ + id: 2, + first_name: "John", + last_name: "Cena", + common_name: "John Cena", +}); + +const CREATED_AT_TIME = "2022-01-01T00:00:00.000Z"; +const LAST_EDITED_TIME = "2023-01-01T00:00:00.000Z"; +const formatDuration = (timestamp: string) => + moment.duration(moment().diff(moment(timestamp))).humanize(); + +const CREATED_AT_DURATION = formatDuration(CREATED_AT_TIME); +const LAST_EDITED_DURATION = formatDuration(LAST_EDITED_TIME); + +const createSearchResult = ({ + model, + ...resultProps +}: { + model: SearchModelType; +} & Partial<SearchResult>) => + createMockSearchResult({ + collection: MOCK_COLLECTION, + database_id: MOCK_DATABASE.id, + created_at: CREATED_AT_TIME, + creator_common_name: MOCK_USER.common_name, + creator_id: MOCK_USER.id, + last_edited_at: LAST_EDITED_TIME, + last_editor_common_name: MOCK_OTHER_USER.common_name, + last_editor_id: MOCK_OTHER_USER.id, + model, + ...resultProps, + }); + +async function setup({ + model = "card", + isCompact = false, + resultProps = {}, +}: { + model?: SearchModelType; + isCompact?: boolean; + resultProps?: Partial<SearchResult>; +} = {}) { + setupTableEndpoints(MOCK_TABLE); + setupDatabaseEndpoints(MOCK_DATABASE); + setupUsersEndpoints([MOCK_USER, MOCK_OTHER_USER]); + setupCollectionByIdEndpoint({ + collections: [MOCK_COLLECTION], + }); + + const result = createSearchResult({ model, ...resultProps }); + + const getUrl = jest.fn(() => "a/b/c"); + const getIcon = jest.fn(() => ({ + name: "eye" as IconName, + size: 14, + width: 14, + height: 14, + })); + const getCollection = jest.fn(() => result.collection); + + const wrappedResult: WrappedResult = { + ...result, + getUrl, + getIcon, + getCollection, + }; + + renderWithProviders( + <InfoText result={wrappedResult} isCompact={isCompact} />, + ); + + await waitFor(() => + expect( + screen.queryByTestId("info-text-asset-link-loading-text"), + ).not.toBeInTheDocument(), + ); + + // await waitforAssetLinkLoadingTextToBeRemoved() + await waitForLoadingTextToBeRemoved(); + + return { + getUrl, + getIcon, + getCollection, + }; +} + +describe("InfoText", () => { + describe("showing relevant information for each model type", () => { + it("shows collection info for a question", async () => { + await setup({ + model: "card", + }); + + const collectionLink = screen.getByText("Collection Name"); + expect(collectionLink).toBeInTheDocument(); + expect(collectionLink).toHaveAttribute( + "href", + `/collection/${MOCK_COLLECTION.id}-collection-name`, + ); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION}`, + ); + }); + + it("shows collection info for a collection", async () => { + await setup({ + model: "collection", + }); + const collectionElement = screen.getByText("Collection"); + expect(collectionElement).toBeInTheDocument(); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION}`, + ); + }); + + it("shows Database for databases", async () => { + await setup({ + model: "database", + }); + expect(screen.getByText("Database")).toBeInTheDocument(); + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION}`, + ); + }); + + it("shows segment's table name", async () => { + await setup({ + model: "segment", + }); + + const tableLink = screen.getByText(MOCK_TABLE.display_name); + expect(tableLink).toHaveAttribute( + "href", + `/question#?db=${MOCK_DATABASE.id}&table=${MOCK_TABLE.id}`, + ); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION}`, + ); + }); + + it("shows metric's table name", async () => { + await setup({ + model: "metric", + }); + + const tableLink = screen.getByText(MOCK_TABLE.display_name); + expect(tableLink).toHaveAttribute( + "href", + `/question#?db=${MOCK_DATABASE.id}&table=${MOCK_TABLE.id}`, + ); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION}`, + ); + }); + + it("shows table's schema", async () => { + await setup({ + model: "table", + }); + + const databaseLink = screen.getByText("Database Name"); + expect(databaseLink).toBeInTheDocument(); + expect(databaseLink).toHaveAttribute( + "href", + `/browse/${MOCK_DATABASE.id}-database-name`, + ); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION}`, + ); + }); + + it("shows pulse's collection", async () => { + await setup({ + model: "pulse", + }); + + const collectionLink = screen.getByText("Collection Name"); + expect(collectionLink).toBeInTheDocument(); + expect(collectionLink).toHaveAttribute( + "href", + `/collection/${MOCK_COLLECTION.id}-collection-name`, + ); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION}`, + ); + }); + + it("shows dashboard's collection", async () => { + await setup({ + model: "dashboard", + }); + + const collectionLink = screen.getByText("Collection Name"); + expect(collectionLink).toBeInTheDocument(); + expect(collectionLink).toHaveAttribute( + "href", + `/collection/${MOCK_COLLECTION.id}-collection-name`, + ); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION}`, + ); + }); + }); + + describe("showing last_edited_by vs created_by", () => { + it("should show last_edited_by when available", async () => { + await setup(); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION} ago by ${MOCK_OTHER_USER.common_name}`, + ); + }); + + it("should show created_by when last_edited_at is not available", async () => { + await setup({ + resultProps: { + last_edited_at: null, + last_editor_id: null, + created_at: null, + }, + }); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Created by you`, + ); + }); + + it("should not show user when neither last_edited_by and created_by are available", async () => { + await setup({ + resultProps: { + last_editor_id: null, + last_edited_at: null, + creator_id: null, + }, + }); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Created ${CREATED_AT_DURATION} ago`, + ); + }); + + it("should not show user when last_edited_by isn't available but last_edited_at is", async () => { + await setup({ + resultProps: { + last_editor_id: null, + creator_id: null, + }, + }); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION} ago`, + ); + }); + }); + + describe("showing last_edited_at vs created_at", () => { + it("should show last_edited_at time when available", async () => { + await setup({ + resultProps: { + last_editor_id: null, + }, + }); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Updated ${LAST_EDITED_DURATION}`, + ); + }); + + it("should show created time when last_edited_at is not available", async () => { + await setup({ + resultProps: { + last_edited_at: null, + last_editor_id: null, + creator_id: null, + }, + }); + + expect(screen.getByTestId("revision-history-button")).toHaveTextContent( + `Created ${CREATED_AT_DURATION}`, + ); + }); + + it("should not show timestamp if neither last_edited_at or created_at is available", async () => { + await setup({ + resultProps: { + last_edited_at: null, + last_editor_id: null, + created_at: null, + creator_id: null, + }, + }); + + expect(screen.queryByText("•")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("revision-history-button"), + ).not.toBeInTheDocument(); + }); + }); +}); + +async function waitForLoadingTextToBeRemoved() { + await waitFor(() => { + expect( + screen.queryByTestId("info-text-asset-link-loading-text"), + ).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.queryByTestId("last-edited-info-loading-text"), + ).not.toBeInTheDocument(); + }); +} diff --git a/frontend/src/metabase/search/components/InfoText/get-info-text.tsx b/frontend/src/metabase/search/components/InfoText/get-info-text.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31a361fb4ef90f2456af25b2e6f7f40f798d72a4 --- /dev/null +++ b/frontend/src/metabase/search/components/InfoText/get-info-text.tsx @@ -0,0 +1,77 @@ +import { t } from "ttag"; +import { + PLUGIN_COLLECTION_COMPONENTS, + PLUGIN_COLLECTIONS, +} from "metabase/plugins"; +import type { WrappedResult } from "metabase/search/types"; +import type { Collection } from "metabase-types/api"; +import { collection as collectionUrl } from "metabase/lib/urls"; + +import { Box } from "metabase/ui"; + +const { CollectionAuthorityLevelIcon } = PLUGIN_COLLECTION_COMPONENTS; + +export type InfoTextData = { + link?: string | null; + icon?: JSX.Element | null; + label?: string | null; +}; + +export const getInfoText = (result: WrappedResult): InfoTextData => { + switch (result.model) { + case "collection": + return getCollectionInfoText(result); + case "database": + return getDatabaseInfoText(); + case "action": + return getActionInfoText(result); + case "card": + case "dataset": + case "indexed-entity": + default: + return getCollectionResult(result); + } +}; +const getActionInfoText = (result: WrappedResult): InfoTextData => { + return { + label: result.model_name, + }; +}; +const getDatabaseInfoText = (): InfoTextData => { + return { + label: t`Database`, + }; +}; +const getCollectionInfoText = (result: WrappedResult): InfoTextData => { + const collection: Partial<Collection> = result.getCollection(); + + if ( + PLUGIN_COLLECTIONS.isRegularCollection(collection) || + !collection.authority_level + ) { + return { + label: t`Collection`, + }; + } + const level = PLUGIN_COLLECTIONS.AUTHORITY_LEVEL[collection.authority_level]; + return { + label: `${level.name} ${t`Collection`}`, + }; +}; + +const getCollectionResult = (result: WrappedResult): InfoTextData => { + const collection = result.getCollection(); + const colUrl = collectionUrl(collection); + const collectionName = collection.name; + return collectionName + ? { + icon: collection.authority_level ? ( + <Box ml="-1.5px" display="inherit" pos="relative" top="-0.5px"> + <CollectionAuthorityLevelIcon size={12} collection={collection} /> + </Box> + ) : null, + link: colUrl, + label: collectionName, + } + : {}; +}; diff --git a/frontend/src/metabase/search/components/InfoText/index.ts b/frontend/src/metabase/search/components/InfoText/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..49aef2d70538107fab1fd0b9357278dc2eb2b4db --- /dev/null +++ b/frontend/src/metabase/search/components/InfoText/index.ts @@ -0,0 +1 @@ +export * from "./InfoText"; diff --git a/frontend/src/metabase/search/components/SearchFilterDateDisplay/SearchFilterDateDisplay.tsx b/frontend/src/metabase/search/components/SearchFilterDateDisplay/SearchFilterDateDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4570b3f576ea08df969668d26183161d283106c4 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchFilterDateDisplay/SearchFilterDateDisplay.tsx @@ -0,0 +1,20 @@ +import { Text } from "metabase/ui"; +import { getFilterTitle } from "metabase/parameters/utils/date-formatting"; +import { dateParameterValueToMBQL } from "metabase-lib/parameters/utils/mbql"; + +export type SearchFilterDateDisplayProps = { + label: string; + value: string | null; +}; +export const SearchFilterDateDisplay = ({ + label, + value, +}: SearchFilterDateDisplayProps) => { + const dateFilter = dateParameterValueToMBQL(value, null); + + return ( + <Text c="inherit" fw={700} truncate> + {dateFilter ? getFilterTitle(dateFilter) : label} + </Text> + ); +}; diff --git a/frontend/src/metabase/search/components/SearchFilterDateDisplay/SearchFilterDateDisplay.unit.spec.tsx b/frontend/src/metabase/search/components/SearchFilterDateDisplay/SearchFilterDateDisplay.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1917b354bbdaee50e124c89504447b2bec31ee4d --- /dev/null +++ b/frontend/src/metabase/search/components/SearchFilterDateDisplay/SearchFilterDateDisplay.unit.spec.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import type { SearchFilterDateDisplayProps } from "./SearchFilterDateDisplay"; +import { SearchFilterDateDisplay } from "./SearchFilterDateDisplay"; + +const TEST_TITLE = "SearchFilterDateDisplay Title"; + +const TEST_DATE_FILTER_DISPLAY: [string | null | undefined, string][] = [ + ["thisday", "Today"], + ["past1days", "Yesterday"], + ["past1weeks", "Previous Week"], + ["past7days", "Previous 7 Days"], + ["past30days", "Previous 30 Days"], + ["past1months", "Previous Month"], + ["past3months", "Previous 3 Months"], + ["past12months", "Previous 12 Months"], + ["2023-08-30~2023-09-29", "August 30, 2023 - September 29, 2023"], + ["past123quarters", "Previous 123 Quarters"], + ["invalidSuperString", TEST_TITLE], + [null, TEST_TITLE], + [undefined, TEST_TITLE], +]; +const setup = ({ + value = null, +}: Partial<SearchFilterDateDisplayProps> = {}) => { + render(<SearchFilterDateDisplay label={TEST_TITLE} value={value} />); +}; + +describe("SearchFilterDateDisplay", () => { + it.each(TEST_DATE_FILTER_DISPLAY)( + "displays correct title when value is %s", + (value, title) => { + setup({ value }); + expect(screen.getByText(title)).toBeInTheDocument(); + }, + ); +}); diff --git a/frontend/src/metabase/search/components/SearchFilterDateDisplay/index.ts b/frontend/src/metabase/search/components/SearchFilterDateDisplay/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d15cf4e30a1f27c75dbcd99954746f0de312aae5 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchFilterDateDisplay/index.ts @@ -0,0 +1 @@ +export * from "./SearchFilterDateDisplay"; diff --git a/frontend/src/metabase/search/components/SearchFilterDatePicker/SearchFilterDatePicker.tsx b/frontend/src/metabase/search/components/SearchFilterDatePicker/SearchFilterDatePicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..37ec445abb2c6804839735d12244c8ed4d7515f7 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchFilterDatePicker/SearchFilterDatePicker.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import { t } from "ttag"; +import { SearchFilterApplyButton } from "metabase/search/components/SearchFilterPopoverWrapper/SearchFilterPopoverWrapper"; +import { filterToUrlEncoded } from "metabase/parameters/utils/date-formatting"; +import DatePicker from "metabase/query_builder/components/filters/pickers/DatePicker/DatePicker"; +import type { DateShortcutOptions } from "metabase/query_builder/components/filters/pickers/DatePicker/DatePickerShortcutOptions"; +import { DATE_SHORTCUT_OPTIONS } from "metabase/query_builder/components/filters/pickers/DatePicker/DatePickerShortcutOptions"; +import { dateParameterValueToMBQL } from "metabase-lib/parameters/utils/mbql"; + +const CREATED_AT_SHORTCUTS: DateShortcutOptions = { + ...DATE_SHORTCUT_OPTIONS, + MISC_OPTIONS: DATE_SHORTCUT_OPTIONS.MISC_OPTIONS.filter( + ({ displayName }) => displayName !== t`Exclude...`, + ), +}; + +export const SearchFilterDatePicker = ({ + value, + onChange, +}: { + value: string | null; + onChange: (value: string | null) => void; +}) => { + const [filter, onFilterChange] = useState( + dateParameterValueToMBQL(value) ?? [], + ); + + const onCommit = (filterToCommit: any[]) => { + onChange(filterToUrlEncoded(filterToCommit)); + }; + + return ( + <DatePicker + filter={filter} + onCommit={onCommit} + onFilterChange={f => onFilterChange(f)} + dateShortcutOptions={CREATED_AT_SHORTCUTS} + > + <SearchFilterApplyButton onApply={() => onCommit(filter)} /> + </DatePicker> + ); +}; diff --git a/frontend/src/metabase/search/components/SearchFilterDatePicker/SearchFilterDatePicker.unit.spec.tsx b/frontend/src/metabase/search/components/SearchFilterDatePicker/SearchFilterDatePicker.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c42e9e4baeb4138e8991e76b61329469e362430a --- /dev/null +++ b/frontend/src/metabase/search/components/SearchFilterDatePicker/SearchFilterDatePicker.unit.spec.tsx @@ -0,0 +1,53 @@ +import userEvent from "@testing-library/user-event"; +import { renderWithProviders, screen, within } from "__support__/ui"; +import { SearchFilterDatePicker } from "./SearchFilterDatePicker"; + +type SetupProps = { + value?: string | null; +}; + +const setup = ({ value = null }: SetupProps = {}) => { + const onChangeMock = jest.fn(); + renderWithProviders( + <SearchFilterDatePicker value={value} onChange={onChangeMock} />, + ); + return { + onChangeMock, + }; +}; + +describe("SearchFilterDatePicker", () => { + it("should render SearchFilterDatePicker component", () => { + setup(); + expect(screen.getByTestId("date-picker")).toBeInTheDocument(); + }); + + it("should not display Exclude… in the date picker shortcut options", () => { + setup(); + expect(screen.queryByText("Exclude…")).not.toBeInTheDocument(); + }); + + it("should call onChange when a date is selected", () => { + const { onChangeMock } = setup(); + userEvent.click(screen.getByText("Today")); + expect(onChangeMock).toHaveBeenCalled(); + }); + + it("should populate the `Specific dates…` date picker with the value passed in", () => { + setup({ value: "2023-09-20" }); + const specificDatePicker = screen.getByTestId("specific-date-picker"); + expect(specificDatePicker).toBeInTheDocument(); + + expect( + within(screen.getByTestId("specific-date-picker")).getByRole("textbox"), + ).toHaveValue("09/20/2023"); + }); + + it("should populate the `Relative dates…` date picker with the value passed in", () => { + setup({ value: "past30days" }); + expect(screen.getByTestId("relative-datetime-value")).toHaveValue("30"); + expect(screen.getByTestId("select-button-content")).toHaveTextContent( + "days", + ); + }); +}); diff --git a/frontend/src/metabase/search/components/SearchFilterDatePicker/index.ts b/frontend/src/metabase/search/components/SearchFilterDatePicker/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..380f197ca65d4adebc838f28194b0813ade82705 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchFilterDatePicker/index.ts @@ -0,0 +1 @@ +export * from "./SearchFilterDatePicker"; diff --git a/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/SearchFilterPopoverWrapper.styled.tsx b/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/SearchFilterPopoverWrapper.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5107af5a623cbe0903086b7bf4f1812efa5d9d45 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/SearchFilterPopoverWrapper.styled.tsx @@ -0,0 +1,19 @@ +import styled from "@emotion/styled"; +import { css } from "@emotion/react"; +import { Stack } from "metabase/ui"; + +export const SearchPopoverContainer = styled(Stack)` + overflow: hidden; + width: 100%; +`; +export const DropdownApplyButtonDivider = styled.hr<{ width?: string }>` + border-width: 1px 0 0 0; + border-style: solid; + ${({ theme, width }) => { + const dividerWidth = width ?? "100%"; + return css` + border-color: ${theme.colors.border[0]}; + width: ${dividerWidth}; + `; + }} +`; diff --git a/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/SearchFilterPopoverWrapper.tsx b/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/SearchFilterPopoverWrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5b4ff30d6f202cf7aa09a1813218faa130461dce --- /dev/null +++ b/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/SearchFilterPopoverWrapper.tsx @@ -0,0 +1,51 @@ +import { t } from "ttag"; +import type { ReactNode } from "react"; +import type { StackProps } from "metabase/ui"; +import { Button, Center, Group, Loader, FocusTrap } from "metabase/ui"; +import type { + FilterTypeKeys, + SearchFilterPropTypes, +} from "metabase/search/types"; +import { + DropdownApplyButtonDivider, + SearchPopoverContainer, +} from "./SearchFilterPopoverWrapper.styled"; + +type SearchFilterPopoverWrapperProps<T extends FilterTypeKeys = any> = { + children: ReactNode; + onApply: (value: SearchFilterPropTypes[T]) => void; + isLoading?: boolean; +} & StackProps; + +export const SearchFilterApplyButton = ({ + onApply, +}: Pick<SearchFilterPopoverWrapperProps, "onApply">) => ( + <Button variant="filled" onClick={onApply}>{t`Apply`}</Button> +); + +export const SearchFilterPopoverWrapper = ({ + children, + onApply, + isLoading = false, + ...stackProps +}: SearchFilterPopoverWrapperProps) => { + if (isLoading) { + return ( + <Center p="lg"> + <Loader data-testid="loading-spinner" /> + </Center> + ); + } + + return ( + <FocusTrap active> + <SearchPopoverContainer spacing={0} {...stackProps}> + {children} + <DropdownApplyButtonDivider /> + <Group position="right" align="center" px="sm" pb="sm"> + <SearchFilterApplyButton onApply={onApply} /> + </Group> + </SearchPopoverContainer> + </FocusTrap> + ); +}; diff --git a/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/SearchFilterPopoverWrapper.unit.spec.tsx b/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/SearchFilterPopoverWrapper.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c7dd12d38c8f35fa5cbb9c1107efa1f16637dd6d --- /dev/null +++ b/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/SearchFilterPopoverWrapper.unit.spec.tsx @@ -0,0 +1,46 @@ +import userEvent from "@testing-library/user-event"; +import { renderWithProviders, screen } from "__support__/ui"; +import { SearchFilterPopoverWrapper } from "./SearchFilterPopoverWrapper"; + +type SetupProps = { + isLoading?: boolean; + onApply?: jest.Func; +}; + +const setup = ({ isLoading = false }: SetupProps = {}) => { + const onApply = jest.fn(); + + renderWithProviders( + <SearchFilterPopoverWrapper isLoading={isLoading} onApply={onApply}> + Children Content + </SearchFilterPopoverWrapper>, + ); + + return { + onApply, + }; +}; +describe("SearchFilterPopoverWrapper", () => { + it("should render loading spinner when isLoading is true", () => { + setup({ isLoading: true }); + + const loadingSpinner = screen.getByTestId("loading-spinner"); + expect(loadingSpinner).toBeInTheDocument(); + }); + + it("should render children content when isLoading is false", () => { + setup(); + + const childrenContent = screen.getByText("Children Content"); + expect(childrenContent).toBeInTheDocument(); + }); + + it('should call onApply when the "Apply" button is clicked', () => { + const { onApply } = setup(); + + const applyButton = screen.getByText("Apply"); + userEvent.click(applyButton); + + expect(onApply).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/index.ts b/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a7ca94eb0341baf98c5ba58b20f53583c87e6b6 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchFilterPopoverWrapper/index.ts @@ -0,0 +1 @@ +export { SearchFilterPopoverWrapper } from "./SearchFilterPopoverWrapper"; diff --git a/frontend/src/metabase/search/components/SearchResult/SearchResult.styled.tsx b/frontend/src/metabase/search/components/SearchResult/SearchResult.styled.tsx index a113aecdc30ebb88f2ac7530580013d42beb1e17..48d392b70d9b8063152c56391f606800a487e247 100644 --- a/frontend/src/metabase/search/components/SearchResult/SearchResult.styled.tsx +++ b/frontend/src/metabase/search/components/SearchResult/SearchResult.styled.tsx @@ -1,118 +1,101 @@ +import isPropValid from "@emotion/is-prop-valid"; +import { css } from "@emotion/react"; import styled from "@emotion/styled"; - -import { color, lighten } from "metabase/lib/colors"; -import { space } from "metabase/styled-components/theme"; -import Link from "metabase/core/components/Link"; -import Text from "metabase/components/type/Text"; -import LoadingSpinner from "metabase/components/LoadingSpinner"; - -interface ResultStylesProps { - compact: boolean; - active: boolean; - isSelected: boolean; -} - -export const TitleWrapper = styled.div` - display: flex; - grid-gap: 0.25rem; - align-items: center; -`; - -export const Title = styled("h3")<{ active: boolean }>` - margin-bottom: 4px; - color: ${props => color(props.active ? "text-dark" : "text-medium")}; -`; - -export const ResultButton = styled.button<ResultStylesProps>` - ${props => resultStyles(props)} - padding-right: 0.5rem; - text-align: left; - cursor: pointer; - width: 100%; - - &:hover { - ${Title} { - color: ${color("brand")}; - } +import type { AnchorHTMLAttributes, HTMLAttributes, RefObject } from "react"; +import { PLUGIN_MODERATION } from "metabase/plugins"; +import type { AnchorProps, BoxProps } from "metabase/ui"; +import { Box, Divider, Stack, Anchor } from "metabase/ui"; + +const { ModerationStatusIcon } = PLUGIN_MODERATION; + +const isBoxPropValid = (propName: PropertyKey) => { + return ( + propName !== "isActive" && + propName !== "isSelected" && + isPropValid(propName) + ); +}; + +export const ResultTitle = styled(Anchor)< + AnchorProps & AnchorHTMLAttributes<HTMLAnchorElement> +>` + line-height: unset; + font-weight: 700; + font-size: ${({ theme }) => theme.fontSizes.md}; + + color: ${({ theme }) => theme.colors.text[2]}; + + &:hover, + &:focus-visible, + &:focus { + text-decoration: none; + color: ${({ theme }) => theme.colors.brand[1]}; + outline: 0; } `; -export const ResultLink = styled(Link)<ResultStylesProps>` - ${props => resultStyles(props)} -`; - -const resultStyles = ({ compact, active, isSelected }: ResultStylesProps) => ` - display: block; - background-color: ${isSelected ? lighten("brand", 0.63) : "transparent"}; - min-height: ${compact ? "36px" : "54px"}; - padding-top: ${space(1)}; - padding-bottom: ${space(1)}; - padding-left: 14px; - padding-right: ${compact ? "20px" : space(3)}; - cursor: ${active ? "pointer" : "default"}; - - &:hover { - background-color: ${active ? lighten("brand", 0.63) : ""}; - - h3 { - color: ${active || isSelected ? color("brand") : ""}; +export const SearchResultContainer = styled(Box, { + shouldForwardProp: isBoxPropValid, +})< + BoxProps & + HTMLAttributes<HTMLButtonElement> & { + isActive?: boolean; + isSelected?: boolean; + component?: string; + ref?: RefObject<HTMLButtonElement> | null; } - } - - ${Link.Root} { - text-underline-position: under; - text-decoration: underline ${color("text-light")}; - text-decoration-style: dashed; - - &:hover { - color: ${active ? color("brand") : ""}; - text-decoration-color: ${active ? color("brand") : ""}; - } - } - - ${Text} { - margin-top: 0; - margin-bottom: 0; - font-size: 13px; - line-height: 19px; - } - - h3 { - font-size: ${compact ? "14px" : "16px"}; - line-height: 1.2em; - overflow-wrap: anywhere; - margin-bottom: 0; - color: ${active && isSelected ? color("brand") : ""}; - } +>` + display: grid; + grid-template-columns: auto 1fr auto; + justify-content: center; + align-items: center; + gap: 0.5rem 0.75rem; + + padding: ${({ theme }) => theme.spacing.sm}; + + ${({ theme, isActive, isSelected }) => + isActive && + css` + border-radius: ${theme.radius.md}; + color: ${isSelected && theme.colors.brand[1]}; + background-color: ${isSelected && theme.colors.brand[0]}; + + ${ResultTitle} { + color: ${isSelected && theme.colors.brand[1]}; + } + + &:hover { + background-color: ${theme.colors.brand[0]}; + cursor: pointer; + + ${ResultTitle} { + color: ${theme.colors.brand[1]}; + } + } + + &:focus-within { + background-color: ${theme.colors.brand[0]}; + } + `} +`; - .Icon-info { - color: ${color("text-light")}; - } +export const ResultNameSection = styled(Stack)` + overflow: hidden; `; -export const ResultInner = styled.div` - display: flex; - justify-content: space-between; - align-items: center; +export const ModerationIcon = styled(ModerationStatusIcon)` + overflow: unset; `; -export const ResultLinkContent = styled.div` - display: flex; - align-items: start; - overflow-wrap: anywhere; +export const LoadingSection = styled(Box)<BoxProps>` + grid-row: 1 / span 2; + grid-column: 3; `; -export const Description = styled(Text)` - padding-left: ${space(1)}; - margin-top: ${space(1)} !important; - border-left: 2px solid ${lighten("brand", 0.45)}; +export const DescriptionSection = styled(Box)` + grid-column-start: 2; `; -export const ResultSpinner = styled(LoadingSpinner)` - display: flex; - flex-grow: 1; - align-self: center; - justify-content: flex-end; - margin-left: ${space(1)}; - color: ${color("brand")}; +export const DescriptionDivider = styled(Divider)` + border-radius: ${({ theme }) => theme.radius.xs}; `; diff --git a/frontend/src/metabase/search/components/SearchResult/SearchResult.tsx b/frontend/src/metabase/search/components/SearchResult/SearchResult.tsx index 2cefec7147beb375854962044f008c953c819551..f6ff0f81bceddf2067bb2a698da170ccc252eb29 100644 --- a/frontend/src/metabase/search/components/SearchResult/SearchResult.tsx +++ b/frontend/src/metabase/search/components/SearchResult/SearchResult.tsx @@ -1,126 +1,141 @@ -import { Button } from "metabase/ui"; -import { isSyncCompleted } from "metabase/lib/syncing"; -import { Icon } from "metabase/core/components/Icon"; -import Text from "metabase/components/type/Text"; +import type { LocationDescriptorObject } from "history"; +import type { MouseEvent } from "react"; +import { useCallback } from "react"; +import { push } from "react-router-redux"; -import { PLUGIN_MODERATION } from "metabase/plugins"; +import { useDispatch } from "metabase/lib/redux"; +import { Group, Text, Loader } from "metabase/ui"; +import { isSyncCompleted } from "metabase/lib/syncing"; import type { WrappedResult } from "metabase/search/types"; -import Link from "metabase/core/components/Link"; import { InfoText } from "../InfoText"; -import { ItemIcon, Context, Score } from "./components"; +import { ItemIcon } from "./components"; + import { - ResultButton, - ResultLink, - Title, - TitleWrapper, - Description, - ResultSpinner, - ResultLinkContent, - ResultInner, + DescriptionDivider, + DescriptionSection, + LoadingSection, + ModerationIcon, + ResultNameSection, + ResultTitle, + SearchResultContainer, } from "./SearchResult.styled"; export function SearchResult({ result, compact = false, - hasDescription = true, - onClick = undefined, + showDescription = true, isSelected = false, + onClick, }: { result: WrappedResult; compact?: boolean; - hasDescription?: boolean; + showDescription?: boolean; onClick?: (result: WrappedResult) => void; isSelected?: boolean; }) { - const active = isItemActive(result); - const loading = isItemLoading(result); + const { name, model, description, moderated_status }: WrappedResult = result; + + const isActive = isItemActive(result); + const isLoading = isItemLoading(result); + + const dispatch = useDispatch(); - // we want to remove link behavior if we have an onClick handler - const ResultContainer = onClick ? ResultButton : ResultLink; + const onChangeLocation = useCallback( + (nextLocation: LocationDescriptorObject | string) => + dispatch(push(nextLocation)), + [dispatch], + ); + + const handleClick = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (!isActive) { + return; + } - const showXRayButton = - result.model === "indexed-entity" && - result.id !== undefined && - result.model_index_id !== null; + if (onClick) { + onClick(result); + return; + } + + onChangeLocation(result.getUrl()); + }; return ( - <ResultContainer - data-is-selected={isSelected} - isSelected={isSelected} - active={active} - compact={compact} - to={!onClick ? result.getUrl() : ""} - onClick={onClick && active ? () => onClick(result) : undefined} + <SearchResultContainer data-testid="search-result-item" + component="button" + onClick={handleClick} + isActive={isActive} + isSelected={isSelected} + data-model-type={model} + data-is-selected={isSelected} + w="100%" + aria-label={`${name} ${model}`} > - <ResultInner> - <ResultLinkContent> - <ItemIcon item={result} type={result.model} active={active} /> - <div> - <TitleWrapper> - <Title active={active} data-testid="search-result-item-name"> - {result.name} - </Title> - <PLUGIN_MODERATION.ModerationStatusIcon - status={result.moderated_status} - size={12} - /> - </TitleWrapper> - <Text data-testid="result-link-info-text"> - <InfoText result={result} /> - </Text> - {hasDescription && result.description && ( - <Description>{result.description}</Description> - )} - <Score scores={result.scores} /> - </div> - {loading && ( - // SearchApp also uses `loading-spinner`, using a different test ID - // to not confuse unit tests waiting for loading-spinner to disappear - <ResultSpinner - data-testid="search-result-loading-spinner" - size={24} - borderWidth={3} - /> - )} - </ResultLinkContent> - {showXRayButton && ( - <Button - onClick={(e: React.MouseEvent<HTMLButtonElement>) => - e.stopPropagation() - } - variant="outline" - p="sm" + <ItemIcon + data-testid="search-result-item-icon" + active={isActive} + item={result} + type={model} + /> + <ResultNameSection justify="center" spacing="xs"> + <Group spacing="xs" align="center" noWrap> + <ResultTitle + role="heading" + data-testid="search-result-item-name" + truncate + href={!onClick ? result.getUrl() : undefined} > - <Link - to={`/auto/dashboard/model_index/${result.model_index_id}/primary_key/${result.id}`} + {name} + </ResultTitle> + <ModerationIcon status={moderated_status} filled size={14} /> + </Group> + <InfoText result={result} isCompact={compact} /> + </ResultNameSection> + {isLoading && ( + <LoadingSection px="xs"> + <Loader /> + </LoadingSection> + )} + {description && showDescription && ( + <DescriptionSection> + <Group noWrap spacing="sm"> + <DescriptionDivider + size="md" + color="focus.0" + orientation="vertical" + /> + <Text + data-testid="result-description" + color="text.1" + align="left" + size="sm" + lineClamp={2} > - <Icon name="bolt" /> - </Link> - </Button> - )} - </ResultInner> - {compact || <Context context={result.context} />} - </ResultContainer> + {description} + </Text> + </Group> + </DescriptionSection> + )} + </SearchResultContainer> ); } const isItemActive = (result: WrappedResult) => { - switch (result.model) { - case "table": - return isSyncCompleted(result); - default: - return true; + if (result.model !== "table") { + return true; } + + return isSyncCompleted(result); }; const isItemLoading = (result: WrappedResult) => { - switch (result.model) { - case "database": - case "table": - return !isSyncCompleted(result); - default: - return false; + if (result.model !== "database" && result.model !== "table") { + return false; } + + return !isSyncCompleted(result); }; diff --git a/frontend/src/metabase/search/components/SearchResult/SearchResult.unit.spec.tsx b/frontend/src/metabase/search/components/SearchResult/SearchResult.unit.spec.tsx deleted file mode 100644 index 214564b3ffb8cb3b2dbc61bef1c3f775743dd547..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/search/components/SearchResult/SearchResult.unit.spec.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import userEvent from "@testing-library/user-event"; -import { setupEnterpriseTest } from "__support__/enterprise"; -import { - setupTableEndpoints, - setupDatabaseEndpoints, -} from "__support__/server-mocks"; -import { - createMockSearchResult, - createMockTable, - createMockDatabase, -} from "metabase-types/api/mocks"; -import { - getIcon, - renderWithProviders, - queryIcon, - screen, -} from "__support__/ui"; - -import type { InitialSyncStatus } from "metabase-types/api"; -import type { WrappedResult } from "metabase/search/types"; -import { SearchResult } from "./SearchResult"; - -const createWrappedSearchResult = ( - options: Partial<WrappedResult>, -): WrappedResult => { - const result = createMockSearchResult(options); - - return { - ...result, - getUrl: options.getUrl ?? (() => "/collection/root"), - getIcon: options.getIcon ?? (() => ({ name: "folder" })), - getCollection: options.getCollection ?? (() => result.collection), - }; -}; - -describe("SearchResult", () => { - it("renders a search result question item", () => { - const result = createWrappedSearchResult({ - name: "My Item", - model: "card", - description: "My Item Description", - getIcon: () => ({ name: "table" }), - }); - - renderWithProviders(<SearchResult result={result} />); - - expect(screen.getByText(result.name)).toBeInTheDocument(); - expect(screen.getByText(result.description as string)).toBeInTheDocument(); - expect(getIcon("table")).toBeInTheDocument(); - }); - - it("renders a search result collection item", () => { - const result = createWrappedSearchResult({ - name: "My Folder of Goodies", - model: "collection", - collection: { - id: 1, - name: "This should not appear", - authority_level: null, - }, - }); - - renderWithProviders(<SearchResult result={result} />); - - expect(screen.getByText(result.name)).toBeInTheDocument(); - expect(screen.getByText("Collection")).toBeInTheDocument(); - expect(screen.queryByText(result.collection.name)).not.toBeInTheDocument(); - expect(getIcon("folder")).toBeInTheDocument(); - }); -}); - -describe("SearchResult > Tables", () => { - interface SetupOpts { - name: string; - initial_sync_status: InitialSyncStatus; - } - - const setup = (setupOpts: SetupOpts) => { - const TEST_TABLE = createMockTable(setupOpts); - const TEST_DATABASE = createMockDatabase(); - setupTableEndpoints(TEST_TABLE); - setupDatabaseEndpoints(TEST_DATABASE); - const result = createWrappedSearchResult({ - model: "table", - table_id: TEST_TABLE.id, - database_id: TEST_DATABASE.id, - getUrl: () => `/table/${TEST_TABLE.id}`, - getIcon: () => ({ name: "table" }), - ...setupOpts, - }); - const onClick = jest.fn(); - renderWithProviders(<SearchResult result={result} onClick={onClick} />); - const link = screen.getByText(result.name); - return { link, onClick }; - }; - - it("tables with initial_sync_status='complete' are clickable", () => { - const { link, onClick } = setup({ - name: "Complete Table", - initial_sync_status: "complete", - }); - userEvent.click(link); - expect(onClick).toHaveBeenCalled(); - }); - - it("tables with initial_sync_status='incomplete' are not clickable", () => { - const { link, onClick } = setup({ - name: "Incomplete Table", - initial_sync_status: "incomplete", - }); - userEvent.click(link); - expect(onClick).not.toHaveBeenCalled(); - }); - - it("tables with initial_sync_status='aborted' are not clickable", () => { - const { link, onClick } = setup({ - name: "Aborted Table", - initial_sync_status: "aborted", - }); - userEvent.click(link); - expect(onClick).not.toHaveBeenCalled(); - }); -}); - -describe("SearchResult > Collections", () => { - const resultInRegularCollection = createWrappedSearchResult({ - name: "My Regular Item", - collection_authority_level: null, - collection: { - id: 1, - name: "Regular Collection", - authority_level: null, - }, - }); - - const resultInOfficalCollection = createWrappedSearchResult({ - name: "My Official Item", - collection_authority_level: "official", - collection: { - id: 1, - name: "Official Collection", - authority_level: "official", - }, - }); - - describe("OSS", () => { - it("renders regular collection correctly", () => { - renderWithProviders(<SearchResult result={resultInRegularCollection} />); - expect( - screen.getByText(resultInRegularCollection.name), - ).toBeInTheDocument(); - expect(screen.getByText("Regular Collection")).toBeInTheDocument(); - expect(getIcon("folder")).toBeInTheDocument(); - expect(queryIcon("badge")).not.toBeInTheDocument(); - }); - - it("renders official collections as regular", () => { - renderWithProviders(<SearchResult result={resultInOfficalCollection} />); - expect( - screen.getByText(resultInOfficalCollection.name), - ).toBeInTheDocument(); - expect(screen.getByText("Official Collection")).toBeInTheDocument(); - expect(getIcon("folder")).toBeInTheDocument(); - expect(queryIcon("badge")).not.toBeInTheDocument(); - }); - }); - - describe("EE", () => { - const resultInOfficalCollectionEE: WrappedResult = { - ...resultInOfficalCollection, - getIcon: () => ({ name: "badge" }), - }; - - beforeAll(() => { - setupEnterpriseTest(); - }); - - it("renders regular collection correctly", () => { - renderWithProviders(<SearchResult result={resultInRegularCollection} />); - expect( - screen.getByText(resultInRegularCollection.name), - ).toBeInTheDocument(); - expect(screen.getByText("Regular Collection")).toBeInTheDocument(); - expect(getIcon("folder")).toBeInTheDocument(); - expect(queryIcon("badge")).not.toBeInTheDocument(); - }); - - it("renders official collections correctly", () => { - renderWithProviders( - <SearchResult result={resultInOfficalCollectionEE} />, - ); - expect( - screen.getByText(resultInOfficalCollectionEE.name), - ).toBeInTheDocument(); - expect(screen.getByText("Official Collection")).toBeInTheDocument(); - expect(getIcon("badge")).toBeInTheDocument(); - expect(queryIcon("folder")).not.toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/src/metabase/search/components/SearchResult/components/CollectionIcon.tsx b/frontend/src/metabase/search/components/SearchResult/components/CollectionIcon.tsx index 718ac1d18c5b312ba4e3811a1e1dec5bcb39a4dc..82ba19557793ad6836f4a0b73dfd9391b3d9a659 100644 --- a/frontend/src/metabase/search/components/SearchResult/components/CollectionIcon.tsx +++ b/frontend/src/metabase/search/components/SearchResult/components/CollectionIcon.tsx @@ -1,14 +1,23 @@ import { Icon } from "metabase/core/components/Icon"; +import { color } from "metabase/lib/colors"; import { PLUGIN_COLLECTIONS } from "metabase/plugins"; import { DEFAULT_ICON_SIZE } from "metabase/search/components/SearchResult/components"; -import type { WrappedResult } from "metabase/search/types"; +import type { IconComponentProps } from "./ItemIcon"; -export function CollectionIcon({ item }: { item: WrappedResult }) { +export function CollectionIcon({ item }: { item: IconComponentProps["item"] }) { const iconProps = { ...item.getIcon(), tooltip: null }; - const isRegular = PLUGIN_COLLECTIONS.isRegularCollection(item.collection); + const isRegular = + "collection" in item && + PLUGIN_COLLECTIONS.isRegularCollection(item.collection); if (isRegular) { - return <Icon {...iconProps} size={DEFAULT_ICON_SIZE} />; + return ( + <Icon + {...iconProps} + size={DEFAULT_ICON_SIZE} + color={color("text-light")} + /> + ); } return <Icon {...iconProps} width={20} height={24} />; diff --git a/frontend/src/metabase/search/components/SearchResult/components/DefaultIcon.tsx b/frontend/src/metabase/search/components/SearchResult/components/DefaultIcon.tsx index d807debe44a61f76031f43261abb48c08acf6dff..ab25df33948326db6ab42c083d7442f1e29029b1 100644 --- a/frontend/src/metabase/search/components/SearchResult/components/DefaultIcon.tsx +++ b/frontend/src/metabase/search/components/SearchResult/components/DefaultIcon.tsx @@ -1,7 +1,7 @@ import { Icon } from "metabase/core/components/Icon"; -import type { WrappedResult } from "metabase/search/types"; +import type { IconComponentProps } from "./ItemIcon"; import { DEFAULT_ICON_SIZE } from "./constants"; -export function DefaultIcon({ item }: { item: WrappedResult }) { +export function DefaultIcon({ item }: { item: IconComponentProps["item"] }) { return <Icon {...item.getIcon()} size={DEFAULT_ICON_SIZE} />; } diff --git a/frontend/src/metabase/search/components/SearchResult/components/ItemIcon.styled.tsx b/frontend/src/metabase/search/components/SearchResult/components/ItemIcon.styled.tsx index 5199373ce15755cd7718e20c5d991ae7a45457c6..b030b695eb5651dac71c2d2e252d99609f865acc 100644 --- a/frontend/src/metabase/search/components/SearchResult/components/ItemIcon.styled.tsx +++ b/frontend/src/metabase/search/components/SearchResult/components/ItemIcon.styled.tsx @@ -22,12 +22,14 @@ export const IconWrapper = styled.div<{ active: boolean; type: SearchModelType; }>` + border: ${({ theme }) => `1px solid ${theme.colors.border[0]}`}; + border-radius: ${({ theme }) => theme.radius.sm}; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; color: ${({ active, type }) => getColorForIconWrapper({ active, type })}; - margin-right: 10px; flex-shrink: 0; + background: ${color("white")}; `; diff --git a/frontend/src/metabase/search/components/SearchResult/components/ItemIcon.tsx b/frontend/src/metabase/search/components/SearchResult/components/ItemIcon.tsx index 9aa6cccc7d87ed591ccfe42421d72d7eaed2c683..b8cec6448959a85af8f5746f9ebc78dcf543fe74 100644 --- a/frontend/src/metabase/search/components/SearchResult/components/ItemIcon.tsx +++ b/frontend/src/metabase/search/components/SearchResult/components/ItemIcon.tsx @@ -1,12 +1,13 @@ import type { SearchModelType } from "metabase-types/api"; import { Icon } from "metabase/core/components/Icon"; import type { WrappedResult } from "metabase/search/types"; +import type { WrappedRecentItem } from "metabase/nav/components/search/RecentsList"; import { CollectionIcon } from "./CollectionIcon"; import { DefaultIcon } from "./DefaultIcon"; import { IconWrapper } from "./ItemIcon.styled"; -interface IconComponentProps { - item: WrappedResult; +export interface IconComponentProps { + item: WrappedResult | WrappedRecentItem; type: SearchModelType; } @@ -24,13 +25,19 @@ const IconComponent = ({ item, type }: IconComponentProps) => { interface ItemIconProps { active: boolean; - item: WrappedResult; + item: WrappedResult | WrappedRecentItem; type: SearchModelType; + "data-testid"?: string; } -export const ItemIcon = ({ active, item, type }: ItemIconProps) => { +export const ItemIcon = ({ + active, + item, + type, + "data-testid": dataTestId, +}: ItemIconProps) => { return ( - <IconWrapper type={type} active={active}> + <IconWrapper type={type} active={active} data-testid={dataTestId}> <IconComponent item={item} type={type} /> </IconWrapper> ); diff --git a/frontend/src/metabase/search/components/SearchResult/index.ts b/frontend/src/metabase/search/components/SearchResult/index.ts index 09ed6feab213d7d2dca789828666878a5a6fd791..330f1efd5dba9797556483471282d416032f03e3 100644 --- a/frontend/src/metabase/search/components/SearchResult/index.ts +++ b/frontend/src/metabase/search/components/SearchResult/index.ts @@ -1,9 +1,3 @@ export { SearchResult } from "./SearchResult"; -export { - ResultLink, - ResultButton, - ResultSpinner, - Title, - TitleWrapper, -} from "./SearchResult.styled"; export * from "./components"; +export * from "./SearchResult.styled"; diff --git a/frontend/src/metabase/search/components/SearchResult/tests/SearchResult-Collections.unit.spec.tsx b/frontend/src/metabase/search/components/SearchResult/tests/SearchResult-Collections.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e67365fad80e39a444248e215183fc44707bed01 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchResult/tests/SearchResult-Collections.unit.spec.tsx @@ -0,0 +1,129 @@ +import { waitFor } from "@testing-library/react"; +import { setupEnterpriseTest } from "__support__/enterprise"; +import { + setupCollectionByIdEndpoint, + setupUsersEndpoints, +} from "__support__/server-mocks"; +import { + getIcon, + queryIcon, + renderWithProviders, + screen, +} from "__support__/ui"; +import { createMockCollection, createMockUser } from "metabase-types/api/mocks"; +import { SearchResult } from "metabase/search/components/SearchResult"; +import type { WrappedResult } from "metabase/search/types"; +import { createWrappedSearchResult } from "./util"; + +const TEST_REGULAR_COLLECTION = createMockCollection({ + id: 1, + name: "Regular Collection", + authority_level: null, +}); + +const TEST_OFFICIAL_COLLECTION = createMockCollection({ + id: 2, + name: "Official Collection", + authority_level: "official", +}); + +const resultInRegularCollection = createWrappedSearchResult({ + name: "My Regular Item", + collection_authority_level: null, + collection: TEST_REGULAR_COLLECTION, +}); + +const resultInOfficalCollection = createWrappedSearchResult({ + name: "My Official Item", + collection_authority_level: "official", + collection: TEST_OFFICIAL_COLLECTION, +}); + +const setup = ({ result }: { result: WrappedResult }) => { + setupCollectionByIdEndpoint({ + collections: [TEST_REGULAR_COLLECTION, TEST_OFFICIAL_COLLECTION], + }); + + setupUsersEndpoints([createMockUser()]); + + renderWithProviders(<SearchResult result={result} />); +}; + +describe("SearchResult > Collections", () => { + describe("OSS", () => { + it("renders regular collection correctly", async () => { + setup({ result: resultInRegularCollection }); + expect( + screen.getByText(resultInRegularCollection.name), + ).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByTestId("info-text-collection-loading-text"), + ).not.toBeInTheDocument(); + }); + expect(screen.getByText("Regular Collection")).toBeInTheDocument(); + expect(getIcon("folder")).toBeInTheDocument(); + expect(queryIcon("badge")).not.toBeInTheDocument(); + }); + + it("renders official collections as regular", async () => { + setup({ result: resultInOfficalCollection }); + expect( + screen.getByText(resultInOfficalCollection.name), + ).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByTestId("info-text-collection-loading-text"), + ).not.toBeInTheDocument(); + }); + expect(screen.getByText("Official Collection")).toBeInTheDocument(); + expect(getIcon("folder")).toBeInTheDocument(); + expect(queryIcon("badge")).not.toBeInTheDocument(); + }); + }); + + describe("EE", () => { + const resultInOfficalCollectionEE: WrappedResult = { + ...resultInOfficalCollection, + getIcon: () => ({ name: "badge" }), + }; + + beforeAll(() => { + setupEnterpriseTest(); + }); + + it("renders regular collection correctly", async () => { + setup({ result: resultInRegularCollection }); + expect( + screen.getByText(resultInRegularCollection.name), + ).toBeInTheDocument(); + + await waitFor(() => { + expect( + screen.queryByTestId("info-text-collection-loading-text"), + ).not.toBeInTheDocument(); + }); + + expect(screen.getByText("Regular Collection")).toBeInTheDocument(); + expect(getIcon("folder")).toBeInTheDocument(); + expect(queryIcon("badge")).not.toBeInTheDocument(); + }); + + it("renders official collections correctly", async () => { + setup({ result: resultInOfficalCollectionEE }); + expect( + screen.getByText(resultInOfficalCollectionEE.name), + ).toBeInTheDocument(); + + await waitFor(() => { + expect( + screen.queryByTestId("info-text-collection-loading-text"), + ).not.toBeInTheDocument(); + }); + + expect(screen.getByText("Official Collection")).toBeInTheDocument(); + expect(getIcon("badge")).toBeInTheDocument(); + expect(queryIcon("folder")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/metabase/search/components/SearchResult/tests/SearchResult-Tables.unit.spec.tsx b/frontend/src/metabase/search/components/SearchResult/tests/SearchResult-Tables.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8464a06cb2704f42917b7af5c81df65d1fc47df2 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchResult/tests/SearchResult-Tables.unit.spec.tsx @@ -0,0 +1,70 @@ +import userEvent from "@testing-library/user-event"; +import { + setupDatabaseEndpoints, + setupTableEndpoints, + setupUsersEndpoints, +} from "__support__/server-mocks"; +import { renderWithProviders, screen } from "__support__/ui"; +import type { InitialSyncStatus } from "metabase-types/api"; +import { + createMockDatabase, + createMockTable, + createMockUser, +} from "metabase-types/api/mocks"; +import { SearchResult } from "metabase/search/components/SearchResult"; +import { createWrappedSearchResult } from "metabase/search/components/SearchResult/tests/util"; + +interface SetupOpts { + name: string; + initial_sync_status: InitialSyncStatus; +} + +const setup = (setupOpts: SetupOpts) => { + const TEST_TABLE = createMockTable(setupOpts); + const TEST_DATABASE = createMockDatabase(); + setupTableEndpoints(TEST_TABLE); + setupDatabaseEndpoints(TEST_DATABASE); + setupUsersEndpoints([createMockUser()]); + const result = createWrappedSearchResult({ + model: "table", + table_id: TEST_TABLE.id, + database_id: TEST_DATABASE.id, + getUrl: () => `/table/${TEST_TABLE.id}`, + getIcon: () => ({ name: "table" }), + ...setupOpts, + }); + + const onClick = jest.fn(); + renderWithProviders(<SearchResult result={result} onClick={onClick} />); + const link = screen.getByText(result.name); + return { link, onClick }; +}; + +describe("SearchResult > Tables", () => { + it("tables with initial_sync_status='complete' are clickable", () => { + const { link, onClick } = setup({ + name: "Complete Table", + initial_sync_status: "complete", + }); + userEvent.click(link); + expect(onClick).toHaveBeenCalled(); + }); + + it("tables with initial_sync_status='incomplete' are not clickable", () => { + const { link, onClick } = setup({ + name: "Incomplete Table", + initial_sync_status: "incomplete", + }); + userEvent.click(link); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("tables with initial_sync_status='aborted' are not clickable", () => { + const { link, onClick } = setup({ + name: "Aborted Table", + initial_sync_status: "aborted", + }); + userEvent.click(link); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/metabase/search/components/SearchResult/tests/SearchResult.unit.spec.tsx b/frontend/src/metabase/search/components/SearchResult/tests/SearchResult.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b80e466c97d92420f0e104b01a04e717ea2c444 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchResult/tests/SearchResult.unit.spec.tsx @@ -0,0 +1,61 @@ +import { + setupCollectionByIdEndpoint, + setupUsersEndpoints, +} from "__support__/server-mocks"; +import { getIcon, renderWithProviders, screen } from "__support__/ui"; +import { createMockCollection, createMockUser } from "metabase-types/api/mocks"; +import { SearchResult } from "metabase/search/components/SearchResult/SearchResult"; +import { createWrappedSearchResult } from "metabase/search/components/SearchResult/tests/util"; +import type { WrappedResult } from "metabase/search/types"; + +const TEST_REGULAR_COLLECTION = createMockCollection({ + id: 1, + name: "Regular Collection", + authority_level: null, +}); + +const TEST_RESULT_QUESTION = createWrappedSearchResult({ + name: "My Item", + model: "card", + description: "My Item Description", + getIcon: () => ({ name: "table" }), +}); + +const TEST_RESULT_COLLECTION = createWrappedSearchResult({ + name: "My Folder of Goodies", + model: "collection", + collection: TEST_REGULAR_COLLECTION, +}); + +const setup = ({ result }: { result: WrappedResult }) => { + setupCollectionByIdEndpoint({ + collections: [TEST_REGULAR_COLLECTION], + }); + + setupUsersEndpoints([createMockUser()]); + + renderWithProviders(<SearchResult result={result} />); +}; + +describe("SearchResult", () => { + it("renders a search result question item", () => { + setup({ result: TEST_RESULT_QUESTION }); + + expect(screen.getByText(TEST_RESULT_QUESTION.name)).toBeInTheDocument(); + expect( + screen.getByText(TEST_RESULT_QUESTION.description as string), + ).toBeInTheDocument(); + expect(getIcon("table")).toBeInTheDocument(); + }); + + it("renders a search result collection item", () => { + setup({ result: TEST_RESULT_COLLECTION }); + + expect(screen.getByText(TEST_RESULT_COLLECTION.name)).toBeInTheDocument(); + expect(screen.getByText("Collection")).toBeInTheDocument(); + expect( + screen.queryByText(TEST_RESULT_COLLECTION.collection.name), + ).not.toBeInTheDocument(); + expect(getIcon("folder")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/search/components/SearchResult/tests/util.ts b/frontend/src/metabase/search/components/SearchResult/tests/util.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd86b06a58a43aaad84c121bae7cef02e7981429 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchResult/tests/util.ts @@ -0,0 +1,15 @@ +import { createMockSearchResult } from "metabase-types/api/mocks"; +import type { WrappedResult } from "metabase/search/types"; + +export const createWrappedSearchResult = ( + options: Partial<WrappedResult>, +): WrappedResult => { + const result = createMockSearchResult(options); + + return { + ...result, + getUrl: options.getUrl ?? (() => "/collection/root"), + getIcon: options.getIcon ?? (() => ({ name: "folder" })), + getCollection: options.getCollection ?? (() => result.collection), + }; +}; diff --git a/frontend/src/metabase/search/components/SearchResultLink/SearchResultLink.styled.tsx b/frontend/src/metabase/search/components/SearchResultLink/SearchResultLink.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..966e6ffd114243fbf736146b71839d5fb592551d --- /dev/null +++ b/frontend/src/metabase/search/components/SearchResultLink/SearchResultLink.styled.tsx @@ -0,0 +1,30 @@ +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; +import type { TextProps, AnchorProps } from "metabase/ui"; +import { Group } from "metabase/ui"; + +type ResultLinkProps = AnchorProps | TextProps; + +export const ResultLink = styled.a<ResultLinkProps>` + line-height: unset; + + ${({ theme, href }) => { + return ( + href && + css` + &:hover, + &:focus, + &:focus-within { + color: ${theme.colors.brand[1]}; + outline: 0; + } + ` + ); + }}; + + transition: color 0.2s ease-in-out; +`; + +export const ResultLinkWrapper = styled(Group)` + overflow: hidden; +`; diff --git a/frontend/src/metabase/search/components/SearchResultLink/SearchResultLink.tsx b/frontend/src/metabase/search/components/SearchResultLink/SearchResultLink.tsx new file mode 100644 index 0000000000000000000000000000000000000000..abe436408fb003cc65f92db4f94785c4cede072d --- /dev/null +++ b/frontend/src/metabase/search/components/SearchResultLink/SearchResultLink.tsx @@ -0,0 +1,47 @@ +import Tooltip from "metabase/core/components/Tooltip"; +import { Anchor, Text } from "metabase/ui"; +import { useIsTruncated } from "metabase/hooks/use-is-truncated"; +import { ResultLink, ResultLinkWrapper } from "./SearchResultLink.styled"; + +export const SearchResultLink = ({ + children, + leftIcon = null, + href = null, +}: { + children: JSX.Element | string | null; + leftIcon?: JSX.Element | null; + href?: string | null; +}) => { + const { isTruncated, ref: truncatedRef } = + useIsTruncated<HTMLAnchorElement>(); + + const componentProps = href + ? { + as: Anchor, + href, + td: "underline", + } + : { + as: Text, + td: "none", + }; + + return ( + <Tooltip isEnabled={isTruncated} tooltip={children}> + <ResultLinkWrapper data-testid="result-link-wrapper" spacing="xs" noWrap> + {leftIcon} + <ResultLink + {...componentProps} + span + c="text.1" + size="sm" + truncate + onClick={e => e.stopPropagation()} + ref={truncatedRef} + > + {children} + </ResultLink> + </ResultLinkWrapper> + </Tooltip> + ); +}; diff --git a/frontend/src/metabase/search/components/SearchResultLink/index.ts b/frontend/src/metabase/search/components/SearchResultLink/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bb8a54f2b7bf9bd6822e9459e79166a67516d4c --- /dev/null +++ b/frontend/src/metabase/search/components/SearchResultLink/index.ts @@ -0,0 +1 @@ +export * from "./SearchResultLink"; diff --git a/frontend/src/metabase/search/components/SearchSidebar/SearchSidebar.tsx b/frontend/src/metabase/search/components/SearchSidebar/SearchSidebar.tsx index e3cafc2b79ce8ba6f6014a789f5180a838548634..099103155af1a14550e009d9a2ca8689087cf2e2 100644 --- a/frontend/src/metabase/search/components/SearchSidebar/SearchSidebar.tsx +++ b/frontend/src/metabase/search/components/SearchSidebar/SearchSidebar.tsx @@ -1,35 +1,43 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import _ from "underscore"; import type { FilterTypeKeys, - SearchFilterPropTypes, - SearchFilters, - SearchSidebarFilterComponent, + SearchFilterComponent, + SearchQueryParamValue, + URLSearchFilterQueryParams, } from "metabase/search/types"; -import { Title, Flex } from "metabase/ui"; +import { Stack } from "metabase/ui"; import { SearchFilterKeys } from "metabase/search/constants"; -import { SidebarFilter } from "metabase/search/components/SidebarFilter/SidebarFilter"; -import { TypeFilter } from "metabase/search/components/filters/TypeFilter/TypeFilter"; +import { DropdownSidebarFilter } from "metabase/search/components/DropdownSidebarFilter"; +import { TypeFilter } from "metabase/search/components/filters/TypeFilter"; +import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins"; +import { ToggleSidebarFilter } from "metabase/search/components/ToggleSidebarFilter"; +import { CreatedByFilter } from "metabase/search/components/filters/CreatedByFilter"; +import { NativeQueryFilter } from "metabase/search/components/filters/NativeQueryFilter"; +import { LastEditedByFilter } from "metabase/search/components/filters/LastEditedByFilter"; +import { LastEditedAtFilter } from "metabase/search/components/filters/LastEditedAtFilter"; +import { CreatedAtFilter } from "metabase/search/components/filters/CreatedAtFilter"; -export const filterMap: Record<FilterTypeKeys, SearchSidebarFilterComponent> = { - [SearchFilterKeys.Type]: TypeFilter, +type SearchSidebarProps = { + value: URLSearchFilterQueryParams; + onChange: (value: URLSearchFilterQueryParams) => void; }; -export const SearchSidebar = ({ - value, - onChangeFilters, -}: { - value: SearchFilters; - onChangeFilters: (filters: SearchFilters) => void; -}) => { - const onOutputChange = ( - key: FilterTypeKeys, - val: SearchFilterPropTypes[FilterTypeKeys], - ) => { - if (!val || val.length === 0) { - onChangeFilters(_.omit(value, key)); +export const SearchSidebar = ({ value, onChange }: SearchSidebarProps) => { + const filterMap: Record<FilterTypeKeys, SearchFilterComponent> = { + [SearchFilterKeys.Type]: TypeFilter, + [SearchFilterKeys.CreatedBy]: CreatedByFilter, + [SearchFilterKeys.CreatedAt]: CreatedAtFilter, + [SearchFilterKeys.LastEditedBy]: LastEditedByFilter, + [SearchFilterKeys.LastEditedAt]: LastEditedAtFilter, + [SearchFilterKeys.Verified]: PLUGIN_CONTENT_VERIFICATION.VerifiedFilter, + [SearchFilterKeys.NativeQuery]: NativeQueryFilter, + }; + + const onOutputChange = (key: FilterTypeKeys, val?: SearchQueryParamValue) => { + if (!val) { + onChange(_.omit(value, key)); } else { - onChangeFilters({ + onChange({ ...value, [key]: val, }); @@ -37,18 +45,49 @@ export const SearchSidebar = ({ }; const getFilter = (key: FilterTypeKeys) => { - const Filter = filterMap[key]; - const normalizedValue = - Array.isArray(value[key]) || !value[key] ? value[key] : [value[key]]; - return ( - <SidebarFilter - filter={Filter} - data-testid={`${key}-search-filter`} - value={normalizedValue} - onChange={value => onOutputChange(key, value)} - /> - ); + const Filter: SearchFilterComponent = filterMap[key]; + + if (!Filter.type) { + return null; + } + + const filterValue = Filter.fromUrl(value[key]); + + if (Filter.type === "toggle") { + return ( + <ToggleSidebarFilter + filter={Filter} + value={filterValue} + data-testid={`${key}-search-filter`} + onChange={value => onOutputChange(key, Filter.toUrl(value))} + /> + ); + } else if (Filter.type === "dropdown") { + return ( + <DropdownSidebarFilter + filter={Filter} + data-testid={`${key}-search-filter`} + value={filterValue} + onChange={value => onOutputChange(key, Filter.toUrl(value))} + /> + ); + } + return null; }; - return <Flex direction="column">{getFilter(SearchFilterKeys.Type)}</Flex>; + return ( + <Stack spacing="lg"> + {getFilter(SearchFilterKeys.Type)} + <Stack spacing="sm"> + {getFilter(SearchFilterKeys.CreatedBy)} + {getFilter(SearchFilterKeys.LastEditedBy)} + </Stack> + <Stack spacing="sm"> + {getFilter(SearchFilterKeys.CreatedAt)} + {getFilter(SearchFilterKeys.LastEditedAt)} + </Stack> + {getFilter(SearchFilterKeys.Verified)} + {getFilter(SearchFilterKeys.NativeQuery)} + </Stack> + ); }; diff --git a/frontend/src/metabase/search/components/SearchSidebar/index.ts b/frontend/src/metabase/search/components/SearchSidebar/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..37510d01e362dbce1a34ec056a3067f7f13a25b7 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchSidebar/index.ts @@ -0,0 +1 @@ +export { SearchSidebar } from "./SearchSidebar"; diff --git a/frontend/src/metabase/search/components/SearchSidebar/tests/enterprise.unit.spec.tsx b/frontend/src/metabase/search/components/SearchSidebar/tests/enterprise.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb4ce89b2a1a847fd1ec290a523c8ed6c588bbea --- /dev/null +++ b/frontend/src/metabase/search/components/SearchSidebar/tests/enterprise.unit.spec.tsx @@ -0,0 +1,20 @@ +import { screen } from "__support__/ui"; +import type { SearchSidebarSetupOptions } from "./setup"; +import { setup } from "./setup"; + +const setupEnterprise = async (opts?: SearchSidebarSetupOptions) => { + setup({ + ...opts, + hasEnterprisePlugins: true, + }); +}; + +describe("SearchFilterSidebar", () => { + it("should not render `Verified` filter when content_verification plugin is not enabled", async () => { + await setupEnterprise(); + + expect( + screen.queryByTestId("verified-search-filter"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/search/components/SearchSidebar/tests/premium.unit.spec.tsx b/frontend/src/metabase/search/components/SearchSidebar/tests/premium.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b39da7ea90566bc37f704dea454eb5245b80cf07 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchSidebar/tests/premium.unit.spec.tsx @@ -0,0 +1,20 @@ +import { createMockTokenFeatures } from "metabase-types/api/mocks"; +import { screen } from "__support__/ui"; +import type { SearchSidebarSetupOptions } from "metabase/search/components/SearchSidebar/tests/setup"; +import { setup } from "metabase/search/components/SearchSidebar/tests/setup"; + +const setupPremium = async (opts?: SearchSidebarSetupOptions) => { + setup({ + ...opts, + tokenFeatures: createMockTokenFeatures({ content_verification: true }), + hasEnterprisePlugins: true, + }); +}; + +describe("SearchFilterSidebar", () => { + it("renders `Verified` filter", async () => { + await setupPremium(); + + expect(screen.getByTestId("verified-search-filter")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/search/components/SearchSidebar/tests/setup.tsx b/frontend/src/metabase/search/components/SearchSidebar/tests/setup.tsx index 443f072a6204b9645525f8b415168611bd8a1d1a..29a5772b6412ff8a7ca0852dbff535ca7cf644ed 100644 --- a/frontend/src/metabase/search/components/SearchSidebar/tests/setup.tsx +++ b/frontend/src/metabase/search/components/SearchSidebar/tests/setup.tsx @@ -1,11 +1,44 @@ +import { setupDatabasesEndpoints } from "__support__/server-mocks"; import { renderWithProviders } from "__support__/ui"; -import { SearchSidebar } from "metabase/search/components/SearchSidebar/SearchSidebar"; +import { + createMockDatabase, + createMockTokenFeatures, +} from "metabase-types/api/mocks"; +import { setupEnterprisePlugins } from "__support__/enterprise"; +import { createMockState } from "metabase-types/store/mocks"; +import { mockSettings } from "__support__/settings"; +import type { TokenFeatures } from "metabase-types/api"; +import type { URLSearchFilterQueryParams } from "metabase/search/types"; +import { SearchSidebar } from "metabase/search/components/SearchSidebar"; -export const setup = ({ value = {}, onChangeFilters = jest.fn() } = {}) => { - const defaultProps = { - value, - onChangeFilters, - }; +export interface SearchSidebarSetupOptions { + tokenFeatures?: TokenFeatures; + hasEnterprisePlugins?: boolean; + value?: URLSearchFilterQueryParams; + onChange?: (filters: URLSearchFilterQueryParams) => void; +} - renderWithProviders(<SearchSidebar {...defaultProps} />); +const TEST_DATABASE = createMockDatabase(); + +export const setup = ({ + tokenFeatures = createMockTokenFeatures(), + hasEnterprisePlugins = false, + value = {}, + onChange = jest.fn(), +}: SearchSidebarSetupOptions = {}) => { + setupDatabasesEndpoints([TEST_DATABASE]); + + const settings = mockSettings({ "token-features": tokenFeatures }); + + const state = createMockState({ + settings, + }); + + if (hasEnterprisePlugins) { + setupEnterprisePlugins(); + } + + renderWithProviders(<SearchSidebar value={value} onChange={onChange} />, { + storeInitialState: state, + }); }; diff --git a/frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.styled.tsx b/frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf3aba377499c2e800669069b7bee5a8c8c7418b --- /dev/null +++ b/frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.styled.tsx @@ -0,0 +1,46 @@ +import styled from "@emotion/styled"; +import { css } from "@emotion/react"; +import type { HTMLAttributes } from "react"; +import type { ButtonProps } from "metabase/ui"; +import { Stack, Button, Group, TextInput } from "metabase/ui"; + +export const SearchUserPickerContainer = styled(Stack)` + overflow: hidden; +`; + +export const SearchUserItemContainer = styled(Group)` + overflow-y: auto; +`; + +export const UserPickerInput = styled(TextInput)` + flex: 1; +`; + +export const SearchUserPickerContent = styled(Stack)` + overflow-y: auto; + flex: 1; +`; + +export const SearchUserSelectBox = styled(Stack)` + border: ${({ theme }) => theme.colors.border[0]} 1px solid; + border-radius: ${({ theme }) => theme.radius.md}; +`; + +export const SelectedUserButton = styled(Button)< + ButtonProps & HTMLAttributes<HTMLButtonElement> +>` + ${({ theme }) => { + const primaryColor = theme.colors.brand[1]; + const backgroundColor = theme.fn.lighten(primaryColor, 0.8); + const hoverBackgroundColor = theme.fn.lighten(primaryColor, 0.6); + + return css` + background-color: ${backgroundColor}; + border: 0; + + &:hover { + background-color: ${hoverBackgroundColor}; + } + `; + }} +`; diff --git a/frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.tsx b/frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..20546e2215dcf623a482897ea52050f4c8e863ab --- /dev/null +++ b/frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.tsx @@ -0,0 +1,132 @@ +import { without } from "underscore"; +import { useState } from "react"; +import { t } from "ttag"; +import type { UserId, UserListResult } from "metabase-types/api"; +import { Center, Text } from "metabase/ui"; +import { SearchFilterPopoverWrapper } from "metabase/search/components/SearchFilterPopoverWrapper"; +import { + SearchUserItemContainer, + SearchUserPickerContainer, + SearchUserPickerContent, + SearchUserSelectBox, + SelectedUserButton, + UserPickerInput, +} from "metabase/search/components/SearchUserPicker/SearchUserPicker.styled"; +import { useUserListQuery } from "metabase/common/hooks/use-user-list-query"; +import { UserListElement } from "metabase/search/components/UserListElement"; +import { Icon } from "metabase/core/components/Icon"; + +export const SearchUserPicker = ({ + value, + onChange, +}: { + value: UserId[]; + onChange: (value: UserId[]) => void; +}) => { + const { data: users = [], isLoading } = useUserListQuery(); + + const [userFilter, setUserFilter] = useState(""); + const [selectedUserIds, setSelectedUserIds] = useState(value); + + const isSelected = (user: UserListResult) => + selectedUserIds.includes(user.id); + + const filteredUsers = users.filter(user => { + return ( + user.common_name.toLowerCase().includes(userFilter.toLowerCase()) && + !isSelected(user) + ); + }); + + const removeUser = (user?: UserListResult) => { + if (user) { + setSelectedUserIds(without(selectedUserIds, user.id)); + } + }; + + const addUser = (user: UserListResult) => { + setSelectedUserIds([...selectedUserIds, user.id]); + }; + + const onUserSelect = (user: UserListResult) => { + if (isSelected(user)) { + removeUser(user); + } else { + addUser(user); + } + }; + + const generateUserListElements = (userList: UserListResult[]) => { + return userList.map(user => ( + <UserListElement + key={user.id} + isSelected={isSelected(user)} + onClick={onUserSelect} + value={user} + /> + )); + }; + + return ( + <SearchFilterPopoverWrapper + isLoading={isLoading} + onApply={() => onChange(selectedUserIds)} + > + <SearchUserPickerContainer p="sm" spacing="xs"> + <SearchUserSelectBox spacing={0}> + <SearchUserItemContainer + data-testid="search-user-select-box" + spacing="xs" + p="xs" + mah="30vh" + > + {selectedUserIds.map(userId => { + const user = users.find(user => user.id === userId); + return ( + <SelectedUserButton + data-testid="selected-user-button" + key={userId} + c="brand.1" + px="md" + py="sm" + maw="100%" + rightIcon={<Icon name="close" />} + onClick={() => removeUser(user)} + > + <Text align="left" w="100%" truncate c="inherit"> + {user?.common_name} + </Text> + </SelectedUserButton> + ); + })} + <UserPickerInput + variant="unstyled" + pl="sm" + size="md" + placeholder={t`Search for someone…`} + value={userFilter} + tabIndex={0} + onChange={event => setUserFilter(event.currentTarget.value)} + mt="-0.25rem" + miw="18ch" + /> + </SearchUserItemContainer> + </SearchUserSelectBox> + <SearchUserPickerContent + data-testid="search-user-list" + h="100%" + spacing="xs" + p="xs" + > + {filteredUsers.length > 0 ? ( + generateUserListElements(filteredUsers) + ) : ( + <Center py="md"> + <Text size="md" weight={700}>{t`No results`}</Text> + </Center> + )} + </SearchUserPickerContent> + </SearchUserPickerContainer> + </SearchFilterPopoverWrapper> + ); +}; diff --git a/frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.unit.spec.tsx b/frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..721ed1069381fbd8f60add264f95553462981072 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.unit.spec.tsx @@ -0,0 +1,163 @@ +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { createMockUser } from "metabase-types/api/mocks"; +import type { User, UserId } from "metabase-types/api"; +import { screen, renderWithProviders, waitFor, within } from "__support__/ui"; +import type { CreatedByFilterProps } from "metabase/search/types"; +import { setupUsersEndpoints } from "__support__/server-mocks"; +import { SearchUserPicker } from "metabase/search/components/SearchUserPicker"; + +const TEST_USERS: User[] = [ + createMockUser({ id: 1, common_name: "Alice" }), + createMockUser({ id: 2, common_name: "Bob" }), +]; + +const TestSearchUserPicker = ({ + value, + onChange, +}: { + value: UserId[]; + onChange: jest.Func; +}) => { + const [selectedUserIds, setSelectedUserIds] = + useState<CreatedByFilterProps>(value); + const onUserChange = (userIds: CreatedByFilterProps) => { + setSelectedUserIds(userIds); + onChange(userIds); + }; + return <SearchUserPicker value={selectedUserIds} onChange={onUserChange} />; +}; + +const setup = async ({ + initialSelectedUsers = [], +}: { initialSelectedUsers?: UserId[] } = {}) => { + setupUsersEndpoints(TEST_USERS); + + const mockOnChange = jest.fn(); + renderWithProviders( + <TestSearchUserPicker + value={initialSelectedUsers} + onChange={mockOnChange} + />, + ); + + await waitFor(() => { + expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); + }); + + return { mockOnChange }; +}; + +describe("SearchUserPicker", () => { + it("should display user list when data is available", async () => { + await setup(); + + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); + + it("should show 'No results' when no users match the filter", async () => { + await setup(); + userEvent.type( + screen.getByPlaceholderText("Search for someone…"), + "Charlie", + ); + expect(screen.getByText("No results")).toBeInTheDocument(); + }); + + it("should show selected users in the select box on initial load", async () => { + await setup({ initialSelectedUsers: TEST_USERS.map(user => user.id) }); + expect( + screen.getAllByTestId("selected-user-button").map(el => el.textContent), + ).toEqual(["Alice", "Bob"]); + + // all users are in the select box, so the search list should be empty + expect(screen.getByText("No results")).toBeInTheDocument(); + }); + + it("should not show any users when there are no selected users on initial load", async () => { + await setup(); + expect( + screen.queryByTestId("selected-user-button"), + ).not.toBeInTheDocument(); + }); + + it("should add user to select box and remove them from search list when user is selected from search list", async () => { + await setup(); + const searchUserList = within(screen.getByTestId("search-user-list")); + + userEvent.click(searchUserList.getByText("Alice")); + expect(screen.getByTestId("selected-user-button")).toHaveTextContent( + "Alice", + ); + + expect(searchUserList.queryByText("Alice")).not.toBeInTheDocument(); + + userEvent.click(searchUserList.getByText("Bob")); + expect( + screen.getAllByTestId("selected-user-button").map(el => el.textContent), + ).toEqual(["Alice", "Bob"]); + + expect(searchUserList.getByText("No results")).toBeInTheDocument(); + }); + + it("should remove user from select box and add them to search list when user is remove from select box", async () => { + await setup({ + initialSelectedUsers: TEST_USERS.map(user => user.id), + }); + + const searchUserList = within(screen.getByTestId("search-user-list")); + const selectBox = within(screen.getByTestId("search-user-select-box")); + + // expect the two users are in the select box and not in the search list + expect(searchUserList.getByText("No results")).toBeInTheDocument(); + expect(selectBox.getByText("Alice")).toBeInTheDocument(); + expect(selectBox.getByText("Bob")).toBeInTheDocument(); + + userEvent.click(selectBox.getByText("Alice")); + expect(screen.getByTestId("selected-user-button")).toHaveTextContent("Bob"); + expect(searchUserList.getByText("Alice")).toBeInTheDocument(); + + userEvent.click(selectBox.getByText("Bob")); + + // expect the two users are only in the search list now + expect( + screen.queryByTestId("selected-user-button"), + ).not.toBeInTheDocument(); + expect(searchUserList.getByText("Alice")).toBeInTheDocument(); + expect(searchUserList.getByText("Bob")).toBeInTheDocument(); + }); + + it("should filter users when user types in the search box", async () => { + await setup(); + userEvent.type(screen.getByPlaceholderText("Search for someone…"), "Alice"); + const searchUserList = within(screen.getByTestId("search-user-list")); + expect(searchUserList.getByText("Alice")).toBeInTheDocument(); + + expect(searchUserList.queryByText("Bob")).not.toBeInTheDocument(); + }); + + it("should call onChange with a list of user ids when the user clicks Apply with a selection", async () => { + const { mockOnChange } = await setup(); + + const searchUserList = within(screen.getByTestId("search-user-list")); + userEvent.click(searchUserList.getByText("Alice")); + userEvent.click(searchUserList.getByText("Bob")); + + userEvent.click(screen.getByText("Apply")); + + expect(mockOnChange).toHaveBeenCalledWith([1, 2]); + }); + + it("should call onChange with an empty list when the user clicks Apply with no selection", async () => { + const { mockOnChange } = await setup({ + initialSelectedUsers: TEST_USERS.map(user => user.id), + }); + const searchUserList = within(screen.getByTestId("search-user-select-box")); + userEvent.click(searchUserList.getByText("Alice")); + userEvent.click(searchUserList.getByText("Bob")); + + userEvent.click(screen.getByText("Apply")); + expect(mockOnChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/frontend/src/metabase/search/components/SearchUserPicker/index.ts b/frontend/src/metabase/search/components/SearchUserPicker/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ace4802c368a832441cd98cb922c9cec4e229a3b --- /dev/null +++ b/frontend/src/metabase/search/components/SearchUserPicker/index.ts @@ -0,0 +1,2 @@ +export { SearchUserPicker } from "./SearchUserPicker"; +export * from "./SearchUserPicker.styled"; diff --git a/frontend/src/metabase/search/components/SidebarFilter/SidebarFilter.styled.tsx b/frontend/src/metabase/search/components/SidebarFilter/SidebarFilter.styled.tsx deleted file mode 100644 index e659f438622fee90fce121b484f5e98f4347abb4..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/search/components/SidebarFilter/SidebarFilter.styled.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import styled from "@emotion/styled"; -import { css } from "@emotion/react"; -import { ParameterFieldSet } from "metabase/parameters/components/ParameterWidget/ParameterWidget.styled"; - -export const DropdownFilterElement = styled(ParameterFieldSet)` - height: 40px; - - ${({ theme, fieldHasValueOrFocus }) => { - return ( - fieldHasValueOrFocus && - css` - border-color: ${theme.colors.brand[1]}; - color: ${theme.colors.brand[1]}; - ` - ); - }} - &:hover { - ${({ theme }) => { - return css` - background-color: ${theme.colors?.bg[1]}; - transition: background-color 0.3s; - cursor: pointer; - `; - }} - } - - @media screen and (min-width: 440px) { - margin-right: 0; - } -`; -export const DropdownApplyButtonDivider = styled.hr` - ${({ theme }) => { - return css` - border-top: 1px solid ${theme.colors?.border[0]}; - `; - }} -`; diff --git a/frontend/src/metabase/search/components/SidebarFilter/SidebarFilter.tsx b/frontend/src/metabase/search/components/SidebarFilter/SidebarFilter.tsx deleted file mode 100644 index ed65e6ba628a9ebafb11c559d665b03752de7f9e..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/search/components/SidebarFilter/SidebarFilter.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { isEmpty } from "underscore"; -import type { MouseEvent } from "react"; -import { useLayoutEffect, useRef, useState } from "react"; -import { t } from "ttag"; -import type { - SearchFilterComponentProps, - SearchSidebarFilterComponent, -} from "metabase/search/types"; -import { Box, Button, Group, Text } from "metabase/ui"; -import type { IconName } from "metabase/core/components/Icon"; -import { Icon } from "metabase/core/components/Icon"; -import Popover from "metabase/components/Popover"; -import { useSelector } from "metabase/lib/redux"; -import { getIsNavbarOpen } from "metabase/selectors/app"; -import useIsSmallScreen from "metabase/hooks/use-is-small-screen"; -import { - DropdownApplyButtonDivider, - DropdownFilterElement, -} from "./SidebarFilter.styled"; - -export type SearchSidebarFilterProps = { - filter: SearchSidebarFilterComponent; -} & SearchFilterComponentProps; - -export const SidebarFilter = ({ - filter: { title, iconName, DisplayComponent, ContentComponent }, - "data-testid": dataTestId, - value, - onChange, -}: SearchSidebarFilterProps) => { - const [selectedValues, setSelectedValues] = useState(value); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const isNavbarOpen = useSelector(getIsNavbarOpen); - const isSmallScreen = useIsSmallScreen(); - - const dropdownRef = useRef<HTMLDivElement>(null); - const [popoverWidth, setPopoverWidth] = useState<string | null>(null); - - const fieldHasValue = !isEmpty(value); - - const handleResize = () => { - if (dropdownRef.current) { - const { width } = dropdownRef.current.getBoundingClientRect(); - setPopoverWidth(`${width}px`); - } - }; - - useLayoutEffect(() => { - if (!popoverWidth) { - handleResize(); - } - window.addEventListener("resize", handleResize, false); - return () => window.removeEventListener("resize", handleResize, false); - }, [dropdownRef, popoverWidth]); - - useLayoutEffect(() => { - if (isNavbarOpen && isSmallScreen) { - setIsPopoverOpen(false); - } - }, [isNavbarOpen, isSmallScreen]); - - const onApplyFilter = () => { - onChange(selectedValues); - setIsPopoverOpen(false); - }; - - const onClearFilter = (e: MouseEvent) => { - if (fieldHasValue) { - e.stopPropagation(); - setSelectedValues(undefined); - onChange(undefined); - setIsPopoverOpen(false); - } - }; - - const onPopoverClose = () => { - // reset selection to the current filter state - setSelectedValues(value); - setIsPopoverOpen(false); - }; - - const getDropdownIcon = (): IconName => { - if (fieldHasValue) { - return "close"; - } else { - return isPopoverOpen ? "chevronup" : "chevrondown"; - } - }; - - return ( - <div data-testid={dataTestId} ref={dropdownRef}> - <div onClick={() => setIsPopoverOpen(!isPopoverOpen)}> - <DropdownFilterElement - noPadding - fieldHasValueOrFocus={fieldHasValue} - legend={fieldHasValue ? title : null} - > - <Group position="apart"> - {fieldHasValue ? ( - <DisplayComponent value={value} /> - ) : ( - <Group noWrap> - <Icon name={iconName} /> - <Text weight={700}>{title}</Text> - </Group> - )} - <Button - style={{ pointerEvents: "all" }} - data-testid="sidebar-filter-dropdown-button" - compact - c="inherit" - variant="subtle" - onClick={onClearFilter} - leftIcon={<Icon name={getDropdownIcon()} />} - /> - </Group> - </DropdownFilterElement> - </div> - <Popover - isOpen={isPopoverOpen} - onClose={onPopoverClose} - target={dropdownRef.current} - ignoreTrigger - autoWidth - > - <Box p="md" w={popoverWidth ?? "100%"}> - <ContentComponent - value={selectedValues} - onChange={selected => setSelectedValues(selected)} - /> - </Box> - <DropdownApplyButtonDivider /> - <Group position="right" align="center" px="sm" pb="sm"> - <Button onClick={onApplyFilter}>{t`Apply filters`}</Button> - </Group> - </Popover> - </div> - ); -}; diff --git a/frontend/src/metabase/search/components/ToggleSidebarFilter/ToggleSidebarFilter.styled.tsx b/frontend/src/metabase/search/components/ToggleSidebarFilter/ToggleSidebarFilter.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0130ef7aa8adf4df16746d418d0abe5be816c6d6 --- /dev/null +++ b/frontend/src/metabase/search/components/ToggleSidebarFilter/ToggleSidebarFilter.styled.tsx @@ -0,0 +1,8 @@ +import styled from "@emotion/styled"; +import { Switch } from "metabase/ui"; + +export const FilterSwitch = styled(Switch)` + .emotion-Switch-body { + justify-content: space-between; + } +`; diff --git a/frontend/src/metabase/search/components/ToggleSidebarFilter/ToggleSidebarFilter.tsx b/frontend/src/metabase/search/components/ToggleSidebarFilter/ToggleSidebarFilter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2dc4b7aa83d29b7797507a7c0eb0e2c5d1731d83 --- /dev/null +++ b/frontend/src/metabase/search/components/ToggleSidebarFilter/ToggleSidebarFilter.tsx @@ -0,0 +1,32 @@ +import type { SearchFilterToggle } from "metabase/search/types"; +import { Text } from "metabase/ui"; +import { FilterSwitch } from "./ToggleSidebarFilter.styled"; + +export type ToggleSidebarFilterProps = { + filter: SearchFilterToggle; + value: boolean; + onChange: (value: boolean) => void; + "data-testid"?: string; +}; + +export const ToggleSidebarFilter = ({ + filter: { label }, + value, + onChange, + "data-testid": dataTestId, +}: ToggleSidebarFilterProps) => { + return ( + <FilterSwitch + wrapperProps={{ + "data-testid": dataTestId, + }} + data-testid="toggle-filter-switch" + size="sm" + labelPosition="left" + label={<Text color="text.2">{label()}</Text>} + data-is-checked={value} + checked={value} + onChange={event => onChange(event.currentTarget.checked)} + /> + ); +}; diff --git a/frontend/src/metabase/search/components/ToggleSidebarFilter/ToggleSidebarFilter.unit.spec.tsx b/frontend/src/metabase/search/components/ToggleSidebarFilter/ToggleSidebarFilter.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92ffec7bac0232a2d92cee535144876a457aac42 --- /dev/null +++ b/frontend/src/metabase/search/components/ToggleSidebarFilter/ToggleSidebarFilter.unit.spec.tsx @@ -0,0 +1,101 @@ +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { renderWithProviders, screen } from "__support__/ui"; +import type { SearchFilterComponent } from "metabase/search/types"; +import type { ToggleSidebarFilterProps } from "metabase/search/components/ToggleSidebarFilter"; +import { ToggleSidebarFilter } from "metabase/search/components/ToggleSidebarFilter"; + +const mockFilter: SearchFilterComponent = { + label: () => "Mock Filter", + iconName: "filter", + type: "toggle", + fromUrl: value => value, + toUrl: value => value, +}; + +const MockToggleSidebarFilter = ({ + filter, + value, + onChange, +}: ToggleSidebarFilterProps) => { + const [toggleValue, setToggleValue] = useState(value); + const onFilterChange = (toggleValue: ToggleSidebarFilterProps["value"]) => { + setToggleValue(toggleValue); + onChange(toggleValue); + }; + + return ( + <ToggleSidebarFilter + filter={filter} + value={toggleValue} + onChange={onFilterChange} + /> + ); +}; + +const setup = ({ + value = false, + onChange = jest.fn(), +}: { + value?: ToggleSidebarFilterProps["value"]; + onChange?: jest.Mock; +} = {}) => { + renderWithProviders( + <MockToggleSidebarFilter + filter={mockFilter} + value={value} + onChange={onChange} + />, + ); + + return { + onChange, + }; +}; + +describe("ToggleSidebarFilter", () => { + it("should render the component with the title", () => { + setup({ + onChange: jest.fn(), + }); + + const titleElement = screen.getByText("Mock Filter"); + const switchElement = screen.getByTestId("toggle-filter-switch"); + + expect(titleElement).toBeInTheDocument(); + expect(switchElement).toBeInTheDocument(); + }); + + it("should call the onChange function when the switch is toggled", () => { + const onChangeMock = jest.fn(); + setup({ + value: undefined, + onChange: onChangeMock, + }); + + const switchElement = screen.getByRole("checkbox"); + userEvent.click(switchElement); + + expect(onChangeMock).toHaveBeenCalledTimes(1); + expect(onChangeMock).toHaveBeenCalledWith(true); + + expect(switchElement).toHaveAttribute("data-is-checked", "true"); + }); + + it("should have the switch checked when value is true", () => { + setup({ + value: true, + onChange: jest.fn(), + }); + + const switchElement = screen.getByRole("checkbox"); + expect(switchElement).toHaveAttribute("data-is-checked", "true"); + }); + + it("should have the switch unchecked when value is false", () => { + setup(); + + const switchElement = screen.getByRole("checkbox"); + expect(switchElement).toHaveAttribute("data-is-checked", "false"); + }); +}); diff --git a/frontend/src/metabase/search/components/ToggleSidebarFilter/index.ts b/frontend/src/metabase/search/components/ToggleSidebarFilter/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..50400b24cdb00ffeb1c3f3256d846d96849abc62 --- /dev/null +++ b/frontend/src/metabase/search/components/ToggleSidebarFilter/index.ts @@ -0,0 +1,2 @@ +export { ToggleSidebarFilter } from "./ToggleSidebarFilter"; +export type { ToggleSidebarFilterProps } from "./ToggleSidebarFilter"; diff --git a/frontend/src/metabase/search/components/UserListElement/UserListElement.styled.tsx b/frontend/src/metabase/search/components/UserListElement/UserListElement.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a7e0510036e340fedf7e37934450c43444b31e0 --- /dev/null +++ b/frontend/src/metabase/search/components/UserListElement/UserListElement.styled.tsx @@ -0,0 +1,17 @@ +import styled from "@emotion/styled"; +import type { HTMLAttributes } from "react"; +import type { ButtonProps } from "metabase/ui"; +import { Button } from "metabase/ui"; + +export const UserElement = styled(Button)< + HTMLAttributes<HTMLButtonElement> & ButtonProps +>` + &:hover { + background-color: ${({ theme }) => theme.colors.brand[0]}; + } + + & > div { + display: flex; + justify-content: flex-start; + } +`; diff --git a/frontend/src/metabase/search/components/UserListElement/UserListElement.tsx b/frontend/src/metabase/search/components/UserListElement/UserListElement.tsx new file mode 100644 index 0000000000000000000000000000000000000000..185ad0318ed2b88db0ab02cbaf23d573bfcf0cef --- /dev/null +++ b/frontend/src/metabase/search/components/UserListElement/UserListElement.tsx @@ -0,0 +1,29 @@ +import type { UserListResult } from "metabase-types/api"; +import { Text } from "metabase/ui"; +import { UserElement } from "./UserListElement.styled"; + +export type UserListElementProps = { + value: UserListResult; + onClick: (value: UserListResult) => void; + isSelected: boolean; +}; + +export const UserListElement = ({ + value, + isSelected, + onClick, +}: UserListElementProps) => ( + <UserElement + data-testid="user-list-element" + onClick={() => onClick(value)} + data-is-selected={isSelected} + px="sm" + py="xs" + variant="subtle" + bg={isSelected ? "brand.0" : undefined} + > + <Text weight={700} color={isSelected ? "brand.1" : undefined} truncate> + {value.common_name} + </Text> + </UserElement> +); diff --git a/frontend/src/metabase/search/components/UserListElement/UserListElement.unit.spec.tsx b/frontend/src/metabase/search/components/UserListElement/UserListElement.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..53de44c62da36925e7dff814c85bebe727ef23ec --- /dev/null +++ b/frontend/src/metabase/search/components/UserListElement/UserListElement.unit.spec.tsx @@ -0,0 +1,54 @@ +import userEvent from "@testing-library/user-event"; +import { renderWithProviders, screen } from "__support__/ui"; +import { createMockUserListResult } from "metabase-types/api/mocks"; +import { UserListElement } from "metabase/search/components/UserListElement/index"; + +const TEST_USER_LIST_RESULT = createMockUserListResult({ + common_name: "Alice Johnson", +}); + +const setup = ({ value = TEST_USER_LIST_RESULT, isSelected = false } = {}) => { + const onClickMock = jest.fn(); + renderWithProviders( + <UserListElement + value={value} + isSelected={isSelected} + onClick={onClickMock} + />, + ); + return { onClickMock }; +}; + +describe("UserListElement", () => { + it("should render the component with user's common name", () => { + setup(); + expect(screen.getByTestId("user-list-element")).toHaveTextContent( + "Alice Johnson", + ); + }); + + it("should call the onClick function when clicked", () => { + const { onClickMock } = setup(); + + userEvent.click(screen.getByText("Alice Johnson")); + + expect(onClickMock).toHaveBeenCalledTimes(1); + expect(onClickMock).toHaveBeenCalledWith(TEST_USER_LIST_RESULT); + }); + + it("should be selected when isSelected is true", () => { + setup({ isSelected: true }); + expect(screen.getByTestId("user-list-element")).toHaveAttribute( + "data-is-selected", + "true", + ); + }); + + it("should not be selected when isSelected is false", () => { + setup(); + expect(screen.getByTestId("user-list-element")).toHaveAttribute( + "data-is-selected", + "false", + ); + }); +}); diff --git a/frontend/src/metabase/search/components/UserListElement/index.ts b/frontend/src/metabase/search/components/UserListElement/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc3997ea6e6949abf035c3cf89af41bfec2a8b5e --- /dev/null +++ b/frontend/src/metabase/search/components/UserListElement/index.ts @@ -0,0 +1 @@ +export { UserListElement } from "./UserListElement"; diff --git a/frontend/src/metabase/search/components/UserNameDisplay/UserNameDisplay.tsx b/frontend/src/metabase/search/components/UserNameDisplay/UserNameDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f360c21076ff01b1298cd1d2c7f6913c9ca56ccc --- /dev/null +++ b/frontend/src/metabase/search/components/UserNameDisplay/UserNameDisplay.tsx @@ -0,0 +1,40 @@ +import { t } from "ttag"; +import { useUserListQuery } from "metabase/common/hooks/use-user-list-query"; +import { Text } from "metabase/ui"; +import type { UserId } from "metabase-types/api"; + +export type UserNameDisplayProps = { + userIdList: UserId[]; + label: string; +}; + +export const UserNameDisplay = ({ + userIdList, + label, +}: UserNameDisplayProps) => { + const { data: users = [], isLoading } = useUserListQuery(); + + const selectedUserList = users.filter(user => userIdList.includes(user.id)); + + const getDisplayValue = () => { + if (isLoading) { + return t`Loading…`; + } + + if (selectedUserList.length === 0) { + return label; + } + + if (selectedUserList.length === 1) { + return selectedUserList[0].common_name ?? t`1 user selected`; + } + + return t`${selectedUserList.length} users selected`; + }; + + return ( + <Text c="inherit" weight={700} truncate> + {getDisplayValue()} + </Text> + ); +}; diff --git a/frontend/src/metabase/search/components/UserNameDisplay/UserNameDisplay.unit.spec.tsx b/frontend/src/metabase/search/components/UserNameDisplay/UserNameDisplay.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0d2886de48777bad1bdeb53437fc97322b6ab2a3 --- /dev/null +++ b/frontend/src/metabase/search/components/UserNameDisplay/UserNameDisplay.unit.spec.tsx @@ -0,0 +1,77 @@ +import { renderWithProviders, screen, waitFor } from "__support__/ui"; +import { createMockUserListResult } from "metabase-types/api/mocks"; +import { setupUsersEndpoints } from "__support__/server-mocks"; +import type { UserListResult } from "metabase-types/api"; +import { UserNameDisplay } from "./UserNameDisplay"; +import type { UserNameDisplayProps } from "./UserNameDisplay"; + +const TEST_USER_LIST_RESULTS = [ + createMockUserListResult({ id: 1, common_name: "Testy Tableton" }), + createMockUserListResult({ id: 2, common_name: "Testy McTestface" }), +]; + +const setup = async ({ + userIdList = [], + users = TEST_USER_LIST_RESULTS, + waitForLoading = true, +}: { + userIdList?: UserNameDisplayProps["userIdList"]; + users?: UserListResult[]; + waitForLoading?: boolean; +} = {}) => { + setupUsersEndpoints(users); + + renderWithProviders( + <UserNameDisplay label={"UserNameDisplay Test"} userIdList={userIdList} />, + ); + + if (waitForLoading) { + await waitFor(() => { + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(); + }); + } +}; + +describe("UserNameDisplay", () => { + it("should initially display loading message when users are selected", async () => { + await setup({ + waitForLoading: false, + userIdList: [TEST_USER_LIST_RESULTS[0].id], + }); + expect(screen.getByText("Loading…")).toBeInTheDocument(); + }); + + it("should initially display title when user list is empty", async () => { + await setup({ waitForLoading: true }); + expect(screen.getByText("UserNameDisplay Test")).toBeInTheDocument(); + }); + + it("should display user name when there's one user in the list", async () => { + await setup({ userIdList: [TEST_USER_LIST_RESULTS[0].id] }); + expect(screen.getByText("Testy Tableton")).toBeInTheDocument(); + }); + + it("should fallback to '1 user selected' if there is one user and they don't have a common name", async () => { + // the backend should always return a `common_name` field, so this is a fallback + + const userWithoutCommonName = createMockUserListResult({ + id: 99999, + common_name: undefined, + }); + + await setup({ + users: [userWithoutCommonName], + userIdList: [userWithoutCommonName.id], + }); + expect(screen.getByText("1 user selected")).toBeInTheDocument(); + }); + + it("should display `X users selected` if there are multiple users", async () => { + await setup({ + userIdList: TEST_USER_LIST_RESULTS.map(user => user.id), + }); + expect( + screen.getByText(`${TEST_USER_LIST_RESULTS.length} users selected`), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/search/components/UserNameDisplay/index.ts b/frontend/src/metabase/search/components/UserNameDisplay/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee8832407f5c66d9523b91ab023afda34f3fe227 --- /dev/null +++ b/frontend/src/metabase/search/components/UserNameDisplay/index.ts @@ -0,0 +1,2 @@ +export { UserNameDisplay } from "./UserNameDisplay"; +export type { UserNameDisplayProps } from "./UserNameDisplay"; diff --git a/frontend/src/metabase/search/components/filters/CreatedAtFilter.tsx b/frontend/src/metabase/search/components/filters/CreatedAtFilter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e60f1dae8f8c45c80404b70df05d79937e75e274 --- /dev/null +++ b/frontend/src/metabase/search/components/filters/CreatedAtFilter.tsx @@ -0,0 +1,25 @@ +/* eslint-disable react/prop-types */ +import { t } from "ttag"; +import { Box } from "metabase/ui"; +import { SearchFilterDateDisplay } from "metabase/search/components/SearchFilterDateDisplay"; +import { SearchFilterDatePicker } from "metabase/search/components/SearchFilterDatePicker"; +import type { SearchFilterDropdown } from "metabase/search/types"; + +export const CreatedAtFilter: SearchFilterDropdown<"created_at"> = { + iconName: "calendar", + label: () => t`Creation date`, + type: "dropdown", + DisplayComponent: ({ value: dateString }) => ( + <SearchFilterDateDisplay + label={CreatedAtFilter.label()} + value={dateString} + /> + ), + ContentComponent: ({ value, onChange, width }) => ( + <Box miw={width} maw="30rem"> + <SearchFilterDatePicker value={value} onChange={onChange} /> + </Box> + ), + fromUrl: value => value, + toUrl: value => value, +}; diff --git a/frontend/src/metabase/search/components/filters/CreatedByFilter/CreatedByFilter.tsx b/frontend/src/metabase/search/components/filters/CreatedByFilter/CreatedByFilter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be109de62c6c57c3542ee27e4de54a01b84c1ae6 --- /dev/null +++ b/frontend/src/metabase/search/components/filters/CreatedByFilter/CreatedByFilter.tsx @@ -0,0 +1,28 @@ +/* eslint-disable react/prop-types */ +import { t } from "ttag"; +import type { SearchFilterDropdown } from "metabase/search/types"; +import { UserNameDisplay } from "metabase/search/components/UserNameDisplay"; +import { + SearchUserPicker, + SearchUserPickerContainer, +} from "metabase/search/components/SearchUserPicker"; +import { + stringifyUserIdArray, + parseUserIdArray, +} from "metabase/search/utils/user-search-params"; + +export const CreatedByFilter: SearchFilterDropdown<"created_by"> = { + iconName: "person", + label: () => t`Creator`, + type: "dropdown", + DisplayComponent: ({ value: userIdList }) => ( + <UserNameDisplay label={CreatedByFilter.label()} userIdList={userIdList} /> + ), + ContentComponent: ({ value, onChange, width }) => ( + <SearchUserPickerContainer w={width}> + <SearchUserPicker value={value} onChange={onChange} /> + </SearchUserPickerContainer> + ), + fromUrl: parseUserIdArray, + toUrl: stringifyUserIdArray, +}; diff --git a/frontend/src/metabase/search/components/filters/CreatedByFilter/index.ts b/frontend/src/metabase/search/components/filters/CreatedByFilter/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a1a624786a6d76d22020f1e21db89c03bfeb594 --- /dev/null +++ b/frontend/src/metabase/search/components/filters/CreatedByFilter/index.ts @@ -0,0 +1 @@ +export * from "./CreatedByFilter"; diff --git a/frontend/src/metabase/search/components/filters/LastEditedAtFilter.tsx b/frontend/src/metabase/search/components/filters/LastEditedAtFilter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..95c9ebe8b02bc9523c7ab1f6ff549b5f9b75050d --- /dev/null +++ b/frontend/src/metabase/search/components/filters/LastEditedAtFilter.tsx @@ -0,0 +1,25 @@ +/* eslint-disable react/prop-types */ +import { t } from "ttag"; +import { SearchFilterDateDisplay } from "metabase/search/components/SearchFilterDateDisplay"; +import { SearchFilterDatePicker } from "metabase/search/components/SearchFilterDatePicker"; +import type { SearchFilterDropdown } from "metabase/search/types"; +import { Box } from "metabase/ui"; + +export const LastEditedAtFilter: SearchFilterDropdown<"last_edited_at"> = { + iconName: "calendar", + label: () => t`Last edit date`, + type: "dropdown", + DisplayComponent: ({ value: dateString }) => ( + <SearchFilterDateDisplay + label={LastEditedAtFilter.label()} + value={dateString} + /> + ), + ContentComponent: ({ value, onChange, width }) => ( + <Box miw={width}> + <SearchFilterDatePicker value={value} onChange={onChange} /> + </Box> + ), + fromUrl: value => value, + toUrl: value => value, +}; diff --git a/frontend/src/metabase/search/components/filters/LastEditedByFilter.tsx b/frontend/src/metabase/search/components/filters/LastEditedByFilter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b386a2173a707198eb6c645b1a35fce7b9ad79b4 --- /dev/null +++ b/frontend/src/metabase/search/components/filters/LastEditedByFilter.tsx @@ -0,0 +1,26 @@ +/* eslint-disable react/prop-types */ +import { t } from "ttag"; +import { SearchUserPickerContainer } from "metabase/search/components/SearchUserPicker/SearchUserPicker.styled"; +import type { SearchFilterDropdown } from "metabase/search/types"; +import { UserNameDisplay } from "metabase/search/components/UserNameDisplay/UserNameDisplay"; +import { SearchUserPicker } from "metabase/search/components/SearchUserPicker/SearchUserPicker"; +import { stringifyUserIdArray, parseUserIdArray } from "metabase/search/utils"; + +export const LastEditedByFilter: SearchFilterDropdown<"last_edited_by"> = { + iconName: "person", + label: () => t`Last editor`, + type: "dropdown", + DisplayComponent: ({ value: userIdList }) => ( + <UserNameDisplay + userIdList={userIdList} + label={LastEditedByFilter.label()} + /> + ), + ContentComponent: ({ value, onChange, width }) => ( + <SearchUserPickerContainer w={width}> + <SearchUserPicker value={value} onChange={onChange} /> + </SearchUserPickerContainer> + ), + fromUrl: parseUserIdArray, + toUrl: stringifyUserIdArray, +}; diff --git a/frontend/src/metabase/search/components/filters/NativeQueryFilter/NativeQueryFilter.tsx b/frontend/src/metabase/search/components/filters/NativeQueryFilter/NativeQueryFilter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2e71928ce3ab07748fec7eb60f9130867345cf59 --- /dev/null +++ b/frontend/src/metabase/search/components/filters/NativeQueryFilter/NativeQueryFilter.tsx @@ -0,0 +1,9 @@ +import { NativeQueryLabel } from "metabase/search/components/filters/NativeQueryFilter/NativeQueryLabel"; +import type { SearchFilterToggle } from "metabase/search/types"; + +export const NativeQueryFilter: SearchFilterToggle = { + label: NativeQueryLabel, + type: "toggle", + fromUrl: value => value === "true", + toUrl: (value: boolean) => (value ? "true" : null), +}; diff --git a/frontend/src/metabase/search/components/filters/NativeQueryFilter/NativeQueryLabel.tsx b/frontend/src/metabase/search/components/filters/NativeQueryFilter/NativeQueryLabel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b926a946d96ce6779cf9a410d43b4b5e987f3085 --- /dev/null +++ b/frontend/src/metabase/search/components/filters/NativeQueryFilter/NativeQueryLabel.tsx @@ -0,0 +1,13 @@ +import { t } from "ttag"; +import { useDatabaseListQuery } from "metabase/common/hooks"; +import { getHasNativeWrite } from "metabase/selectors/data"; + +export const NativeQueryLabel = () => { + const { data: databases = [] } = useDatabaseListQuery(); + + const hasNativeWrite = getHasNativeWrite(databases); + + const filterLabel = hasNativeWrite ? t`native` : `SQL`; + + return `Search the contents of ${filterLabel} queries`; +}; diff --git a/frontend/src/metabase/search/components/filters/NativeQueryFilter/index.ts b/frontend/src/metabase/search/components/filters/NativeQueryFilter/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0be06782943990849f8154d914d91f695cba463c --- /dev/null +++ b/frontend/src/metabase/search/components/filters/NativeQueryFilter/index.ts @@ -0,0 +1 @@ +export { NativeQueryFilter } from "./NativeQueryFilter"; diff --git a/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilter.tsx b/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilter.tsx index 5a2bbc0ef1664195c777f21be980993a7df01dcb..a5aeb1d91b8bcb8f688a45791c319d1d9ea77e38 100644 --- a/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilter.tsx +++ b/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilter.tsx @@ -1,11 +1,28 @@ import { t } from "ttag"; -import type { SearchSidebarFilterComponent } from "metabase/search/types"; -import { TypeFilterContent } from "metabase/search/components/filters/TypeFilter/TypeFilterContent"; -import { TypeFilterDisplay } from "metabase/search/components/filters/TypeFilter/TypeFilterDisplay"; +import type { + SearchFilterComponent, + TypeFilterProps, +} from "metabase/search/types"; +import { + filterEnabledSearchTypes, + isEnabledSearchModelType, +} from "metabase/search/utils/enabled-search-type"; +import { TypeFilterContent } from "./TypeFilterContent"; +import { TypeFilterDisplay } from "./TypeFilterDisplay"; -export const TypeFilter: SearchSidebarFilterComponent<"type"> = { +export const TypeFilter: SearchFilterComponent<"type"> = { iconName: "dashboard", - title: t`Content type`, + label: () => t`Content type`, + type: "dropdown", DisplayComponent: TypeFilterDisplay, ContentComponent: TypeFilterContent, + fromUrl: value => { + if (Array.isArray(value)) { + return filterEnabledSearchTypes(value); + } + return isEnabledSearchModelType(value) ? [value] : []; + }, + toUrl: (value: TypeFilterProps | null) => { + return value && value.length > 0 ? value : null; + }, }; diff --git a/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilter.unit.spec.tsx b/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilter.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..773671b7205c03790db9ab2226bc04f92012b009 --- /dev/null +++ b/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilter.unit.spec.tsx @@ -0,0 +1,73 @@ +import { TypeFilter } from "metabase/search/components/filters/TypeFilter"; +import type { EnabledSearchModelType } from "metabase-types/api"; + +const fromUrl = TypeFilter.fromUrl; +const toUrl = TypeFilter.toUrl; + +describe("fromUrl", () => { + it("should return an array with a single valid type when the input exactly matches a type", () => { + const types = "collection"; + const result = fromUrl(types); + expect(result).toEqual(["collection"]); + }); + + it("should return an empty array when the input is null or undefined", () => { + const nullType = null; + const nullResult = fromUrl(nullType); + expect(nullResult).toEqual([]); + + const undefinedType = undefined; + const undefinedResult = fromUrl(undefinedType); + expect(undefinedResult).toEqual([]); + }); + + it("should return an empty array when the input is an invalid type", () => { + const types = "invalidType"; + const result = fromUrl(types); + expect(result).toEqual([]); + }); + + it("should return an array of valid types when the input is an array of valid types", () => { + const types: string[] = ["collection", "dashboard"]; + const result = fromUrl(types); + expect(result).toEqual(["collection", "dashboard"]); + }); + + it("should return an array of valid types when the input is an array with one valid type and one invalid type", () => { + const types = ["collection", "invalidType"]; + const result = fromUrl(types); + expect(result).toEqual(["collection"]); + }); + + it("should return an empty array when the input is an empty array", () => { + const types: string[] = []; + const result = fromUrl(types); + expect(result).toEqual([]); + }); +}); + +describe("toUrl", () => { + it("should convert an array of valid types to an array of valid types", () => { + const types: EnabledSearchModelType[] = ["collection", "dashboard"]; + const result = toUrl(types); + expect(result).toEqual(["collection", "dashboard"]); + }); + + it("should return null when the input array is empty", () => { + const types: EnabledSearchModelType[] = []; + const result = toUrl(types); + expect(result).toBeNull(); + }); + + it("should return null when the input is undefined", () => { + const types = undefined; + const result = toUrl(types); + expect(result).toBeNull(); + }); + + it("should return an array with a single valid type when the input array has one valid type", () => { + const types: EnabledSearchModelType[] = ["collection"]; + const result = toUrl(types); + expect(result).toEqual(["collection"]); + }); +}); diff --git a/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterContent.tsx b/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterContent.tsx index bd19799f169a5dc228d6112fed74e3b4416cc0b5..b9cb853d36df10d494451ac0191eb76e86082285 100644 --- a/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterContent.tsx +++ b/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterContent.tsx @@ -1,42 +1,51 @@ /* eslint-disable react/prop-types */ -import type { SearchSidebarFilterComponent } from "metabase/search/types"; +import { useState } from "react"; +import type { SearchFilterDropdown } from "metabase/search/types"; import { useSearchListQuery } from "metabase/common/hooks"; -import { enabledSearchTypes } from "metabase/search/constants"; import { Checkbox, Stack } from "metabase/ui"; -import LoadingSpinner from "metabase/components/LoadingSpinner"; -import type { EnabledSearchModelType } from "metabase-types/api"; import { getTranslatedEntityName } from "metabase/common/utils/model-names"; +import type { EnabledSearchModelType } from "metabase-types/api"; +import { SearchFilterPopoverWrapper } from "metabase/search/components/SearchFilterPopoverWrapper"; +import { filterEnabledSearchTypes } from "metabase/search/utils"; const EMPTY_SEARCH_QUERY = { models: "dataset", limit: 1 } as const; -export const TypeFilterContent: SearchSidebarFilterComponent<"type">["ContentComponent"] = - ({ value, onChange }) => { +export const TypeFilterContent: SearchFilterDropdown<"type">["ContentComponent"] = + ({ value, onChange, width }) => { const { metadata, isLoading } = useSearchListQuery({ query: EMPTY_SEARCH_QUERY, }); + const [selectedTypes, setSelectedTypes] = useState< + EnabledSearchModelType[] + >(value ?? []); + const availableModels = (metadata && metadata.available_models) ?? []; - const typeFilters: EnabledSearchModelType[] = enabledSearchTypes.filter( - model => availableModels.includes(model), - ); + const typeFilters = filterEnabledSearchTypes(availableModels); - return isLoading ? ( - <LoadingSpinner /> - ) : ( - <Checkbox.Group - data-testid="type-filter-checkbox-group" - w="100%" - value={value} - onChange={onChange} + return ( + <SearchFilterPopoverWrapper + isLoading={isLoading} + onApply={() => onChange(selectedTypes)} + w={width} > - <Stack spacing="md" justify="center" align="flex-start"> - {typeFilters.map(model => ( - <Checkbox - key={model} - value={model} - label={getTranslatedEntityName(model)} - /> - ))} - </Stack> - </Checkbox.Group> + <Checkbox.Group + data-testid="type-filter-checkbox-group" + w="100%" + value={selectedTypes} + onChange={value => + setSelectedTypes(value as EnabledSearchModelType[]) + } + > + <Stack spacing="md" p="md" justify="center" align="flex-start"> + {typeFilters.map(model => ( + <Checkbox + key={model} + value={model} + label={getTranslatedEntityName(model)} + /> + ))} + </Stack> + </Checkbox.Group> + </SearchFilterPopoverWrapper> ); }; diff --git a/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterContent.unit.spec.tsx b/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterContent.unit.spec.tsx index ddc225845440bf568b7dc9463cde3926c57b28bd..cecc114d14501db333769e83d638ce0ac9781b98 100644 --- a/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterContent.unit.spec.tsx +++ b/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterContent.unit.spec.tsx @@ -146,10 +146,10 @@ describe("TypeFilterContent", () => { for (let i = 0; i < options.length; i++) { userEvent.click(options[i]); - expect(onChangeFilters).toHaveReturnedTimes(i + 1); } - expect(onChangeFilters).toHaveReturnedTimes(TEST_TYPES.length); + userEvent.click(screen.getByText("Apply")); + expect(onChangeFilters).toHaveReturnedTimes(1); expect(onChangeFilters).toHaveBeenLastCalledWith(TEST_TYPES); }); @@ -161,8 +161,8 @@ describe("TypeFilterContent", () => { for (const checkedOption of checkedOptions) { userEvent.click(checkedOption); } - - expect(onChangeFilters).toHaveReturnedTimes(TEST_TYPE_SUBSET.length); + userEvent.click(screen.getByText("Apply")); + expect(onChangeFilters).toHaveReturnedTimes(1); expect(onChangeFilters).toHaveBeenLastCalledWith([]); }); }); diff --git a/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterDisplay.tsx b/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterDisplay.tsx index 5de7b75cb6426afee6394d1d34a7c261788b89db..4915f2cb2b2f464eaef3579e797f94fed69fca0e 100644 --- a/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterDisplay.tsx +++ b/frontend/src/metabase/search/components/filters/TypeFilter/TypeFilterDisplay.tsx @@ -1,22 +1,22 @@ /* eslint-disable react/prop-types */ import { t } from "ttag"; -import type { SearchSidebarFilterComponent } from "metabase/search/types"; import { Text } from "metabase/ui"; -import { TypeFilter } from "metabase/search/components/filters/TypeFilter/TypeFilter"; +import { TypeFilter } from "metabase/search/components/filters/TypeFilter"; +import type { SearchFilterDropdown } from "metabase/search/types"; import { getTranslatedEntityName } from "metabase/common/utils/model-names"; -export const TypeFilterDisplay: SearchSidebarFilterComponent<"type">["DisplayComponent"] = +export const TypeFilterDisplay: SearchFilterDropdown<"type">["DisplayComponent"] = ({ value }) => { let titleText = ""; if (!value || !value.length) { - titleText = TypeFilter.title; + titleText = TypeFilter.label(); } else if (value.length === 1) { titleText = getTranslatedEntityName(value[0]) ?? t`1 type selected`; } else { titleText = value.length + t` types selected`; } return ( - <Text c="inherit" weight={700}> + <Text c="inherit" weight={700} truncate> {titleText} </Text> ); diff --git a/frontend/src/metabase/search/components/filters/TypeFilter/index.ts b/frontend/src/metabase/search/components/filters/TypeFilter/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b12c3c3bc4342ba538334697951588b3393ab181 --- /dev/null +++ b/frontend/src/metabase/search/components/filters/TypeFilter/index.ts @@ -0,0 +1 @@ +export * from "./TypeFilter"; diff --git a/frontend/src/metabase/search/constants.ts b/frontend/src/metabase/search/constants.ts index 2c3e531042faabf46a7240ffb14c49115cf22042..8878231806a6447830c7b9fbbd09e44e267dad27 100644 --- a/frontend/src/metabase/search/constants.ts +++ b/frontend/src/metabase/search/constants.ts @@ -2,6 +2,12 @@ import type { EnabledSearchModelType } from "metabase-types/api"; export const SearchFilterKeys = { Type: "type", + Verified: "verified", + CreatedBy: "created_by", + CreatedAt: "created_at", + LastEditedBy: "last_edited_by", + LastEditedAt: "last_edited_at", + NativeQuery: "search_native_query", } as const; export const enabledSearchTypes: EnabledSearchModelType[] = [ diff --git a/frontend/src/metabase/search/containers/SearchApp.jsx b/frontend/src/metabase/search/containers/SearchApp.jsx index 1f5564b7abc7544090856ccea29c65df1a683961..d669af649bb929a042d7a70b406b6246acdd11d9 100644 --- a/frontend/src/metabase/search/containers/SearchApp.jsx +++ b/frontend/src/metabase/search/containers/SearchApp.jsx @@ -19,7 +19,7 @@ import { } from "metabase/search/utils"; import { PAGE_SIZE } from "metabase/search/containers/constants"; import { SearchFilterKeys } from "metabase/search/constants"; -import { SearchSidebar } from "metabase/search/components/SearchSidebar/SearchSidebar"; +import { SearchSidebar } from "metabase/search/components/SearchSidebar"; import { useDispatch } from "metabase/lib/redux"; import { SearchControls, @@ -78,17 +78,14 @@ function SearchApp({ location }) { <Text size="xl" weight={700}> {jt`Results for "${searchText}"`} </Text> - <Search.ListLoader query={query} wrapped> - {({ list, metadata }) => ( - <SearchBody direction="column" justify="center"> - <SearchControls> - <SearchSidebar - value={searchFilters} - onChangeFilters={onFilterChange} - /> - </SearchControls> - <SearchResultContainer> - {list.length === 0 ? ( + <SearchBody direction="column" justify="center"> + <SearchControls pb="lg"> + <SearchSidebar value={searchFilters} onChange={onFilterChange} /> + </SearchControls> + <SearchResultContainer> + <Search.ListLoader query={query} wrapped> + {({ list, metadata }) => + list.length === 0 ? ( <Paper shadow="lg" p="2rem"> <EmptyState title={t`Didn't find anything`} @@ -102,7 +99,10 @@ function SearchApp({ location }) { </Paper> ) : ( <Box> - <SearchResultSection items={list} /> + <SearchResultSection + totalResults={metadata.total} + results={list} + /> <Group justify="flex-end" align="center" my="1rem"> <PaginationControls showTotal @@ -115,11 +115,11 @@ function SearchApp({ location }) { /> </Group> </Box> - )} - </SearchResultContainer> - </SearchBody> - )} - </Search.ListLoader> + ) + } + </Search.ListLoader> + </SearchResultContainer> + </SearchBody> </SearchMain> ); } diff --git a/frontend/src/metabase/search/containers/SearchApp.styled.tsx b/frontend/src/metabase/search/containers/SearchApp.styled.tsx index 071dd34fb7e8a6c6835e4f9f47dd7fd08b005b6b..8866421d91ff5851498af5aa90a30e2d314fa6da 100644 --- a/frontend/src/metabase/search/containers/SearchApp.styled.tsx +++ b/frontend/src/metabase/search/containers/SearchApp.styled.tsx @@ -3,10 +3,10 @@ import { breakpointMinMedium, breakpointMinSmall, } from "metabase/styled-components/theme"; -import { Flex } from "metabase/ui"; +import { Flex, Stack } from "metabase/ui"; const SEARCH_BODY_WIDTH = "90rem"; -const SEARCH_SIDEBAR_WIDTH = "15rem"; +const SEARCH_SIDEBAR_WIDTH = "18rem"; export const SearchMain = styled(Flex)` width: min(calc(${SEARCH_BODY_WIDTH} + ${SEARCH_SIDEBAR_WIDTH}), 100%); @@ -23,7 +23,9 @@ export const SearchBody = styled(Flex)` } `; -export const SearchControls = styled.div` +export const SearchControls = styled(Stack)` + overflow: hidden; + ${breakpointMinMedium} { flex: 0 0 ${SEARCH_SIDEBAR_WIDTH}; } diff --git a/frontend/src/metabase/search/containers/SearchApp.unit.spec.tsx b/frontend/src/metabase/search/containers/SearchApp.unit.spec.tsx index 02439416ef4c4706b8fa352d77389fbcd47ed6ec..e2c4cadd0922953e692a1aabc59790236b5e2491 100644 --- a/frontend/src/metabase/search/containers/SearchApp.unit.spec.tsx +++ b/frontend/src/metabase/search/containers/SearchApp.unit.spec.tsx @@ -8,14 +8,18 @@ import { import SearchApp from "metabase/search/containers/SearchApp"; import { Route } from "metabase/hoc/Title"; import { + setupCollectionByIdEndpoint, setupDatabasesEndpoints, setupSearchEndpoints, setupTableEndpoints, + setupUsersEndpoints, } from "__support__/server-mocks"; import { + createMockCollection, createMockDatabase, createMockSearchResult, createMockTable, + createMockUserListResult, } from "metabase-types/api/mocks"; import type { EnabledSearchModelType, SearchResult } from "metabase-types/api"; @@ -53,6 +57,8 @@ const TEST_SEARCH_RESULTS: SearchResult[] = TEST_ITEMS.map((metadata, index) => const TEST_DATABASE = createMockDatabase(); const TEST_TABLE = createMockTable(); +const TEST_USER_LIST = [createMockUserListResult()]; +const TEST_COLLECTION = createMockCollection(); const setup = async ({ searchText, @@ -66,6 +72,10 @@ const setup = async ({ setupDatabasesEndpoints([TEST_DATABASE]); setupSearchEndpoints(searchItems); setupTableEndpoints(TEST_TABLE); + setupUsersEndpoints(TEST_USER_LIST); + setupCollectionByIdEndpoint({ + collections: [TEST_COLLECTION], + }); // for testing the hydration of search text and filters on page load const params = { @@ -158,7 +168,12 @@ describe("SearchApp", () => { searchText: "Test", }); - userEvent.click(screen.getByTestId("sidebar-filter-dropdown-button")); + userEvent.click( + within(screen.getByTestId("type-search-filter")).getByTestId( + "sidebar-filter-dropdown-button", + ), + ); + await waitForLoaderToBeRemoved(); const popover = within(screen.getByTestId("popover")); @@ -169,7 +184,7 @@ describe("SearchApp", () => { ] as EnabledSearchModelType, }), ); - userEvent.click(popover.getByRole("button", { name: "Apply filters" })); + userEvent.click(popover.getByRole("button", { name: "Apply" })); const url = history.getCurrentLocation(); expect(url.query.type).toEqual(model); @@ -194,17 +209,12 @@ describe("SearchApp", () => { name, ); - const fieldSetContent = within(screen.getByTestId("field-set-content")); - - expect( - fieldSetContent.getByText( - TYPE_FILTER_LABELS[model as EnabledSearchModelType], - ), - ).toBeInTheDocument(); + const typeFilter = within(screen.getByTestId("type-search-filter")); + const fieldSetContent = typeFilter.getByTestId("field-set-content"); - expect( - fieldSetContent.getByLabelText("close icon"), - ).toBeInTheDocument(); + expect(fieldSetContent).toHaveTextContent( + TYPE_FILTER_LABELS[model as EnabledSearchModelType], + ); }, ); }); diff --git a/frontend/src/metabase/search/containers/SearchResultSection.tsx b/frontend/src/metabase/search/containers/SearchResultSection.tsx index db5fda7bc4ed365c568c72829f0d5ecc53b1cf09..ea8cc2d8da3def4a4307d37f0b29b69940c9a10c 100644 --- a/frontend/src/metabase/search/containers/SearchResultSection.tsx +++ b/frontend/src/metabase/search/containers/SearchResultSection.tsx @@ -1,11 +1,33 @@ +import { msgid, ngettext } from "ttag"; import type { WrappedResult } from "metabase/search/types"; -import Card from "metabase/components/Card"; import { SearchResult } from "metabase/search/components/SearchResult"; +import { Paper, Stack, Text } from "metabase/ui"; -export const SearchResultSection = ({ items }: { items: WrappedResult[] }) => ( - <Card className="pt2"> - {items.map(item => { - return <SearchResult key={`${item.id}__${item.model}`} result={item} />; - })} - </Card> -); +export const SearchResultSection = ({ + results, + totalResults, +}: { + results: WrappedResult[]; + totalResults: number; +}) => { + const resultsLabel = ngettext( + msgid`${totalResults} result`, + `${totalResults} results`, + totalResults, + ); + + return ( + <Paper px="sm" py="md"> + <Stack spacing="sm"> + <Text tt="uppercase" fw={700} ml="sm" mb="sm"> + {resultsLabel} + </Text> + {results.map(item => { + return ( + <SearchResult key={`${item.id}__${item.model}`} result={item} /> + ); + })} + </Stack> + </Paper> + ); +}; diff --git a/frontend/src/metabase/search/types.ts b/frontend/src/metabase/search/types.ts index 1dcfaa1c775399b226bfa25d7e1b3aa427fc6575..358bd0cc2a4314002f3e28cb1cf7f20b15f04463 100644 --- a/frontend/src/metabase/search/types.ts +++ b/frontend/src/metabase/search/types.ts @@ -2,9 +2,9 @@ import type { Location } from "history"; import type { ComponentType } from "react"; import type { - Collection, EnabledSearchModelType, SearchResult, + UserId, } from "metabase-types/api"; import type { IconName } from "metabase/core/components/Icon"; import type { SearchFilterKeys } from "metabase/search/constants"; @@ -17,30 +17,73 @@ export interface WrappedResult extends SearchResult { width?: number; height?: number; }; - getCollection: () => Partial<Collection>; + getCollection: () => SearchResult["collection"]; } export type TypeFilterProps = EnabledSearchModelType[]; +export type CreatedByFilterProps = UserId[]; +export type CreatedAtFilterProps = string | null; +export type LastEditedByProps = UserId[]; +export type LastEditedAtFilterProps = string | null; +export type VerifiedFilterProps = true | null; +export type NativeQueryFilterProps = true | null; export type SearchFilterPropTypes = { [SearchFilterKeys.Type]: TypeFilterProps; + [SearchFilterKeys.Verified]: VerifiedFilterProps; + [SearchFilterKeys.CreatedBy]: CreatedByFilterProps; + [SearchFilterKeys.CreatedAt]: CreatedAtFilterProps; + [SearchFilterKeys.LastEditedBy]: LastEditedByProps; + [SearchFilterKeys.LastEditedAt]: LastEditedAtFilterProps; + [SearchFilterKeys.NativeQuery]: NativeQueryFilterProps; }; export type FilterTypeKeys = keyof SearchFilterPropTypes; +// All URL query parameters are returned as strings so we need to account +// for that when parsing them to our filter components +export type SearchQueryParamValue = string | string[] | null | undefined; +export type URLSearchFilterQueryParams = Partial< + Record<FilterTypeKeys, SearchQueryParamValue> +>; +export type SearchAwareLocation = Location< + { q?: string } & URLSearchFilterQueryParams +>; + export type SearchFilters = Partial<SearchFilterPropTypes>; export type SearchFilterComponentProps<T extends FilterTypeKeys = any> = { - value?: SearchFilterPropTypes[T]; + value: SearchFilterPropTypes[T]; onChange: (value: SearchFilterPropTypes[T]) => void; "data-testid"?: string; + width?: string; } & Record<string, unknown>; -export type SearchAwareLocation = Location<{ q?: string } & SearchFilters>; +type SidebarFilterType = "dropdown" | "toggle"; + +interface SearchFilter<T extends FilterTypeKeys = any> { + type: SidebarFilterType; + label: () => string; + iconName?: IconName; + + // parses the string value of a URL query parameter to the filter value + fromUrl: (value: SearchQueryParamValue) => SearchFilterPropTypes[T]; + + // converts filter value to URL query parameter string value + toUrl: (value: SearchFilterPropTypes[T] | null) => SearchQueryParamValue; +} -export type SearchSidebarFilterComponent<T extends FilterTypeKeys = any> = { - title: string; - iconName: IconName; +export interface SearchFilterDropdown<T extends FilterTypeKeys = any> + extends SearchFilter { + type: "dropdown"; DisplayComponent: ComponentType<Pick<SearchFilterComponentProps<T>, "value">>; ContentComponent: ComponentType<SearchFilterComponentProps<T>>; -}; +} + +export interface SearchFilterToggle extends SearchFilter { + type: "toggle"; +} + +export type SearchFilterComponent<T extends FilterTypeKeys = any> = + | SearchFilterDropdown<T> + | SearchFilterToggle; diff --git a/frontend/src/metabase/search/utils/enabled-search-type/enabled-search-type.ts b/frontend/src/metabase/search/utils/enabled-search-type/enabled-search-type.ts new file mode 100644 index 0000000000000000000000000000000000000000..4fe1fd849decb266bfffbba4b744011e400c7fc8 --- /dev/null +++ b/frontend/src/metabase/search/utils/enabled-search-type/enabled-search-type.ts @@ -0,0 +1,16 @@ +import type { EnabledSearchModelType } from "metabase-types/api"; +import { enabledSearchTypes } from "metabase/search/constants"; + +export function isEnabledSearchModelType( + value: unknown, +): value is EnabledSearchModelType { + return ( + typeof value === "string" && enabledSearchTypes.some(type => type === value) + ); +} + +export const filterEnabledSearchTypes = ( + values: unknown[], +): EnabledSearchModelType[] => { + return values.filter(isEnabledSearchModelType); +}; diff --git a/frontend/src/metabase/search/utils/enabled-search-type/enabled-search-type.unit.spec.ts b/frontend/src/metabase/search/utils/enabled-search-type/enabled-search-type.unit.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a962a274725a6a72599986cc1fa839ffcb8451e --- /dev/null +++ b/frontend/src/metabase/search/utils/enabled-search-type/enabled-search-type.unit.spec.ts @@ -0,0 +1,50 @@ +import { + filterEnabledSearchTypes, + isEnabledSearchModelType, +} from "metabase/search/utils"; + +const TEST_VALID_VALUES = [ + "collection", + "dashboard", + "card", + "database", + "table", + "dataset", + "action", +]; + +const TEST_INVALID_VALUES = [null, undefined, 123, "invalid", [], {}]; + +describe("isEnabledSearchModelType", () => { + it("should return true if value is in EnabledSearchModelType", () => { + TEST_VALID_VALUES.forEach(value => { + expect(isEnabledSearchModelType(value)).toBe(true); + }); + }); + + it("should return false if value is not in EnabledSearchModelType", () => { + TEST_INVALID_VALUES.forEach(value => { + expect(isEnabledSearchModelType(value)).toBe(false); + }); + }); +}); + +describe("filterEnabledSearchTypes", () => { + it("should filter and return valid EnabledSearchModelTypes", () => { + const inputValues = [...TEST_VALID_VALUES, ...TEST_INVALID_VALUES]; + + const filteredValues = filterEnabledSearchTypes(inputValues); + expect(filteredValues).toEqual(TEST_VALID_VALUES); + }); + + it("should return an empty array if no EnabledSearchModelType values found", () => { + const filteredValues = filterEnabledSearchTypes(TEST_INVALID_VALUES); + expect(filteredValues).toEqual([]); + }); + + it("should return an empty array when an empty input array is provided", () => { + const inputValues: unknown[] = []; + const filteredValues = filterEnabledSearchTypes(inputValues); + expect(filteredValues).toEqual([]); + }); +}); diff --git a/frontend/src/metabase/search/utils/enabled-search-type/index.ts b/frontend/src/metabase/search/utils/enabled-search-type/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b23246ec6d5adfb1756c0f19953588dfee73f298 --- /dev/null +++ b/frontend/src/metabase/search/utils/enabled-search-type/index.ts @@ -0,0 +1,4 @@ +export { + isEnabledSearchModelType, + filterEnabledSearchTypes, +} from "./enabled-search-type"; diff --git a/frontend/src/metabase/search/utils/index.ts b/frontend/src/metabase/search/utils/index.ts index 4871119ecd38ae8baed4e317987a52ad62cd7c57..032853aba4e747c46a27c5990ffc62a2fe1f6a62 100644 --- a/frontend/src/metabase/search/utils/index.ts +++ b/frontend/src/metabase/search/utils/index.ts @@ -1 +1,3 @@ export * from "./search-location"; +export * from "./user-search-params"; +export * from "./enabled-search-type"; diff --git a/frontend/src/metabase/search/utils/search-location/search-location.ts b/frontend/src/metabase/search/utils/search-location/search-location.ts index 311ee8586119e567fba65ba853ba707f99446161..c879714ae288a4f47a6542ac07c7d21b21b09a85 100644 --- a/frontend/src/metabase/search/utils/search-location/search-location.ts +++ b/frontend/src/metabase/search/utils/search-location/search-location.ts @@ -1,11 +1,13 @@ import _ from "underscore"; -import type { SearchAwareLocation, SearchFilters } from "metabase/search/types"; +import type { + SearchAwareLocation, + URLSearchFilterQueryParams, +} from "metabase/search/types"; import { SearchFilterKeys } from "metabase/search/constants"; -export function isSearchPageLocation(location: SearchAwareLocation): boolean { - const components = location.pathname.split("/"); - return components[components.length - 1] === "search"; +export function isSearchPageLocation(location?: SearchAwareLocation): boolean { + return location ? /^\/?search$/.test(location.pathname) : false; } export function getSearchTextFromLocation( @@ -19,7 +21,7 @@ export function getSearchTextFromLocation( export function getFiltersFromLocation( location: SearchAwareLocation, -): SearchFilters { +): URLSearchFilterQueryParams { if (isSearchPageLocation(location)) { return _.pick(location.query, Object.values(SearchFilterKeys)); } diff --git a/frontend/src/metabase/search/utils/search-location/search-location.unit.spec.ts b/frontend/src/metabase/search/utils/search-location/search-location.unit.spec.ts index f7a40542c7bb209307d6a4590b1a3f57140f23b8..d3ca676499b6604518a3952edc1afb492308dbb2 100644 --- a/frontend/src/metabase/search/utils/search-location/search-location.unit.spec.ts +++ b/frontend/src/metabase/search/utils/search-location/search-location.unit.spec.ts @@ -8,20 +8,28 @@ import type { SearchAwareLocation } from "metabase/search/types"; import { SearchFilterKeys } from "metabase/search/constants"; describe("isSearchPageLocation", () => { - it('should return true when the last component of pathname is "search"', () => { - const location = { - pathname: "/search", - query: {}, - }; - expect(isSearchPageLocation(location as SearchAwareLocation)).toBe(true); + it("should return true for a search page location", () => { + const location = { pathname: "/search" }; + const result = isSearchPageLocation(location as SearchAwareLocation); + expect(result).toBe(true); }); - it('should return false when the last component of pathname is not "search"', () => { - const location = { - pathname: "/collection/root", - query: {}, - }; - expect(isSearchPageLocation(location as SearchAwareLocation)).toBe(false); + it("should return true for a search page location with query params", () => { + const location = { pathname: "/search", search: "?q=test" }; + const result = isSearchPageLocation(location as SearchAwareLocation); + expect(result).toBe(true); + }); + + it('should return false for non-search location that might have "search" in the path', () => { + const location = { pathname: "/collection/1-search" }; + const result = isSearchPageLocation(location as SearchAwareLocation); + expect(result).toBe(false); + }); + + it("should return false for non-search location", () => { + const location = { pathname: "/some-page" }; + const result = isSearchPageLocation(location as SearchAwareLocation); + expect(result).toBe(false); }); }); diff --git a/frontend/src/metabase/search/utils/user-search-params/index.ts b/frontend/src/metabase/search/utils/user-search-params/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a619b76eae86d9812ad8a1f15c7dbabf42b8e41 --- /dev/null +++ b/frontend/src/metabase/search/utils/user-search-params/index.ts @@ -0,0 +1 @@ +export { parseUserIdArray, stringifyUserIdArray } from "./user-search-params"; diff --git a/frontend/src/metabase/search/utils/user-search-params/user-search-params.ts b/frontend/src/metabase/search/utils/user-search-params/user-search-params.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb19a9d37ce3d829509ce9293f9cc5baed60606b --- /dev/null +++ b/frontend/src/metabase/search/utils/user-search-params/user-search-params.ts @@ -0,0 +1,41 @@ +import type { UserId } from "metabase-types/api"; +import type { SearchQueryParamValue } from "metabase/search/types"; +import { isNotNull } from "metabase/core/utils/types"; + +export const parseUserIdArray = (value: SearchQueryParamValue): UserId[] => { + if (!value) { + return []; + } + + if (typeof value === "string") { + const parsedValue = parseUserId(value); + return parsedValue ? [parsedValue] : []; + } + + if (Array.isArray(value)) { + const parsedIds: (number | null)[] = value.map(idString => + parseUserId(idString), + ); + return parsedIds.filter(isNotNull); + } + + return []; +}; + +export const parseUserId = (value: SearchQueryParamValue): UserId | null => { + if (!value || Array.isArray(value)) { + return null; + } + const numValue = Number(value); + + if (!numValue || isNaN(numValue) || numValue <= 0) { + return null; + } + + return numValue; +}; + +export const stringifyUserIdArray = ( + value?: UserId[] | null, +): SearchQueryParamValue => + value ? value.map(idString => String(idString)) : []; diff --git a/frontend/src/metabase/search/utils/user-search-params/user-search-params.unit.spec.ts b/frontend/src/metabase/search/utils/user-search-params/user-search-params.unit.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c0a1a564d37261e827c220fec6866b1f38e8f26 --- /dev/null +++ b/frontend/src/metabase/search/utils/user-search-params/user-search-params.unit.spec.ts @@ -0,0 +1,104 @@ +import { + parseUserId, + stringifyUserIdArray, + parseUserIdArray, +} from "./user-search-params"; + +describe("parseUserIdArray", () => { + it("should return a UserId array when value is a string", () => { + const userId = "123"; + const result = parseUserIdArray(userId); + expect(result).toStrictEqual([123]); + }); + + it("should return a UserId array when value is an array of strings", () => { + const userId = ["123", "456"]; + const result = parseUserIdArray(userId); + expect(result).toStrictEqual([123, 456]); + }); + + it("should filter invalid values from an array of strings", () => { + const userId = ["123", "abc", "456", "def", "789", ""]; + const result = parseUserIdArray(userId); + expect(result).toStrictEqual([123, 456, 789]); + }); + + it("should return an empty array when value is null or undefined", () => { + const nullResult = parseUserIdArray(null); + expect(nullResult).toStrictEqual([]); + + const undefinedResult = parseUserIdArray(undefined); + expect(undefinedResult).toStrictEqual([]); + }); + + it("should return an empty array when value is an empty array", () => { + const userId: string[] = []; + const result = parseUserIdArray(userId); + expect(result).toStrictEqual([]); + }); +}); + +describe("parseUserId", () => { + it("should convert a valid string to a number", () => { + const userId = "123"; + const result = parseUserId(userId); + expect(result).toBe(123); + }); + + it("should return null when userId is null or undefined", () => { + const nullResult = parseUserId(null); + expect(nullResult).toBeNull(); + + const undefinedResult = parseUserId(undefined); + expect(undefinedResult).toBeNull(); + }); + + it("should return null when userId is 0", () => { + const userId = "0"; + const result = parseUserId(userId); + expect(result).toBeNull(); + }); + + it("should return null when userId is a negative number", () => { + const userId = "-1"; + const result = parseUserId(userId); + expect(result).toBeNull(); + }); + + it("should return null when userId is a string that cannot be converted to a number", () => { + const userId = "abc"; + const result = parseUserId(userId); + expect(result).toBeNull(); + }); + + it("should return null when userId is an empty string", () => { + const userId = ""; + const result = parseUserId(userId); + expect(result).toBeNull(); + }); + + it("should return null when userId is an array", () => { + const userId = ["123"]; + const result = parseUserId(userId); + expect(result).toBeNull(); + }); +}); + +describe("stringifyUserIdArray", () => { + it("should convert an UserId number array to a string", () => { + const userId = [1, 2, 3, 4]; + const result = stringifyUserIdArray(userId); + expect(result).toStrictEqual(["1", "2", "3", "4"]); + }); + + it("should convert an UserId number to a string", () => { + const userId = [1]; + const result = stringifyUserIdArray(userId); + expect(result).toStrictEqual(["1"]); + }); + + it("should return null if the input is null", () => { + const result = stringifyUserIdArray([]); + expect(result).toStrictEqual([]); + }); +}); diff --git a/frontend/src/metabase/visualizations/visualizations/LinkViz/LinkViz.unit.spec.tsx b/frontend/src/metabase/visualizations/visualizations/LinkViz/LinkViz.unit.spec.tsx index 5426707674d1dce8ec26ce3ef49d7cadac603f48..70a947a0e564c43f885a9dcfc2d6930708043500 100644 --- a/frontend/src/metabase/visualizations/visualizations/LinkViz/LinkViz.unit.spec.tsx +++ b/frontend/src/metabase/visualizations/visualizations/LinkViz/LinkViz.unit.spec.tsx @@ -10,6 +10,8 @@ import { import { setupSearchEndpoints, setupRecentViewsEndpoints, + setupUsersEndpoints, + setupCollectionByIdEndpoint, } from "__support__/server-mocks"; import * as domUtils from "metabase/lib/dom"; import registerVisualizations from "metabase/visualizations/register"; @@ -22,6 +24,7 @@ import { createMockRecentItem, createMockTable, createMockDashboard, + createMockUser, } from "metabase-types/api/mocks"; import type { LinkVizProps } from "./LinkViz"; @@ -111,12 +114,13 @@ const searchingDashcard = createMockDashboardCardWithVirtualCard({ }, }); +const searchCardCollection = createMockCollection(); const searchCardItem = createMockCollectionItem({ id: 1, model: "card", name: "Question Uno", display: "pie", - collection: createMockCollection(), + collection: searchCardCollection, }); const setup = (options?: Partial<LinkVizProps>) => { @@ -241,6 +245,10 @@ describe("LinkViz", () => { it("clicking a search item should update the entity", async () => { setupSearchEndpoints([searchCardItem]); + setupUsersEndpoints([createMockUser()]); + setupCollectionByIdEndpoint({ + collections: [searchCardCollection], + }); const { changeSpy } = setup({ isEditing: true, diff --git a/resources/migrations/000_migrations.yaml b/resources/migrations/000_migrations.yaml index 21503e18bd3a728fd69762c7b8b72ac4fc231afa..c4265ddd2aa32b64c5f49060cedae1acc47dd8bb 100644 --- a/resources/migrations/000_migrations.yaml +++ b/resources/migrations/000_migrations.yaml @@ -15173,6 +15173,52 @@ databaseChangeLog: column: name: action_id + - changeSet: + id: v48.00-007 + author: qnkhuat + comment: Added 0.48.0 - Add revision.most_recent + changes: + - addColumn: + tableName: revision + columns: + - column: + name: most_recent + type: boolean + defaultValueBoolean: false + remarks: 'Whether a revision is the most recent one' + constraints: + nullable: false + + - changeSet: + id: v48.00-008 + author: qnkhuat + comment: Set revision.most_recent = true for latest revisions + changes: + - sql: + dbms: postgresql,h2 + sql: >- + UPDATE revision r + SET most_recent = true + WHERE (model, model_id, timestamp) IN ( + SELECT model, model_id, MAX(timestamp) + FROM revision + GROUP BY model, model_id); + # mysql and mariadb does not allow update on a table that is in the select part + # so it we join for them + - sql: + dbms: mysql,mariadb + sql: >- + UPDATE revision r + JOIN ( + SELECT model, model_id, MAX(timestamp) AS max_timestamp + FROM revision + GROUP BY model, model_id + ) AS subquery + ON r.model = subquery.model AND r.model_id = subquery.model_id AND r.timestamp = subquery.max_timestamp + SET r.most_recent = true; + rollback: # nothing to do since the most_recent will be dropped anyway + + - changeSet: id: v48.00-009 author: calherries @@ -15229,6 +15275,33 @@ databaseChangeLog: columnNames: table_id, role name: uq_table_privileges_table_id_role + + - changeSet: + id: v48.00-010 + author: qnkhuat + comment: Remove ON UPDATE for revision.timestamp on mysql, mariadb + preConditions: + # If preconditions fail (i.e., dbms is not mysql or mariadb) then mark this migration as 'ran' + - onFail: MARK_RAN + - dbms: + type: mysql,mariadb + changes: + - sql: + sql: ALTER TABLE `revision` CHANGE `timestamp` `timestamp` timestamp(6) NOT NULL DEFAULT current_timestamp(6); + rollback: # no need + + - changeSet: + id: v48.00-011 + author: qnkhuat + comment: Index revision.most_recent + changes: + - createIndex: + tableName: revision + indexName: idx_revision_most_recent + columns: + column: + name: most_recent + - changeSet: id: v48.00-013 author: qnkhuat diff --git a/src/metabase/api/collection.clj b/src/metabase/api/collection.clj index cf355e47fce9ec604656251e5d85d7d5c2801291..e406026118da197b6ec12ad9afffc34131995f7c 100644 --- a/src/metabase/api/collection.clj +++ b/src/metabase/api/collection.clj @@ -330,17 +330,10 @@ dataset? (conj :c.database_id)) :from [[:report_card :c]] - ;; todo: should there be a flag, or a realized view? - :left-join [[{:select [:r1.*] - :from [[:revision :r1]] - :left-join [[:revision :r2] [:and - [:= :r1.model_id :r2.model_id] - [:= :r1.model :r2.model] - [:< :r1.id :r2.id]]] - :where [:and - [:= :r2.id nil] - [:= :r1.model (h2x/literal "Card")]]} :r] - [:= :r.model_id :c.id] + :left-join [[:revision :r] [:and + [:= :r.model_id :c.id] + [:= :r.most_recent true] + [:= :r.model (h2x/literal "Card")]] [:core_user :u] [:= :u.id :r.user_id]] :where [:and [:= :collection_id (:id collection)] @@ -419,16 +412,10 @@ [:u.last_name :last_edit_last_name] [:r.timestamp :last_edit_timestamp]] :from [[:report_dashboard :d]] - :left-join [[{:select [:r1.*] - :from [[:revision :r1]] - :left-join [[:revision :r2] [:and - [:= :r1.model_id :r2.model_id] - [:= :r1.model :r2.model] - [:< :r1.id :r2.id]]] - :where [:and - [:= :r2.id nil] - [:= :r1.model (h2x/literal "Dashboard")]]} :r] - [:= :r.model_id :d.id] + :left-join [[:revision :r] [:and + [:= :r.model_id :d.id] + [:= :r.most_recent true] + [:= :r.model (h2x/literal "Dashboard")]] [:core_user :u] [:= :u.id :r.user_id]] :where [:and [:= :collection_id (:id collection)] diff --git a/src/metabase/api/search.clj b/src/metabase/api/search.clj index 023a5d1c1ccf1b5fe743eebe131491a26bc007b8..99d20416afc8f0cd097a81ee0f467d8cb292bcbd 100644 --- a/src/metabase/api/search.clj +++ b/src/metabase/api/search.clj @@ -2,9 +2,7 @@ (:require [cheshire.core :as json] [compojure.core :refer [GET]] - [flatland.ordered.map :as ordered-map] [honey.sql.helpers :as sql.helpers] - [malli.core :as mc] [medley.core :as m] [metabase.analytics.snowplow :as snowplow] [metabase.api.common :as api] @@ -12,14 +10,15 @@ [metabase.db.query :as mdb.query] [metabase.models.collection :as collection] [metabase.models.interface :as mi] - [metabase.models.permissions :as perms] [metabase.public-settings.premium-features :as premium-features] - [metabase.search.config :as search-config] + [metabase.search.config :as search.config :refer [SearchableModel SearchContext]] + [metabase.search.filter :as search.filter] [metabase.search.scoring :as scoring] - [metabase.search.util :as search-util] + [metabase.search.util :as search.util] [metabase.server.middleware.offset-paging :as mw.offset-paging] [metabase.util :as u] [metabase.util.honey-sql-2 :as h2x] + [metabase.util.i18n :refer [deferred-tru]] [metabase.util.log :as log] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] @@ -29,90 +28,15 @@ (set! *warn-on-reflection* true) -(def ^:private SearchableModel - (into [:enum] search-config/all-models)) - -(def ^:private SearchContext - "Map with the various allowed search parameters, used to construct the SQL query." - (mc/schema - [:map {:closed true} - [:search-string [:maybe ms/NonBlankString]] - [:archived? :boolean] - [:current-user-perms [:set perms/PathSchema]] - [:models {:optional true} [:maybe [:set SearchableModel]]] - [:table-db-id {:optional true} [:maybe ms/Int]] - [:limit-int {:optional true} [:maybe ms/Int]] - [:offset-int {:optional true} [:maybe ms/Int]]])) - (def ^:private HoneySQLColumn [:or :keyword [:tuple :any :keyword]]) -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | Columns for each Entity | -;;; +----------------------------------------------------------------------------------------------------------------+ - -(def ^:private all-search-columns - "All columns that will appear in the search results, and the types of those columns. The generated search query is a - `UNION ALL` of the queries for each different entity; it looks something like: - - SELECT 'card' AS model, id, cast(NULL AS integer) AS table_id, ... - FROM report_card - UNION ALL - SELECT 'metric' as model, id, table_id, ... - FROM metric - - Columns that aren't used in any individual query are replaced with `SELECT cast(NULL AS <type>)` statements. (These - are cast to the appropriate type because Postgres will assume `SELECT NULL` is `TEXT` by default and will refuse to - `UNION` two columns of two different types.)" - (ordered-map/ordered-map - ;; returned for all models. Important to be first for changing model for dataset - :model :text - :id :integer - :name :text - :display_name :text - :description :text - :archived :boolean - ;; returned for Card, Dashboard, and Collection - :collection_id :integer - :collection_name :text - :collection_authority_level :text - ;; returned for Card and Dashboard - :collection_position :integer - :bookmark :boolean - ;; returned for everything except Collection - :updated_at :timestamp - ;; returned for Card only - :dashboardcard_count :integer - :moderated_status :text - ;; returned for Metric and Segment - :table_id :integer - :table_schema :text - :table_name :text - :table_description :text - ;; returned for Metric, Segment, and Action - :database_id :integer - ;; returned for Database and Table - :initial_sync_status :text - ;; returned for Action - :model_id :integer - :model_name :text - ;; returned for indexed-entity - :pk_ref :text - :model_index_id :integer)) - ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Shared Query Logic | ;;; +----------------------------------------------------------------------------------------------------------------+ -(def ^:private true-clause [:inline [:= 1 1]]) -(def ^:private false-clause [:inline [:= 0 1]]) - -(mu/defn ^:private model->alias :- keyword? - [model :- SearchableModel] - (-> model search-config/model-to-db-model :alias)) - (mu/defn ^:private ->column-alias :- keyword? "Returns the column name. If the column is aliased, i.e. [`:original_name` `:aliased_name`], return the aliased column name" @@ -126,7 +50,7 @@ prefixed with the `model` name so that it can be used in criteria. Projects a `nil` for columns the `model` doesn't have and doesn't modify aliases." [model :- SearchableModel, col-alias->honeysql-clause :- [:map-of :keyword HoneySQLColumn]] - (for [[search-col col-type] all-search-columns + (for [[search-col col-type] search.config/all-search-columns :let [maybe-aliased-col (get col-alias->honeysql-clause search-col)]] (cond (= search-col :model) @@ -138,7 +62,7 @@ ;; This is a column reference, need to add the table alias to the column maybe-aliased-col - (keyword (name (model->alias model)) (name maybe-aliased-col)) + (search.config/column-with-model-alias model maybe-aliased-col) ;; This entity is missing the column, project a null for that column value. For Postgres and H2, cast it to the ;; correct type, e.g. @@ -154,84 +78,30 @@ (mu/defn ^:private select-clause-for-model :- [:sequential HoneySQLColumn] "The search query uses a `union-all` which requires that there be the same number of columns in each of the segments of the query. This function will take the columns for `model` and will inject constant `nil` values for any column - missing from `entity-columns` but found in `all-search-columns`." + missing from `entity-columns` but found in `search.config/all-search-columns`." [model :- SearchableModel] - (let [entity-columns (search-config/columns-for-model model) + (let [entity-columns (search.config/columns-for-model model) column-alias->honeysql-clause (m/index-by ->column-alias entity-columns) cols-or-nils (canonical-columns model column-alias->honeysql-clause)] cols-or-nils)) (mu/defn ^:private from-clause-for-model :- [:tuple [:tuple :keyword :keyword]] [model :- SearchableModel] - (let [{:keys [db-model alias]} (get search-config/model-to-db-model model)] + (let [{:keys [db-model alias]} (get search.config/model-to-db-model model)] [[(t2/table-name db-model) alias]])) -(defmulti ^:private archived-where-clause - {:arglists '([model archived?])} - (fn [model _] model)) - -(defmethod archived-where-clause :default - [model archived?] - [:= (keyword (name (model->alias model)) "archived") archived?]) - -;; Databases can't be archived -(defmethod archived-where-clause "database" - [_model archived?] - (if-not archived? - true-clause - false-clause)) - -(defmethod archived-where-clause "indexed-entity" - [_model archived?] - (if-not archived? - true-clause - false-clause)) - -;; Table has an `:active` flag, but no `:archived` flag; never return inactive Tables -(defmethod archived-where-clause "table" - [model archived?] - (if archived? - false-clause ; No tables should appear in archive searches - [:and - [:= (keyword (name (model->alias model)) "active") true] - [:= (keyword (name (model->alias model)) "visibility_type") nil]])) - -(defn- wildcard-match - [s] - (str "%" s "%")) - -(defn- search-string-clause - [model query searchable-columns] - (when query - (into [:or] - (for [column searchable-columns - token (search-util/tokenize (search-util/normalize query))] - (if (and (= model "indexed-entity") (premium-features/sandboxed-or-impersonated-user?)) - [:= 0 1] - - [:like - [:lower column] - (wildcard-match token)]))))) - -(mu/defn ^:private base-where-clause-for-model :- [:fn (fn [x] (and (seq x) (#{:and :inline :=} (first x))))] - [model :- SearchableModel {:keys [search-string archived?]} :- SearchContext] - (let [archived-clause (archived-where-clause model archived?) - search-clause (search-string-clause model search-string - (map (let [model-alias (name (model->alias model))] - (fn [column] - (keyword (str (name model-alias) "." (name column))))) - (search-config/searchable-columns-for-model model)))] - (if search-clause - [:and archived-clause search-clause] - archived-clause))) - -(mu/defn ^:private base-query-for-model :- [:map {:closed true} [:select :any] [:from :any] [:where :any]] +(mu/defn ^:private base-query-for-model :- [:map {:closed true} + [:select :any] + [:from :any] + [:where :any] + [:join {:optional true} :any] + [:left-join {:optional true} :any]] "Create a HoneySQL query map with `:select`, `:from`, and `:where` clauses for `model`, suitable for the `UNION ALL` used in search." [model :- SearchableModel context :- SearchContext] - {:select (select-clause-for-model model) - :from (from-clause-for-model model) - :where (base-where-clause-for-model model context)}) + (-> {:select (select-clause-for-model model) + :from (from-clause-for-model model)} + (search.filter/build-filters model context))) (mu/defn add-collection-join-and-where-clauses "Add a `WHERE` clause to the query to only return Collections the Current User has access to; join against Collection @@ -268,6 +138,40 @@ (sql.helpers/where query [:= id :database_id]) query)) +(mu/defn ^:private replace-select :- :map + "Replace a select from query that has alias is `target-alias` with the `with` column, throw an error if + can't find the target select. + + This works with the assumption that `query` contains a list of select from [[select-clause-for-model]], + and some of them are dummy column casted to the correct type. + + This function then will replace the dummy column with alias is `target-alias` with the `with` column." + [query :- :map + target-alias :- :keyword + with :- [:or :keyword [:sequential :any]]] + (let [selects (:select query) + idx (first (keep-indexed (fn [index item] + (when (and (coll? item) + (= (last item) target-alias)) + index)) + selects))] + (if (some? idx) + (assoc query :select (m/replace-nth idx with selects)) + (throw (ex-info "Failed to replace selector" {:status-code 400 + :target-alias target-alias + :with with}))))) + +(mu/defn ^:private with-last-editing-info :- :map + [query :- :map + model :- [:enum "card" "dataset" "dashboard" "metric"]] + (-> query + (replace-select :last_editor_id [:r.user_id :last_editor_id]) + (replace-select :last_edited_at [:r.timestamp :last_edited_at]) + (sql.helpers/left-join [:revision :r] + [:and [:= :r.model_id (search.config/column-with-model-alias model :id)] + [:= :r.most_recent true] + [:= :r.model (search.config/search-model->revision-model model)]]))) + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Search Queries for each Toucan Model | ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -277,15 +181,17 @@ (fn [model _] model)) (mu/defn ^:private shared-card-impl - [dataset? :- :boolean search-ctx :- SearchContext] + [model :- [:enum "card" "dataset"] + search-ctx :- SearchContext] (-> (base-query-for-model "card" search-ctx) - (update :where (fn [where] [:and [:= :card.dataset dataset?] where])) + (update :where (fn [where] [:and [:= :card.dataset (= "dataset" model)] where])) (sql.helpers/left-join [:card_bookmark :bookmark] [:and [:= :bookmark.card_id :card.id] [:= :bookmark.user_id api/*current-user-id*]]) (add-collection-join-and-where-clauses :card.collection_id search-ctx) - (add-card-db-id-clause (:table-db-id search-ctx)))) + (add-card-db-id-clause (:table-db-id search-ctx)) + (with-last-editing-info model))) (defmethod search-query-for-model "action" [model search-ctx] @@ -296,13 +202,14 @@ [:= :query_action.action_id :action.id]) (add-collection-join-and-where-clauses :model.collection_id search-ctx))) + (defmethod search-query-for-model "card" [_model search-ctx] - (shared-card-impl false search-ctx)) + (shared-card-impl "card" search-ctx)) (defmethod search-query-for-model "dataset" [_model search-ctx] - (-> (shared-card-impl true search-ctx) + (-> (shared-card-impl "dataset" search-ctx) (update :select (fn [columns] (cons [(h2x/literal "dataset") :model] (rest columns)))))) @@ -326,12 +233,14 @@ [:and [:= :bookmark.dashboard_id :dashboard.id] [:= :bookmark.user_id api/*current-user-id*]]) - (add-collection-join-and-where-clauses :dashboard.collection_id search-ctx))) + (add-collection-join-and-where-clauses :dashboard.collection_id search-ctx) + (with-last-editing-info model))) (defmethod search-query-for-model "metric" [model search-ctx] (-> (base-query-for-model model search-ctx) - (sql.helpers/left-join [:metabase_table :table] [:= :metric.table_id :table.id]))) + (sql.helpers/left-join [:metabase_table :table] [:= :metric.table_id :table.id]) + (with-last-editing-info model))) (defmethod search-query-for-model "indexed-entity" [model search-ctx] @@ -357,7 +266,7 @@ {:select (:select base-query) :from [[(merge base-query - {:select [:id :schema :db_id :name :description :display_name :updated_at :initial_sync_status + {:select [:id :schema :db_id :name :description :display_name :created_at :updated_at :initial_sync_status [(h2x/concat (h2x/literal "/db/") :db_id (h2x/literal "/schema/") @@ -377,8 +286,8 @@ (defn order-clause "CASE expression that lets the results be ordered by whether they're an exact (non-fuzzy) match or not" [query] - (let [match (wildcard-match (search-util/normalize query)) - columns-to-search (->> all-search-columns + (let [match (search.util/wildcard-match (search.util/normalize query)) + columns-to-search (->> search.config/all-search-columns (filter (fn [[_k v]] (= v :text))) (map first) (remove #{:collection_authority_level :moderated_status @@ -410,36 +319,54 @@ [instance] (mi/can-read? instance)) -(mu/defn query-model-set +(mu/defn query-model-set :- [:set SearchableModel] "Queries all models with respect to query for one result to see if we get a result or not" [search-ctx :- SearchContext] - ;; mapv used to realize lazy sequence. In web request, user bindings exist for entirety of request and we realize on - ;; serialization. At repl, we print lazy sequence after bindings have elapsed and you get unbound user errors - (mapv #(get (first %) :model) - (filter not-empty - (for [model search-config/all-models - :let [search-query (search-query-for-model model search-ctx) - query-with-limit (when search-query - (sql.helpers/limit search-query 1))] - :when query-with-limit] - (mdb.query/query query-with-limit))))) + (let [model-queries (for [model (search.filter/search-context->applicable-models + (assoc search-ctx :models search.config/all-models))] + {:nest (sql.helpers/limit (search-query-for-model model search-ctx) 1)}) + query (when (pos-int? (count model-queries)) + {:select [:*] + :from [[{:union-all model-queries} :dummy_alias]]})] + (set (some->> query + mdb.query/query + (map :model) + set)))) (mu/defn ^:private full-search-query "Postgres 9 is not happy with the type munging it needs to do to make the union-all degenerate down to trivial case of one model without errors. Therefore we degenerate it down for it" [search-ctx :- SearchContext] - (let [models (or (:models search-ctx) - search-config/all-models) - sql-alias :alias_is_required_by_sql_but_not_needed_here + (let [models (:models search-ctx) order-clause [((fnil order-clause "") (:search-string search-ctx))]] - (if (= (count models) 1) - (search-query-for-model (first models) search-ctx) - {:select [:*] - :from [[{:union-all (vec (for [model models - :let [query (search-query-for-model model search-ctx)] - :when (seq query)] - query))} sql-alias]] - :order-by order-clause}))) + (cond + (= (count models) 0) + {:select [nil]} + + (= (count models) 1) + (search-query-for-model (first models) search-ctx) + + :else + {:select [:*] + :from [[{:union-all (vec (for [model models + :let [query (search-query-for-model model search-ctx)] + :when (seq query)] + query))} :alias_is_required_by_sql_but_not_needed_here]] + :order-by order-clause}))) + +(defn- hydrate-user-metadata + "Hydrate common-name for last_edited_by and created_by from result." + [results] + (let [user-ids (set (flatten (for [result results] + (remove nil? ((juxt :last_editor_id :creator_id) result))))) + user-id->common-name (if (pos? (count user-ids)) + (t2/select-pk->fn :common_name [:model/User :id :first_name :last_name :email] :id [:in user-ids]) + {})] + (mapv (fn [{:keys [creator_id last_editor_id] :as result}] + (assoc result + :creator_common_name (get user-id->common-name creator_id) + :last_editor_common_name (get user-id->common-name last_editor_id))) + results))) (mu/defn ^:private search "Builds a search query that includes all the searchable entities and runs it" @@ -449,9 +376,9 @@ (u/pprint-to-str search-query) (mdb.query/format-sql (first (mdb.query/compile search-query)))) to-toucan-instance (fn [row] - (let [model (-> row :model search-config/model-to-db-model :db-model)] + (let [model (-> row :model search.config/model-to-db-model :db-model)] (t2.instance/instance model row))) - reducible-results (mdb.query/reducible-query search-query :max-rows search-config/*db-max-results*) + reducible-results (mdb.query/reducible-query search-query :max-rows search.config/*db-max-results*) xf (comp (map t2.realize/realize) (map to-toucan-instance) @@ -463,14 +390,14 @@ (map #(update % :pk_ref json/parse-string)) (map (partial scoring/score-and-result (:search-string search-ctx))) (filter #(pos? (:score %)))) - total-results (scoring/top-results reducible-results search-config/max-filtered-results xf)] + total-results (hydrate-user-metadata (scoring/top-results reducible-results search.config/max-filtered-results xf))] ;; We get to do this slicing and dicing with the result data because ;; the pagination of search is for UI improvement, not for performance. ;; We intend for the cardinality of the search results to be below the default max before this slicing occurs {:total (count total-results) :data (cond->> total-results - (some? (:offset-int search-ctx)) (drop (:offset-int search-ctx)) - (some? (:limit-int search-ctx)) (take (:limit-int search-ctx))) + (some? (:offset-int search-ctx)) (drop (:offset-int search-ctx)) + (some? (:limit-int search-ctx)) (take (:limit-int search-ctx))) :available_models (query-model-set search-ctx) :limit (:limit-int search-ctx) :offset (:offset-int search-ctx) @@ -481,27 +408,72 @@ ;;; | Endpoint | ;;; +----------------------------------------------------------------------------------------------------------------+ -(mu/defn ^:private search-context :- SearchContext - [search-string :- [:maybe ms/NonBlankString] - archived-string :- [:maybe ms/BooleanString] - table-db-id :- [:maybe ms/PositiveInt] - models :- [:maybe [:or SearchableModel [:sequential SearchableModel]]] - limit :- [:maybe ms/PositiveInt] - offset :- [:maybe ms/IntGreaterThanOrEqualToZero]] - (cond-> {:search-string search-string - :archived? (Boolean/parseBoolean archived-string) - :current-user-perms @api/*current-user-permissions-set*} - (some? table-db-id) (assoc :table-db-id table-db-id) - (some? models) (assoc :models - (apply hash-set (if (vector? models) models [models]))) - (some? limit) (assoc :limit-int limit) - (some? offset) (assoc :offset-int offset))) +(mu/defn ^:private search-context + [{:keys [archived + created-at + created-by + last-edited-at + last-edited-by + limit + models + offset + search-string + table-db-id + search-native-query + verified]} :- [:map {:closed true} + [:search-string [:maybe ms/NonBlankString]] + [:models [:maybe [:set SearchableModel]]] + [:archived {:optional true} [:maybe :boolean]] + [:created-at {:optional true} [:maybe ms/NonBlankString]] + [:created-by {:optional true} [:maybe [:set ms/PositiveInt]]] + [:last-edited-at {:optional true} [:maybe ms/NonBlankString]] + [:last-edited-by {:optional true} [:maybe [:set ms/PositiveInt]]] + [:limit {:optional true} [:maybe ms/Int]] + [:offset {:optional true} [:maybe ms/Int]] + [:table-db-id {:optional true} [:maybe ms/PositiveInt]] + [:search-native-query {:optional true} [:maybe boolean?]] + [:verified {:optional true} [:maybe true?]]]] + (when (some? verified) + (premium-features/assert-has-any-features + [:content-verification :official-collections] + (deferred-tru "Content Management or Official Collections"))) + (let [models (if (string? models) [models] models) + ctx (cond-> {:search-string search-string + :current-user-perms @api/*current-user-permissions-set* + :archived? (boolean archived) + :models models} + (some? created-at) (assoc :created-at created-at) + (seq created-by) (assoc :created-by created-by) + (some? last-edited-at) (assoc :last-edited-at last-edited-at) + (seq last-edited-by) (assoc :last-edited-by last-edited-by) + (some? table-db-id) (assoc :table-db-id table-db-id) + (some? limit) (assoc :limit-int limit) + (some? offset) (assoc :offset-int offset) + (some? search-native-query) (assoc :search-native-query search-native-query) + (some? verified) (assoc :verified verified))] + (assoc ctx :models (search.filter/search-context->applicable-models ctx)))) (api/defendpoint GET "/models" "Get the set of models that a search query will return" - [q archived-string table-db-id] - {table-db-id [:maybe ms/PositiveInt]} - (query-model-set (search-context q archived-string table-db-id nil nil nil))) + [q archived table-db-id created_at created_by last_edited_at last_edited_by search_native_query verified] + {archived [:maybe ms/BooleanValue] + table-db-id [:maybe ms/PositiveInt] + created_at [:maybe ms/NonBlankString] + created_by [:maybe [:or ms/PositiveInt [:sequential ms/PositiveInt]]] + last_edited_at [:maybe ms/PositiveInt] + last_edited_by [:maybe [:or ms/PositiveInt [:sequential ms/PositiveInt]]] + search_native_query [:maybe true?] + verified [:maybe true?]} + (query-model-set (search-context {:search-string q + :archived archived + :table-db-id table-db-id + :created-at created_at + :created-by (set (u/one-or-many created_by)) + :last-edited-at last_edited_at + :last-edited-by (set (u/one-or-many last_edited_by)) + :search-native-query search_native_query + :verified verified + :models search.config/all-models}))) (api/defendpoint GET "/" "Search within a bunch of models for the substring `q`. @@ -512,20 +484,36 @@ to `table_db_id`. To specify a list of models, pass in an array to `models`. " - [q archived table_db_id models] - {q [:maybe ms/NonBlankString] - archived [:maybe ms/BooleanString] - table_db_id [:maybe ms/PositiveInt] - models [:maybe [:or SearchableModel [:sequential SearchableModel]]]} + [q archived created_at created_by table_db_id models last_edited_at last_edited_by search_native_query verified] + {q [:maybe ms/NonBlankString] + archived [:maybe :boolean] + table_db_id [:maybe ms/PositiveInt] + models [:maybe [:or SearchableModel [:sequential SearchableModel]]] + created_at [:maybe ms/NonBlankString] + created_by [:maybe [:or ms/PositiveInt [:sequential ms/PositiveInt]]] + last_edited_at [:maybe ms/NonBlankString] + last_edited_by [:maybe [:or ms/PositiveInt [:sequential ms/PositiveInt]]] + search_native_query [:maybe true?] + verified [:maybe true?]} (api/check-valid-page-params mw.offset-paging/*limit* mw.offset-paging/*offset*) (let [start-time (System/currentTimeMillis) + models-set (cond + (nil? models) search.config/all-models + (string? models) #{models} + :else (set models)) results (search (search-context - q - archived - table_db_id - models - mw.offset-paging/*limit* - mw.offset-paging/*offset*)) + {:search-string q + :archived archived + :created-at created_at + :created-by (set (u/one-or-many created_by)) + :last-edited-at last_edited_at + :last-edited-by (set (u/one-or-many last_edited_by)) + :table-db-id table_db_id + :models models-set + :limit mw.offset-paging/*limit* + :offset mw.offset-paging/*offset* + :search-native-query search_native_query + :verified verified})) duration (- (System/currentTimeMillis) start-time)] ;; Only track global searches (when (and (nil? models) diff --git a/src/metabase/driver/common/parameters/dates.clj b/src/metabase/driver/common/parameters/dates.clj index 42e049abf9d079445b410915103b80ef3473c942..e4c05127968f4d066df814f8309c138abd9af293 100644 --- a/src/metabase/driver/common/parameters/dates.clj +++ b/src/metabase/driver/common/parameters/dates.clj @@ -12,8 +12,7 @@ [metabase.util.date-2 :as u.date] [metabase.util.i18n :refer [tru]] [metabase.util.malli :as mu] - [metabase.util.malli.schema :as ms] - [schema.core :as s]) + [metabase.util.malli.schema :as ms]) (:import (java.time.temporal Temporal))) @@ -40,7 +39,7 @@ (defn- day-range [start end] - {:start start, :end end}) + {:start start :end end :unit :day}) (defn- comparison-range ([t unit] @@ -52,7 +51,8 @@ ([start end unit resolution] (merge (u.date/comparison-range start unit :>= {:resolution resolution}) - (u.date/comparison-range end unit :<= {:resolution resolution, :end :inclusive})))) + (u.date/comparison-range end unit :<= {:resolution resolution, :end :inclusive}) + {:unit unit}))) (defn- second-range [start end] @@ -87,7 +87,8 @@ "Q3" 3 "Q4" 4))] {:start (.atDay year-quarter 1) - :end (.atEndOfQuarter year-quarter)})) + :end (.atEndOfQuarter year-quarter) + :unit :quarter})) (def ^:private operations-by-date-unit {"second" {:unit-range second-range @@ -108,8 +109,7 @@ :to-period t/years}}) (defn- maybe-reduce-resolution [unit dt] - (if - (contains? #{"second" "minute" "hour"} unit) + (if (contains? #{"second" "minute" "hour"} unit) dt ; for units that are a day or longer, convert back to LocalDate (t/local-date dt))) @@ -167,7 +167,8 @@ :range (fn [_ dt] (let [dt-res (t/local-date dt)] {:start dt-res, - :end dt-res})) + :end dt-res + :unit :day})) :filter (fn [_ field-clause] [:= (with-temporal-unit-if-field field-clause :day) [:relative-datetime :current]])} @@ -175,7 +176,8 @@ :range (fn [_ dt] (let [dt-res (t/local-date dt)] {:start (t/minus dt-res (t/days 1)) - :end (t/minus dt-res (t/days 1))})) + :end (t/minus dt-res (t/days 1)) + :unit :day})) :filter (fn [_ field-clause] [:= (with-temporal-unit-if-field field-clause :day) [:relative-datetime -1 :day]])} @@ -291,6 +293,13 @@ "Regex to match date exclusion values, e.g. exclude-days-Mon, exclude-months-Jan, etc." (re-pattern (str "exclude-" temporal-units-regex #"s-([-\p{Alnum}]+)"))) +(defn- absolute-date->unit + [date-string] + (if (str/includes? date-string "T") + ;; on the UI you can specify the time up to the minute, so we use minute here + :minute + :day)) + (def ^:private absolute-date-string-decoders ;; year and month [{:parser (regex->parser #"([0-9]{4}-[0-9]{2})" [:date]) @@ -308,20 +317,20 @@ ;; single day {:parser (regex->parser #"([0-9-T:]+)" [:date]) :range (fn [{:keys [date]} _] - {:start date, :end date}) + {:start date :end date :unit (absolute-date->unit date)}) :filter (fn [{:keys [date]} field-clause] (let [iso8601date (->iso-8601-date date)] [:= (with-temporal-unit-if-field field-clause :day) iso8601date]))} ;; day range {:parser (regex->parser #"([0-9-T]+)~([0-9-T]+)" [:date-1 :date-2]) :range (fn [{:keys [date-1 date-2]} _] - {:start date-1, :end date-2}) + {:start date-1 :end date-2 :unit (absolute-date->unit date-1)}) :filter (fn [{:keys [date-1 date-2]} field-clause] [:between (with-temporal-unit-if-field field-clause :day) (->iso-8601-date date-1) (->iso-8601-date date-2)])} ;; datetime range {:parser (regex->parser #"([0-9-T:]+)~([0-9-T:]+)" [:date-1 :date-2]) :range (fn [{:keys [date-1 date-2]} _] - {:start date-1, :end date-2}) + {:start date-1, :end date-2 :unit (absolute-date->unit date-1)}) :filter (fn [{:keys [date-1 date-2]} field-clause] [:between (with-temporal-unit-if-field field-clause :default) (->iso-8601-date-time date-1) @@ -329,13 +338,13 @@ ;; before day {:parser (regex->parser #"~([0-9-T:]+)" [:date]) :range (fn [{:keys [date]} _] - {:end date}) + {:end date :unit (absolute-date->unit date)}) :filter (fn [{:keys [date]} field-clause] [:< (with-temporal-unit-if-field field-clause :day) (->iso-8601-date date)])} ;; after day {:parser (regex->parser #"([0-9-T:]+)~" [:date]) :range (fn [{:keys [date]} _] - {:start date}) + {:start date :unit (absolute-date->unit date)}) :filter (fn [{:keys [date]} field-clause] [:> (with-temporal-unit-if-field field-clause :day) (->iso-8601-date date)])} ;; exclusions @@ -363,38 +372,58 @@ (parser-result-decoder parser-result decoder-param))) decoders)) +(def ^:private TemporalUnit + (into [:enum] u.date/add-units)) + (def ^:private TemporalRange - {(s/optional-key :start) Temporal, (s/optional-key :end) Temporal}) + [:map + [:start {:optional true} [:fn #(instance? Temporal %)]] + [:end {:optional true} [:fn #(instance? Temporal %)]] + [:unit TemporalUnit]]) -(s/defn ^:private adjust-inclusive-range-if-needed :- (s/maybe TemporalRange) +(mu/defn ^:private adjust-inclusive-range-if-needed :- [:maybe TemporalRange] "Make an inclusive date range exclusive as needed." - [{:keys [inclusive-start? inclusive-end?]}, {:keys [start end]} :- (s/maybe TemporalRange)] - (merge - (when start - {:start (if inclusive-start? - start - (u.date/add start :day -1))}) - (when end - {:end (if inclusive-end? - end - (u.date/add end :day 1))}))) + [{:keys [inclusive-start? inclusive-end?]} temporal-range :- [:maybe TemporalRange]] + (-> temporal-range + (m/update-existing :start #(if inclusive-start? + % + (u.date/add % (case (:unit temporal-range) + (:year :quarter :month :week :day) + :day + (:unit temporal-range)) -1))) + (m/update-existing :end #(if inclusive-end? + % + (u.date/add % (case (:unit temporal-range) + (:year :quarter :month :week :day) + :day + (:unit temporal-range)) 1))))) (def ^:private DateStringRange "Schema for a valid date range returned by `date-string->range`." - (-> {(s/optional-key :start) s/Str, (s/optional-key :end) s/Str} - (s/constrained seq - "must have either :start or :end") - (s/constrained (fn [{:keys [start end]}] - (or (not start) - (not end) - (not (pos? (compare start end))))) - ":start must not come after :end") - (s/named "valid date range"))) - -(s/defn date-string->range :- DateStringRange + [:and [:map {:closed true} + [:start {:optional true} ms/NonBlankString] + [:end {:optional true} ms/NonBlankString]] + [:fn {:error/message "must have either :start or :end"} + (fn [{:keys [start end]}] + (or start end))] + [:fn {:error/message ":start must come before :end"} + (fn [{:keys [start end]}] + (or (not start) + (not end) + (not (pos? (compare start end)))))]]) + +(defn- format-date-range + [date-range] + (-> date-range + (m/update-existing :start u.date/format) + (m/update-existing :end u.date/format) + (dissoc :unit))) + +(mu/defn date-string->range :- DateStringRange "Takes a string description of a date range such as `lastmonth` or `2016-07-15~2016-08-6` and returns a map with - `:start` and/or `:end` keys, as ISO-8601 *date* strings. By default, `:start` and `:end` are inclusive, e.g. + `:start` and/or `:end` keys, as ISO-8601 *date* strings. By default, `:start` and `:end` are inclusive, + e.g: (date-string->range \"past2days\") ; -> {:start \"2020-01-20\", :end \"2020-01-21\"} intended for use with SQL like @@ -409,20 +438,21 @@ ([date-string] (date-string->range date-string nil)) - ([date-string :- s/Str {:keys [inclusive-start? inclusive-end?] - :or {inclusive-start? true, inclusive-end? true}}] + ([date-string :- ms/NonBlankString + {:keys [inclusive-start? inclusive-end?] + :or {inclusive-start? true inclusive-end? true}}] (let [options {:inclusive-start? inclusive-start?, :inclusive-end? inclusive-end?} now (t/local-date-time)] ;; Relative dates respect the given time zone because a notion like "last 7 days" might mean a different range of ;; days depending on the user timezone (or (->> (execute-decoders relative-date-string-decoders :range now date-string) (adjust-inclusive-range-if-needed options) - (m/map-vals u.date/format)) + format-date-range) ;; Absolute date ranges don't need the time zone conversion because in SQL the date ranges are compared ;; against the db field value that is casted granularity level of a day in the db time zone (->> (execute-decoders absolute-date-string-decoders :range nil date-string) (adjust-inclusive-range-if-needed options) - (m/map-vals u.date/format)) + format-date-range) ;; if both of the decoders above fail, then the date string is invalid (throw (ex-info (tru "Don''t know how to parse date param ''{0}'' — invalid format" date-string) {:param date-string diff --git a/src/metabase/models/metric.clj b/src/metabase/models/metric.clj index fe4947b29cf0344f0526d49a8e6af2d44cfa06f0..18baa736f9883e74cbedca21d1247364391285db 100644 --- a/src/metabase/models/metric.clj +++ b/src/metabase/models/metric.clj @@ -51,6 +51,10 @@ (when (not= (:creator_id <>) (t2/select-one-fn :creator_id Metric :id id)) (throw (UnsupportedOperationException. (tru "You cannot update the creator_id of a Metric."))))))) +(t2/define-before-delete :model/Metric + [{:keys [id] :as _metric}] + (t2/delete! :model/Revision :model "Metric" :model_id id)) + (defmethod mi/perms-objects-set Metric [metric read-or-write] (let [table (or (:table metric) diff --git a/src/metabase/models/params/custom_values.clj b/src/metabase/models/params/custom_values.clj index f14b0322035041bb0a20f42e6e8742e3fe8899b3..2c45e7bf94ad5389e69dac99ea1fcd26f3c79c98 100644 --- a/src/metabase/models/params/custom_values.clj +++ b/src/metabase/models/params/custom_values.clj @@ -11,7 +11,7 @@ [metabase.models.interface :as mi] [metabase.query-processor :as qp] [metabase.query-processor.util :as qp.util] - [metabase.search.util :as search] + [metabase.search.util :as search.util] [metabase.util :as u] [metabase.util.i18n :refer [tru]] [metabase.util.malli :as mu] @@ -26,13 +26,11 @@ - [[value1], [value2]] - [[value2, label2], [value2, label2]] - we search using label in this case" [query values] - (let [normalized-query (search/normalize query)] - (filter (fn [v] - (str/includes? (search/normalize (if (= (count v) 1) - (first v) - (second v))) - normalized-query)) - values))) + (let [normalized-query (search.util/normalize query)] + (filter (fn [v] (str/includes? (search.util/normalize (if (= (count v) 1) + (first v) + (second v))) + normalized-query)) values))) (defn- static-list-values [{values-source-options :values_source_config :as _param} query] diff --git a/src/metabase/models/revision.clj b/src/metabase/models/revision.clj index 336356d69ffe215d456965ca964ab081061f9f32..208da49be9a299c18c14b505cdd5b5660a91584e 100644 --- a/src/metabase/models/revision.clj +++ b/src/metabase/models/revision.clj @@ -74,7 +74,10 @@ (t2/define-before-insert :model/Revision [revision] - (assoc revision :timestamp :%now :metabase_version config/mb-version-string)) + (assoc revision + :timestamp :%now + :metabase_version config/mb-version-string + :most_recent true)) (t2/define-before-update :model/Revision [_revision] @@ -91,6 +94,28 @@ (cond-> revision model (update :object (partial mi/do-after-select model))))) +(defn- delete-old-revisions! + "Delete old revisions of `model` with `id` when there are more than `max-revisions` in the DB." + [model id] + (when-let [old-revisions (seq (drop max-revisions (t2/select-fn-vec :id :model/Revision + :model (name model) + :model_id id + {:order-by [[:timestamp :desc] + [:id :desc]]})))] + (t2/delete! :model/Revision :id [:in old-revisions]))) + +(t2/define-after-insert :model/Revision + [revision] + (u/prog1 revision + (let [{:keys [id model model_id]} revision] + ;; Note 1: Update the last `most_recent revision` to false (not including the current revision) + ;; Note 2: We don't allow updating revision but this is a special case, so we by pass the check by + ;; updating directly with the table name + (t2/update! (t2/table-name :model/Revision) + {:model model :model_id model_id :most_recent true :id [:not= id]} + {:most_recent false}) + (delete-old-revisions! model model_id)))) + ;;; # Functions (defn- revision-changes @@ -144,17 +169,6 @@ (recur (conj acc (add-revision-details model r1 r2)) (conj more r2)))))) -(defn- delete-old-revisions! - "Delete old revisions of `model` with `id` when there are more than `max-revisions` in the DB." - [model id] - {:pre [(mdb.u/toucan-model? model) (integer? id)]} - (when-let [old-revisions (seq (drop max-revisions (map :id (t2/select [Revision :id] - :model (name model) - :model_id id - {:order-by [[:timestamp :desc] - [:id :desc]]}))))] - (t2/delete! Revision :id [:in old-revisions]))) - (defn push-revision! "Record a new Revision for `entity` with `id` if it's changed compared to the last revision. Returns `object` or `nil` if the object does not changed." @@ -187,7 +201,6 @@ :is_creation is-creation? :is_reversion false :message message) - (delete-old-revisions! entity id) object))) (defn revert! diff --git a/src/metabase/models/revision/last_edit.clj b/src/metabase/models/revision/last_edit.clj index 367905804cff106e5b7535dad65c81feedf49497..23c157520aa4f8e4b032be554b95e88a8e53f434 100644 --- a/src/metabase/models/revision/last_edit.clj +++ b/src/metabase/models/revision/last_edit.clj @@ -11,9 +11,9 @@ [clj-time.core :as time] [clojure.set :as set] [medley.core :as m] - [metabase.db.query :as mdb.query] [metabase.util.malli :as mu] - [metabase.util.malli.schema :as ms])) + [metabase.util.malli.schema :as ms] + [toucan2.core :as t2])) (def ^:private model->db-model {:card "Card" :dashboard "Dashboard"}) @@ -38,14 +38,13 @@ `:card`. Gets the last edited information from the revisions table. If you need this information from a put route, use `@api/*current-user*` and a current timestamp since revisions are events and asynchronous." [{:keys [id] :as item} model :- [:enum :dashboard :card]] - (if-let [[updated-info] (seq (mdb.query/query {:select [:u.id :u.email :u.first_name :u.last_name :r.timestamp] - :from [[:revision :r]] - :left-join [[:core_user :u] [:= :u.id :r.user_id]] - :where [:and - [:= :r.model (model->db-model model)] - [:= :r.model_id id]] - :order-by [[:r.id :desc]] - :limit 1}))] + (if-let [updated-info (t2/query-one {:select [:u.id :u.email :u.first_name :u.last_name :r.timestamp] + :from [[:revision :r]] + :left-join [[:core_user :u] [:= :u.id :r.user_id]] + :where [:and + [:= :r.most_recent true] + [:= :r.model (model->db-model model)] + [:= :r.model_id id]]})] (assoc item :last-edit-info updated-info) item)) @@ -69,27 +68,21 @@ "Fetch edited info from the revisions table. Revision information is timestamp, user id, email, first and last name. Takes card-ids and dashboard-ids and returns a map structured like - {:card {model_id {:id :email :first_name :last_name :timestamp}} - :dashboard {model_id {:id :email :first_name :last_name :timestamp}}}" + {:card {card_id {:id :email :first_name :last_name :timestamp}} + :dashboard {dashboard_id {:id :email :first_name :last_name :timestamp}}}" [{:keys [card-ids dashboard-ids]}] (when (seq (concat card-ids dashboard-ids)) - ;; [:in :model_id []] generates bad sql so need to conditionally add it - (let [where-clause (into [:or] - (keep (fn [[model-name ids]] - (when (seq ids) - [:and [:= :model model-name] [:in :model_id ids]]))) - [["Card" card-ids] - ["Dashboard" dashboard-ids]]) - latest-changes (mdb.query/query {:select [:u.id :u.email :u.first_name :u.last_name - :r.model :r.model_id :r.timestamp] - :from [[:revision :r]] - :left-join [[:core_user :u] [:= :u.id :r.user_id]] - :where [:in :r.id - ;; subselect for the max revision id for each item - {:select [[:%max.id :latest-revision-id]] - :from [:revision] - :where where-clause - :group-by [:model :model_id]}]})] + (let [latest-changes (t2/query {:select [:u.id :u.email :u.first_name :u.last_name + :r.model :r.model_id :r.timestamp] + :from [[:revision :r]] + :left-join [[:core_user :u] [:= :u.id :r.user_id]] + :where [:and [:= :r.most_recent true] + (into [:or] + (keep (fn [[model-name ids]] + (when (seq ids) + [:and [:= :model model-name] [:in :model_id ids]]))) + [["Card" card-ids] + ["Dashboard" dashboard-ids]])]})] (->> latest-changes (group-by :model) (m/map-vals (fn [model-changes] diff --git a/src/metabase/moderation.clj b/src/metabase/moderation.clj index f0cb7edd64047bf25ce7d2ccf30174dd9907022a..3ef73255267a50c217aceaa41057a085d3b12d9e 100644 --- a/src/metabase/moderation.clj +++ b/src/metabase/moderation.clj @@ -12,9 +12,7 @@ (def moderated-item-type->model "Maps DB name of the moderated item type to the model symbol (used for t2/select and such)" {"card" 'Card - :card 'Card - "dashboard" 'Dashboard - :dashboard 'Dashboard}) + :card 'Card}) (defn- object->type "Convert a moderated item instance to the keyword stored in the database" diff --git a/src/metabase/public_settings/premium_features.clj b/src/metabase/public_settings/premium_features.clj index 72f11cd3958035a10f7679995e197f7dde20be92..3cc43c410518135c084117d86300197a6a647689 100644 --- a/src/metabase/public_settings/premium_features.clj +++ b/src/metabase/public_settings/premium_features.clj @@ -258,8 +258,7 @@ (mu/defn assert-has-feature "Check if an token with `feature` is present. If not, throw an error with a message using `feature-name`. - `feature-name` should be a localized string unless used in a CLI context. - + `feature-name` should be a localized string unless used in a CLI context. (assert-has-feature :sandboxes (tru \"Sandboxing\")) => throws an error with a message using \"Sandboxing\" as the feature name." [feature-flag :- keyword? @@ -267,6 +266,13 @@ (when-not (has-feature? feature-flag) (throw (ee-feature-error feature-name)))) +(mu/defn assert-has-any-features + "Check if has at least one of feature in `features`. Throw an error if none of the features are available." + [feature-flag :- [:sequential keyword?] + feature-name :- [:or string? mu/localized-string-schema]] + (when-not (some has-feature? feature-flag) + (throw (ee-feature-error feature-name)))) + (defn- default-premium-feature-getter [feature] (fn [] (and config/ee-available? diff --git a/src/metabase/search/config.clj b/src/metabase/search/config.clj index d913c6dcd028272f3e6ade4da503b26d3a5fe903..41dbfdfafc3a8062a1f8cc583c395368fc04b40a 100644 --- a/src/metabase/search/config.clj +++ b/src/metabase/search/config.clj @@ -1,11 +1,15 @@ (ns metabase.search.config (:require - [metabase.models - :refer [Action Card Collection Dashboard Database Metric - ModelIndexValue Segment Table]] + [cheshire.core :as json] + [clojure.string :as str] + [flatland.ordered.map :as ordered-map] + [malli.core :as mc] + [metabase.models.permissions :as perms] [metabase.models.setting :refer [defsetting]] [metabase.public-settings :as public-settings] - [metabase.util.i18n :refer [deferred-tru]])) + [metabase.util.i18n :refer [deferred-tru]] + [metabase.util.malli :as mu] + [metabase.util.malli.schema :as ms])) (defsetting search-typeahead-enabled (deferred-tru "Enable typeahead search in the {0} navbar?" @@ -41,22 +45,128 @@ (def model-to-db-model "Mapping from string model to the Toucan model backing it." - {"action" {:db-model Action, :alias :action} - "card" {:db-model Card, :alias :card} - "collection" {:db-model Collection, :alias :collection} - "dashboard" {:db-model Dashboard, :alias :dashboard} - "database" {:db-model Database, :alias :database} - "dataset" {:db-model Card, :alias :card} - "indexed-entity" {:db-model ModelIndexValue :alias :model-index-value} - "metric" {:db-model Metric, :alias :metric} - "segment" {:db-model Segment, :alias :segment} - "table" {:db-model Table, :alias :table}}) + {"action" {:db-model :model/Action :alias :action} + "card" {:db-model :model/Card :alias :card} + "collection" {:db-model :model/Collection :alias :collection} + "dashboard" {:db-model :model/Dashboard :alias :dashboard} + "database" {:db-model :model/Database :alias :database} + "dataset" {:db-model :model/Card :alias :card} + "indexed-entity" {:db-model :model/ModelIndexValue :alias :model-index-value} + "metric" {:db-model :model/Metric :alias :metric} + "segment" {:db-model :model/Segment :alias :segment} + "table" {:db-model :model/Table :alias :table}}) (def all-models - "All valid models to search for. The order of this list also influences the order of the results: items earlier in the + "Set of all valid models to search for. " + (set (keys model-to-db-model))) + +(def models-search-order + "The order of this list influences the order of the results: items earlier in the list will be ranked higher." ["dashboard" "metric" "segment" "indexed-entity" "card" "dataset" "collection" "table" "action" "database"]) +(assert (= all-models (set models-search-order)) "The models search order has to include all models") + +(defn search-model->revision-model + "Return the apporpriate revision model given a search model." + [model] + (case model + "dataset" (recur "card") + (str/capitalize model))) + +(defn model->alias + "Given a model string returns the model alias" + [model] + (-> model model-to-db-model :alias)) + +(mu/defn column-with-model-alias :- keyword? + "Given a column and a model name, Return a keyword representing the column with the model alias prepended. + + (column-with-model-alias \"card\" :id) => :card.id)" + [model-string :- ms/KeywordOrString + column :- ms/KeywordOrString] + (keyword (str (name (model->alias model-string)) "." (name column)))) + +(def SearchableModel + "Schema for searchable models" + (into [:enum] all-models)) + +(def SearchContext + "Map with the various allowed search parameters, used to construct the SQL query." + (mc/schema + [:map {:closed true} + [:search-string [:maybe ms/NonBlankString]] + [:archived? :boolean] + [:current-user-perms [:set perms/PathSchema]] + [:models [:set SearchableModel]] + [:created-at {:optional true} ms/NonBlankString] + [:created-by {:optional true} [:set {:min 1} ms/PositiveInt]] + [:last-edited-at {:optional true} ms/NonBlankString] + [:last-edited-by {:optional true} [:set {:min 1} ms/PositiveInt]] + [:table-db-id {:optional true} ms/PositiveInt] + [:limit-int {:optional true} ms/Int] + [:offset-int {:optional true} ms/Int] + [:search-native-query {:optional true} true?] + ;; true to search for verified items only, + ;; nil will return all items + [:verified {:optional true} true?]])) + + +(def all-search-columns + "All columns that will appear in the search results, and the types of those columns. The generated search query is a + `UNION ALL` of the queries for each different entity; it looks something like: + + SELECT 'card' AS model, id, cast(NULL AS integer) AS table_id, ... + FROM report_card + UNION ALL + SELECT 'metric' as model, id, table_id, ... + FROM metric + + Columns that aren't used in any individual query are replaced with `SELECT cast(NULL AS <type>)` statements. (These + are cast to the appropriate type because Postgres will assume `SELECT NULL` is `TEXT` by default and will refuse to + `UNION` two columns of two different types.)" + (ordered-map/ordered-map + ;; returned for all models. Important to be first for changing model for dataset + :model :text + :id :integer + :name :text + :display_name :text + :description :text + :archived :boolean + ;; returned for Card, Dashboard, and Collection + :collection_id :integer + :collection_name :text + :collection_authority_level :text + ;; returned for Card and Dashboard + :collection_position :integer + :creator_id :integer + :created_at :timestamp + :bookmark :boolean + ;; returned for everything except Collection + :updated_at :timestamp + ;; returned for Card only, used for scoring + :dashboardcard_count :integer + :last_edited_at :timestamp + :last_editor_id :integer + :moderated_status :text + ;; returned for Metric and Segment + :table_id :integer + :table_schema :text + :table_name :text + :table_description :text + ;; returned for Metric, Segment, and Action + :database_id :integer + ;; returned for Database and Table + :initial_sync_status :text + ;; returned for Action + :model_id :integer + :model_name :text + ;; returned for indexed-entity + :pk_ref :text + :model_index_id :integer + ;; returned for Card and Action + :dataset_query :text)) + (def ^:const displayed-columns "All of the result components that by default are displayed by the frontend." #{:name :display_name :collection_name :description}) @@ -73,11 +183,13 @@ (defmethod searchable-columns-for-model "action" [_] [:name + :dataset_query :description]) (defmethod searchable-columns-for-model "card" [_] [:name + :dataset_query :description]) (defmethod searchable-columns-for-model "dataset" @@ -110,7 +222,7 @@ (def ^:private default-columns "Columns returned for all models." - [:id :name :description :archived :updated_at]) + [:id :name :description :archived :created_at :updated_at]) (def ^:private bookmark-col "Case statement to return boolean values of `:bookmark` for Card, Collection and Dashboard." @@ -126,6 +238,7 @@ (def ^:private table-columns "Columns containing information about the Table this model references. Returned for Metrics and Segments." [:table_id + :created_at [:table.db_id :database_id] [:table.schema :table_schema] [:table.name :table_name] @@ -139,14 +252,16 @@ (defmethod columns-for-model "action" [_] (conj default-columns :model_id + :creator_id [:model.collection_id :collection_id] [:model.id :model_id] [:model.name :model_name] - [:query_action.database_id :database_id])) + [:query_action.database_id :database_id] + [:query_action.dataset_query :dataset_query])) (defmethod columns-for-model "card" [_] - (conj default-columns :collection_id :collection_position + (conj default-columns :collection_id :collection_position :dataset_query :creator_id [:collection.name :collection_name] [:collection.authority_level :collection_authority_level] [{:select [:status] @@ -171,18 +286,17 @@ [:model.collection_id :collection_id] [:model.id :model_id] [:model.name :model_name] - [:model.database_id :database_id] - [:model.dataset_query :dataset_query]]) + [:model.database_id :database_id]]) (defmethod columns-for-model "dashboard" [_] - (conj default-columns :collection_id :collection_position bookmark-col + (conj default-columns :collection_id :collection_position :creator_id bookmark-col [:collection.name :collection_name] [:collection.authority_level :collection_authority_level])) (defmethod columns-for-model "database" [_] - [:id :name :description :updated_at :initial_sync_status]) + [:id :name :description :created_at :updated_at :initial_sync_status]) (defmethod columns-for-model "collection" [_] @@ -194,16 +308,17 @@ (defmethod columns-for-model "segment" [_] - (into default-columns table-columns)) + (concat default-columns table-columns [:creator_id])) (defmethod columns-for-model "metric" [_] - (into default-columns table-columns)) + (concat default-columns table-columns [:creator_id])) (defmethod columns-for-model "table" [_] [:id :name + :created_at :display_name :description :updated_at @@ -222,3 +337,10 @@ (defmethod column->string :default [value _ _] value) + +(defmethod column->string [:card :dataset_query] + [value _ _] + (let [query (json/parse-string value true)] + (if (= "native" (:type query)) + (-> query :native :query) + ""))) diff --git a/src/metabase/search/filter.clj b/src/metabase/search/filter.clj new file mode 100644 index 0000000000000000000000000000000000000000..1fb2301656ae49f31314b12961a980a3b3e2ed39 --- /dev/null +++ b/src/metabase/search/filter.clj @@ -0,0 +1,308 @@ +(ns metabase.search.filter + "Namespace that defines the filters that are applied to the search results. + + There are required filters and optional filters. + Archived is an required filters and is always applied, the reason because by default we want to hide archived/inactive entities. + + But there are OPTIONAL FILTERS like :created-by, :created-at, when these filters are provided, the results will return only + results of models that have these filters. + + The multi method for optional filters should have the default implementation to throw for unsupported models, and then each model + that supports the filter should define its own method for the filter." + (:require + [clojure.set :as set] + [clojure.string :as str] + [honey.sql.helpers :as sql.helpers] + [metabase.driver.common.parameters.dates :as params.dates] + [metabase.public-settings.premium-features :as premium-features] + [metabase.search.config :as search.config :refer [SearchableModel SearchContext]] + [metabase.search.util :as search.util] + [metabase.util.date-2 :as u.date] + [metabase.util.i18n :refer [tru]] + [metabase.util.malli :as mu]) + (:import + (java.time LocalDate))) + + +(def ^:private true-clause [:inline [:= 1 1]]) +(def ^:private false-clause [:inline [:= 0 1]]) + +;; ------------------------------------------------------------------------------------------------;; +;; Required Filters ; +;; ------------------------------------------------------------------------------------------------;; + +(defmulti ^:private archived-clause + "Clause to filter by the archived status of the entity." + {:arglists '([model archived?])} + (fn [model _] model)) + +(defmethod archived-clause :default + [model archived?] + [:= (search.config/column-with-model-alias model :archived) archived?]) + +;; Databases can't be archived +(defmethod archived-clause "database" + [_model archived?] + (if archived? + false-clause + true-clause)) + +(defmethod archived-clause "indexed-entity" + [_model archived?] + (if-not archived? + true-clause + false-clause)) + +;; Table has an `:active` flag, but no `:archived` flag; never return inactive Tables +(defmethod archived-clause "table" + [model archived?] + (if archived? + false-clause ; No tables should appear in archive searches + [:and + [:= (search.config/column-with-model-alias model :active) true] + [:= (search.config/column-with-model-alias model :visibility_type) nil]])) + +(mu/defn ^:private search-string-clause-for-model + [model :- SearchableModel + search-context :- SearchContext + search-native-query? :- [:maybe :boolean]] + (when-let [query (:search-string search-context)] + (into + [:or] + (for [column (cond->> (search.config/searchable-columns-for-model model) + (not search-native-query?) + (remove #{:dataset_query}) + + true + (map #(search.config/column-with-model-alias model %))) + wildcarded-token (->> (search.util/normalize query) + search.util/tokenize + (map search.util/wildcard-match))] + (cond + (and (= model "indexed-entity") (premium-features/sandboxed-or-impersonated-user?)) + [:= 0 1] + + (and (#{"card" "dataset"} model) (= column (search.config/column-with-model-alias model :dataset_query))) + [:and + [:= (search.config/column-with-model-alias model :query_type) "native"] + [:like [:lower column] wildcarded-token]] + + (and (#{"action"} model) + (= column (search.config/column-with-model-alias model :dataset_query))) + [:like [:lower :query_action.dataset_query] wildcarded-token] + + :else + [:like [:lower column] wildcarded-token]))))) + +;; ------------------------------------------------------------------------------------------------;; +;; Optional filters ;; +;; ------------------------------------------------------------------------------------------------;; + +(defmulti ^:private build-optional-filter-query + "Build the query to filter by `filter`. + Dispath with an array of [filter model-name]." + {:arglists '([model fitler query filter-value])} + (fn [filter model _query _filter-value] + [filter model])) + +(defmethod build-optional-filter-query :default + [filter model _query _creator-id] + (throw (ex-info (format "%s filter for %s is not supported" filter model) {:filter filter :model model}))) + +;; Created by filters +(defn- default-created-by-fitler-clause + [model creator-ids] + (if (= 1 (count creator-ids)) + [:= (search.config/column-with-model-alias model :creator_id) (first creator-ids)] + [:in (search.config/column-with-model-alias model :creator_id) creator-ids])) + +(doseq [model ["card" "dataset" "dashboard" "action"]] + (defmethod build-optional-filter-query [:created-by model] + [_filter model query creator-ids] + (sql.helpers/where query (default-created-by-fitler-clause model creator-ids)))) + +;; Verified filters + +(defmethod build-optional-filter-query [:verified "card"] + [_filter model query verified] + (assert (true? verified) "filter for non-verified cards is not supported") + (if (premium-features/has-feature? :content-verification) + (-> query + (sql.helpers/join :moderation_review + [:= :moderation_review.moderated_item_id + (search.config/column-with-model-alias model :id)]) + (sql.helpers/where [:= :moderation_review.status "verified"] + [:= :moderation_review.moderated_item_type "card"] + [:= :moderation_review.most_recent true])) + (sql.helpers/where query false-clause))) + +(defmethod build-optional-filter-query [:verified "dataset"] + [filter _model query verified] + (build-optional-filter-query filter "card" query verified)) + +;; Created at filters + +(defn- date-range-filter-clause + [dt-col dt-val] + (let [date-range (try + (params.dates/date-string->range dt-val {:inclusive-end? false}) + (catch Exception _e + (throw (ex-info (tru "Failed to parse datetime value: {0}" dt-val) {:status-code 400})))) + start (some-> (:start date-range) u.date/parse) + end (some-> (:end date-range) u.date/parse) + dt-col (if (some #(instance? LocalDate %) [start end]) + [:cast dt-col :date] + dt-col)] + (cond + (= start end) + [:= dt-col start] + + (nil? start) + [:< dt-col end] + + (nil? end) + [:> dt-col start] + + :else + [:and [:>= dt-col start] [:< dt-col end]]))) + +(doseq [model ["collection" "database" "table" "dashboard" "card" "dataset" "action"]] + (defmethod build-optional-filter-query [:created-at model] + [_filter model query created-at] + (sql.helpers/where query (date-range-filter-clause + (search.config/column-with-model-alias model :created_at) + created-at)))) + +;; Last edited by filter + +(defn- joined-with-table? + "Check if the query have a join with `table`. + Note: this does a very shallow check by only checking if the join-clause is the same. + Using the same table with a different alias will return false. + + (-> (sql.helpers/select :*) + (sql.helpers/from [:a]) + (sql.helpers/join :b [:= :a.id :b.id]) + (joined-with-table? :join :b)) + + ;; => true" + [query join-type table] + (->> (get query join-type) (partition 2) (map first) (some #(= % table)) boolean)) + +(doseq [model ["dashboard" "card" "dataset" "metric"]] + (defmethod build-optional-filter-query [:last-edited-by model] + [_filter model query editor-ids] + (cond-> query + ;; both last-edited-by and last-edited-at join with revision, so we should be careful not to join twice + (not (joined-with-table? query :join :revision)) + (-> (sql.helpers/join :revision [:= :revision.model_id (search.config/column-with-model-alias model :id)]) + (sql.helpers/where [:= :revision.most_recent true] + [:= :revision.model (search.config/search-model->revision-model model)])) + (= 1 (count editor-ids)) + (sql.helpers/where [:= :revision.user_id (first editor-ids)]) + + (> (count editor-ids) 1) + (sql.helpers/where [:in :revision.user_id editor-ids])))) + +(doseq [model ["dashboard" "card" "dataset" "metric"]] + (defmethod build-optional-filter-query [:last-edited-at model] + [_filter model query last-edited-at] + (cond-> query + ;; both last-edited-by and last-edited-at join with revision, so we should be careful not to join twice + (not (joined-with-table? query :join :revision)) + (-> (sql.helpers/join :revision [:= :revision.model_id (search.config/column-with-model-alias model :id)]) + (sql.helpers/where [:= :revision.most_recent true] + [:= :revision.model (search.config/search-model->revision-model model)])) + true + ;; on UI we showed the the last edit info from revision.timestamp + ;; not the model.updated_at column + ;; to be consistent we use revision.timestamp to do the filtering + (sql.helpers/where (date-range-filter-clause :revision.timestamp last-edited-at))))) + +;; TODO: once we record revision for actions, we should update this to use the same approach with dashboard/card +(defmethod build-optional-filter-query [:last-edited-at "action"] + [_filter model query last-edited-at] + (sql.helpers/where query (date-range-filter-clause + (search.config/column-with-model-alias model :updated_at) + last-edited-at))) + +(defn- feature->supported-models + "Return A map of filter to its support models. + + E.g: {:created-by #{\"card\" \"dataset\" \"dashboard\" \"action\"}} + + This is function instead of a def so that optional-filter-clause can be defined anywhere in the codebase." + [] + (merge + ;; models support search-native-query if dataset_query is one of the searchable columns + {:search-native-query (->> (dissoc (methods search.config/searchable-columns-for-model) :default) + (filter (fn [[k v]] + (contains? (set (v k)) :dataset_query))) + (map first) + set)} + (->> (dissoc (methods build-optional-filter-query) :default) + keys + (reduce (fn [acc [filter model]] + (update acc filter set/union #{model})) + {})))) + +;; ------------------------------------------------------------------------------------------------;; +;; Public functions ;; +;; ------------------------------------------------------------------------------------------------;; + +(mu/defn search-context->applicable-models :- [:set SearchableModel] + "Returns a set of models that are applicable given the search context. + + If the context has optional filters, the models will be restricted for the set of supported models only." + [search-context :- SearchContext] + (let [{:keys [created-at + created-by + last-edited-at + last-edited-by + models + search-native-query + verified]} search-context + feature->supported-models (feature->supported-models)] + (cond-> models + (some? created-at) (set/intersection (:created-at feature->supported-models)) + (some? created-by) (set/intersection (:created-by feature->supported-models)) + (some? last-edited-at) (set/intersection (:last-edited-at feature->supported-models)) + (some? last-edited-by) (set/intersection (:last-edited-by feature->supported-models)) + (true? search-native-query) (set/intersection (:search-native-query feature->supported-models)) + (true? verified) (set/intersection (:verified feature->supported-models))))) + +(mu/defn build-filters :- map? + "Build the search filters for a model." + [honeysql-query :- :map + model :- SearchableModel + search-context :- SearchContext] + (let [{:keys [archived? + created-at + created-by + last-edited-at + last-edited-by + search-string + search-native-query + verified]} search-context] + (cond-> honeysql-query + (not (str/blank? search-string)) + (sql.helpers/where (search-string-clause-for-model model search-context search-native-query)) + + (some? archived?) + (sql.helpers/where (archived-clause model archived?)) + + ;; build optional filters + (some? created-at) + (#(build-optional-filter-query :created-at model % created-at)) + + (some? created-by) + (#(build-optional-filter-query :created-by model % created-by)) + + (some? last-edited-at) + (#(build-optional-filter-query :last-edited-at model % last-edited-at)) + + (some? last-edited-by) + (#(build-optional-filter-query :last-edited-by model % last-edited-by)) + + (some? verified) + (#(build-optional-filter-query :verified model % verified))))) diff --git a/src/metabase/search/scoring.clj b/src/metabase/search/scoring.clj index 3b7d662320b6ea565fcc75d90e9ff01273e4a035..ec6acc618d220133130cf62b7fa7c378a0aa56b8 100644 --- a/src/metabase/search/scoring.clj +++ b/src/metabase/search/scoring.clj @@ -111,11 +111,13 @@ Some of the scorers can be tweaked with configuration in [[metabase.search.config]]." (:require + [cheshire.core :as json] [clojure.string :as str] [java-time.api :as t] + [metabase.mbql.normalize :as mbql.normalize] [metabase.public-settings.premium-features :refer [defenterprise]] - [metabase.search.config :as search-config] - [metabase.search.util :as search-util] + [metabase.search.config :as search.config] + [metabase.search.util :as search.util] [metabase.util :as u])) (defn- matches? @@ -129,7 +131,7 @@ (defn- tokens->string [tokens abbreviate?] (let [->string (partial str/join " ") - context search-config/surrounding-match-context] + context search.config/surrounding-match-context] (if (or (not abbreviate?) (<= (count tokens) (* 2 context))) (->string tokens) @@ -158,13 +160,13 @@ the text match, if there is one. If there is no match, the score is 0." [weighted-scorers query-tokens search-result] ;; TODO is pmap over search-result worth it? - (let [scores (for [column (search-config/searchable-columns-for-model (:model search-result)) + (let [scores (for [column (search.config/searchable-columns-for-model (:model search-result)) {:keys [scorer name weight] :as _ws} weighted-scorers :let [matched-text (-> search-result (get column) - (search-config/column->string (:model search-result) column)) - match-tokens (some-> matched-text search-util/normalize search-util/tokenize) + (search.config/column->string (:model search-result) column)) + match-tokens (some-> matched-text search.util/normalize search.util/tokenize) raw-score (scorer query-tokens match-tokens)] :when (and matched-text (pos? raw-score))] {:score raw-score @@ -179,7 +181,7 @@ (defn- consecutivity-scorer [query-tokens match-tokens] - (/ (search-util/largest-common-subseq-length + (/ (search.util/largest-common-subseq-length matches? ;; See comment on largest-common-subseq-length re. its cache. This is a little conservative, but better to under- than over-estimate (take 30 query-tokens) @@ -248,7 +250,7 @@ {:scorer prefix-scorer :name "prefix" :weight 1}]) (def ^:private model->sort-position - (zipmap (reverse search-config/all-models) (range))) + (zipmap (reverse search.config/models-search-order) (range))) (defn- model-score [{:keys [model]}] @@ -259,7 +261,7 @@ [raw-search-string result] (if (seq raw-search-string) (text-scores-with match-based-scorers - (search-util/tokenize (search-util/normalize raw-search-string)) + (search.util/tokenize (search.util/normalize raw-search-string)) result) [{:score 0 :weight 0}])) @@ -283,13 +285,13 @@ [{:keys [model dashboardcard_count]}] (if (= model "card") (min (/ dashboardcard_count - search-config/dashboard-count-ceiling) + search.config/dashboard-count-ceiling) 1) 0)) (defn- recency-score [{:keys [updated_at]}] - (let [stale-time search-config/stale-time-in-days + (let [stale-time search.config/stale-time-in-days days-ago (if updated_at (t/time-between updated_at (t/offset-date-time) @@ -312,12 +314,13 @@ name) :context (when (and match-context-thunk (empty? - (remove matching-columns search-config/displayed-columns))) + (remove matching-columns search.config/displayed-columns))) (match-context-thunk)) :collection {:id collection_id :name collection_name :authority_level collection_authority_level} :scores all-scores) + (update :dataset_query #(some-> % json/parse-string mbql.normalize/normalize)) (dissoc :collection_id :collection_name diff --git a/src/metabase/search/util.clj b/src/metabase/search/util.clj index 235772c7da474a2f6f436474945c564a606bd6a5..a1954f0d8b8e721137ad278b7990ce8d95b447fe 100644 --- a/src/metabase/search/util.clj +++ b/src/metabase/search/util.clj @@ -5,6 +5,11 @@ [metabase.util :as u] [metabase.util.malli :as mu])) +(defn wildcard-match + "Returns a string pattern to match a wildcard search term." + [s] + (str "%" s "%")) + (mu/defn normalize :- :string "Normalize a `query` to lower-case." [query :- :string] diff --git a/src/metabase/upload.clj b/src/metabase/upload.clj index 8cc18a0a12bac0738cb3741805dca1edd9a168b7..0875c66385826cf0a9fb365bc8edec971389e938 100644 --- a/src/metabase/upload.clj +++ b/src/metabase/upload.clj @@ -11,7 +11,7 @@ [metabase.driver :as driver] [metabase.mbql.util :as mbql.u] [metabase.public-settings :as public-settings] - [metabase.search.util :as search-util] + [metabase.search.util :as search.util] [metabase.util :as u] [metabase.util.i18n :refer [tru]]) (:import @@ -154,7 +154,7 @@ (defn- row->types [row] - (map (comp value->type search-util/normalize) row)) + (map (comp value->type search.util/normalize) row)) (defn- lowest-common-member [[x & xs :as all-xs] ys] (cond diff --git a/src/metabase/util/date_2.clj b/src/metabase/util/date_2.clj index 3ccd6329a8bd595fdd265932e952011062b8339e..23b35bc8ca1c22cf867b8267b1980f7e55556123 100644 --- a/src/metabase/util/date_2.clj +++ b/src/metabase/util/date_2.clj @@ -162,7 +162,8 @@ (some-> t class .getCanonicalName)) {:t t})))))) -(def ^:private add-units +(def add-units + "A list of units that can be added to a temporal value." #{:millisecond :second :minute :hour :day :week :month :quarter :year}) (s/defn add :- Temporal diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 4efaee46f513e1ee03cfb0a70aa2f54a3b0ce1c9..4f40d4714356de7ca919c9c0015b9ecd30a6e7d1 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -39,10 +39,8 @@ [metabase.models.moderation-review :as moderation-review] [metabase.models.permissions :as perms] [metabase.models.permissions-group :as perms-group] - [metabase.models.revision :as revision :refer [Revision]] - [metabase.models.user :refer [User]] - [metabase.public-settings.premium-features-test - :as premium-features-test] + [metabase.models.revision :as revision] + [metabase.public-settings.premium-features-test :as premium-features-test] [metabase.query-processor :as qp] [metabase.query-processor.async :as qp.async] [metabase.query-processor.card :as qp.card] @@ -398,6 +396,41 @@ (into #{} (map :name) (mt/user-http-request :rasta :get 200 "card" :f :using_model :model_id model-id)))))))) +(deftest get-cards-with-last-edit-info-test + (mt/with-temp [:model/Card {card-1-id :id} {:name "Card 1"} + :model/Card {card-2-id :id} {:name "Card 2"}] + (with-cards-in-readable-collection [card-1-id card-2-id] + (doseq [user-id [(mt/user->id :rasta) (mt/user->id :crowberto)]] + (revision/push-revision! + :entity :model/Card + :id card-1-id + :user-id user-id + :is_creation true + :object {:id card-1-id})) + + (doseq [user-id [(mt/user->id :crowberto) (mt/user->id :rasta)]] + (revision/push-revision! + :entity :model/Card + :id card-2-id + :user-id user-id + :is_creation true + :object {:id card-2-id})) + (let [results (m/index-by :id (mt/user-http-request :rasta :get 200 "card"))] + (is (=? {:name "Card 1" + :last-edit-info {:id (mt/user->id :rasta) + :email "rasta@metabase.com" + :first_name "Rasta" + :last_name "Toucan" + :timestamp some?}} + (get results card-1-id))) + (is (=? {:name "Card 2" + :last-edit-info {:id (mt/user->id :crowberto) + :email "crowberto@metabase.com" + :first_name "Crowberto" + :last_name "Corv" + :timestamp some?}} + (get results card-2-id))))))) + (deftest get-series-for-card-permission-test (t2.with-temp/with-temp [:model/Card {card-id :id} {:name "Card" @@ -1062,11 +1095,11 @@ :metabase_version config/mb-version-string}) (mt/user-http-request :rasta :get 200 (str "card/" (u/the-id card)))))) (testing "Card should include last edit info if available" - (mt/with-temp [User {user-id :id} {:first_name "Test" :last_name "User" :email "user@test.com"} - Revision _ {:model "Card" - :model_id (:id card) - :user_id user-id - :object (revision/serialize-instance card (:id card) card)}] + (mt/with-temp [:model/User {user-id :id} {:first_name "Test" :last_name "User" :email "user@test.com"} + :model/Revision _ {:model "Card" + :model_id (:id card) + :user_id user-id + :object (revision/serialize-instance card (:id card) card)}] (is (= {:id true :email "user@test.com" :first_name "Test" :last_name "User" :timestamp true} (-> (mt/user-http-request :rasta :get 200 (str "card/" (u/the-id card))) mt/boolean-ids-and-timestamps diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index bc29fa1384fe9a813c527e2eeefe4f2e15b96e9d..469efabe19c725ec2bd1ef4fbcc69f1c4cff7675 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -30,6 +30,7 @@ Revision Table User]] + [metabase.models.collection :as collection] [metabase.models.dashboard :as dashboard] [metabase.models.dashboard-card :as dashboard-card] [metabase.models.dashboard-test :as dashboard-test] @@ -215,7 +216,7 @@ (t2.with-temp/with-temp [Collection collection] (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) (let [test-dashboard-name "Test Create Dashboard"] - (try + (mt/with-model-cleanup [:model/Dashboard] (is (= (merge dashboard-defaults {:name test-dashboard-name @@ -232,36 +233,85 @@ :parameters [{:id "abc123", :name "test", :type "date"}] :cache_ttl 1234 :collection_id (u/the-id collection)}) - dashboard-response))) - (finally - (t2/delete! Dashboard :name test-dashboard-name)))))))) + dashboard-response))))))))) (deftest create-dashboard-with-collection-position-test (testing "POST /api/dashboard" (testing "Make sure we can create a Dashboard with a Collection position" (mt/with-non-admin-groups-no-root-collection-perms (t2.with-temp/with-temp [Collection collection] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (let [dashboard-name (mt/random-name)] - (try + (mt/with-model-cleanup [:model/Dashboard] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (let [dashboard-name (mt/random-name)] (mt/user-http-request :rasta :post 200 "dashboard" {:name dashboard-name :collection_id (u/the-id collection) :collection_position 1000}) (is (=? {:collection_id true, :collection_position 1000} (some-> (t2/select-one [Dashboard :collection_id :collection_position] :name dashboard-name) - (update :collection_id (partial = (u/the-id collection)))))) - (finally - (t2/delete! Dashboard :name dashboard-name))))) + (update :collection_id (partial = (u/the-id collection)))))))) - (testing "..but not if we don't have permissions for the Collection" - (t2.with-temp/with-temp [Collection collection] - (let [dashboard-name (mt/random-name)] - (mt/user-http-request :rasta :post 403 "dashboard" {:name dashboard-name - :collection_id (u/the-id collection) - :collection_position 1000}) - (is (= nil - (some-> (t2/select-one [Dashboard :collection_id :collection_position] :name dashboard-name) - (update :collection_id (partial = (u/the-id collection))))))))))))) + (testing "..but not if we don't have permissions for the Collection" + (t2.with-temp/with-temp [Collection collection] + (let [dashboard-name (mt/random-name)] + (mt/user-http-request :rasta :post 403 "dashboard" {:name dashboard-name + :collection_id (u/the-id collection) + :collection_position 1000}) + (is (not (t2/select-one [Dashboard :collection_id :collection_position] :name dashboard-name))))))))))) + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | GET /api/dashboard/ | +;;; +----------------------------------------------------------------------------------------------------------------+ + +(deftest get-dashboards-test + (mt/with-temp + [:model/Dashboard {rasta-dash :id} {:creator_id (mt/user->id :rasta)} + :model/Dashboard {crowberto-dash :id} {:creator_id (mt/user->id :crowberto) + :collection_id (:id (collection/user->personal-collection (mt/user->id :crowberto)))} + :model/Dashboard {archived-dash :id} {:archived true + :collection_id (:id (collection/user->personal-collection (mt/user->id :crowberto))) + :creator_id (mt/user->id :crowberto)}] + + (testing "should include creator info and last edited info" + (revision/push-revision! + :entity :model/Dashboard + :id crowberto-dash + :user-id (mt/user->id :crowberto) + :is_creation true + :object {:id crowberto-dash}) + (is (=? (merge (t2/select-one :model/Dashboard crowberto-dash) + {:creator {:id (mt/user->id :crowberto) + :email "crowberto@metabase.com" + :first_name "Crowberto" + :last_name "Corv" + :common_name "Crowberto Corv"}} + {:last-edit-info {:id (mt/user->id :crowberto) + :first_name "Crowberto" + :last_name "Corv" + :email "crowberto@metabase.com" + :timestamp true}}) + (-> (mt/user-http-request :crowberto :get 200 "dashboard" :f "mine") + first + (update-in [:last-edit-info :timestamp] boolean))))) + + (testing "f=all shouldn't return archived dashboards" + (is (= #{rasta-dash crowberto-dash} + (set (map :id (mt/user-http-request :crowberto :get 200 "dashboard" :f "all"))))) + + (testing "and should respect read perms" + (is (= #{rasta-dash} + (set (map :id (mt/user-http-request :rasta :get 200 "dashboard" :f "all"))))))) + + (testing "f=archvied return archived dashboards" + (is (= #{archived-dash} + (set (map :id (mt/user-http-request :crowberto :get 200 "dashboard" :f "archived"))))) + + (testing "and should return read perms" + (is (= #{} + (set (map :id (mt/user-http-request :rasta :get 200 "dashboard" :f "archived"))))))) + + (testing "f=mine return dashboards created by caller but do not include archived" + (is (= #{crowberto-dash} + (set (map :id (mt/user-http-request :crowberto :get 200 "dashboard" :f "mine")))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | GET /api/dashboard/:id | @@ -819,15 +869,15 @@ (testing "Check that adding a new Dashboard at Collection position 3 will increment position of the existing item at position 3" (mt/with-non-admin-groups-no-root-collection-perms (t2.with-temp/with-temp [Collection collection] - (api.card-test/with-ordered-items collection [Card a - Pulse b - Card d] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (is (= {"a" 1 - "b" 2 - "d" 3} - (api.card-test/get-name->collection-position :rasta collection))) - (try + (mt/with-model-cleanup [:model/Dashboard] + (api.card-test/with-ordered-items collection [Card a + Pulse b + Card d] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (is (= {"a" 1 + "b" 2 + "d" 3} + (api.card-test/get-name->collection-position :rasta collection))) (mt/user-http-request :rasta :post 200 "dashboard" {:name "c" :collection_id (u/the-id collection) :collection_position 3}) @@ -835,9 +885,7 @@ "b" 2 "c" 3 "d" 4} - (api.card-test/get-name->collection-position :rasta collection))) - (finally - (t2/delete! Dashboard :collection_id (u/the-id collection)))))))))) + (api.card-test/get-name->collection-position :rasta collection)))))))))) (deftest insert-dashboard-no-position-test (testing "POST /api/dashboard" @@ -852,17 +900,14 @@ "b" 2 "d" 3} (api.card-test/get-name->collection-position :rasta collection))) - (try + (mt/with-model-cleanup [:model/Dashboard] (mt/user-http-request :rasta :post 200 "dashboard" {:name "c" :collection_id (u/the-id collection)}) (is (= {"a" 1 "b" 2 "c" nil "d" 3} - (api.card-test/get-name->collection-position :rasta collection))) - (finally - (t2/delete! Dashboard :collection_id (u/the-id collection)))))))))) - + (api.card-test/get-name->collection-position :rasta collection)))))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | DELETE /api/dashboard/:id | @@ -881,13 +926,13 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ (deftest copy-dashboard-test - (testing "POST /api/dashboard/:id/copy" - (testing "A plain copy with nothing special" - (t2.with-temp/with-temp [Dashboard dashboard {:name "Test Dashboard" - :description "A description" - :creator_id (mt/user->id :rasta)}] - (let [response (mt/user-http-request :rasta :post 200 (format "dashboard/%d/copy" (:id dashboard)))] - (try + (mt/with-model-cleanup [:model/Dashboard] + (testing "POST /api/dashboard/:id/copy" + (testing "A plain copy with nothing special" + (t2.with-temp/with-temp [Dashboard dashboard {:name "Test Dashboard" + :description "A description" + :creator_id (mt/user->id :rasta)}] + (let [response (mt/user-http-request :rasta :post 200 (format "dashboard/%d/copy" (:id dashboard)))] (is (= (merge dashboard-defaults {:name "Test Dashboard" @@ -898,19 +943,18 @@ (dashboard-response response))) (is (some? (:entity_id response))) (is (not= (:entity_id dashboard) (:entity_id response)) - "The copy should have a new entity ID generated") - (finally - (t2/delete! Dashboard :id (u/the-id response))))))))) + "The copy should have a new entity ID generated"))))))) + (deftest copy-dashboard-test-2 - (testing "POST /api/dashboard/:id/copy" - (testing "Ensure name / description / user set when copying" - (t2.with-temp/with-temp [Dashboard dashboard {:name "Test Dashboard" - :description "An old description"}] - (let [response (mt/user-http-request :crowberto :post 200 (format "dashboard/%d/copy" (:id dashboard)) - {:name "Test Dashboard - Duplicate" - :description "A new description"})] - (try + (mt/with-model-cleanup [:model/Dashboard] + (testing "POST /api/dashboard/:id/copy" + (testing "Ensure name / description / user set when copying" + (t2.with-temp/with-temp [Dashboard dashboard {:name "Test Dashboard" + :description "An old description"}] + (let [response (mt/user-http-request :crowberto :post 200 (format "dashboard/%d/copy" (:id dashboard)) + {:name "Test Dashboard - Duplicate" + :description "A new description"})] (is (= (merge dashboard-defaults {:name "Test Dashboard - Duplicate" @@ -921,68 +965,68 @@ (dashboard-response response))) (is (some? (:entity_id response))) (is (not= (:entity_id dashboard) (:entity_id response)) - "The copy should have a new entity ID generated") - (finally - (t2/delete! Dashboard :id (u/the-id response))))))))) + "The copy should have a new entity ID generated"))))))) + (deftest copy-dashboard-test-3 (testing "Deep copy: POST /api/dashboard/:id/copy" (mt/dataset sample-dataset - (mt/with-temp [Collection source-coll {:name "Source collection"} - Collection dest-coll {:name "Destination collection"} - Dashboard dashboard {:name "Dashboard to be Copied" - :description "A description" - :collection_id (u/the-id source-coll) - :creator_id (mt/user->id :rasta)} - Card total-card {:name "Total orders per month" - :collection_id (u/the-id source-coll) - :display :line - :visualization_settings - {:graph.dimensions ["CREATED_AT"] - :graph.metrics ["sum"]} - :dataset_query - (mt/$ids - {:database (mt/id) - :type :query - :query {:source-table $$orders - :aggregation [[:sum $orders.total]] - :breakout [!month.orders.created_at]}})} - Card avg-card {:name "Average orders per month" - :collection_id (u/the-id source-coll) - :display :line - :visualization_settings - {:graph.dimensions ["CREATED_AT"] - :graph.metrics ["sum"]} - :dataset_query - (mt/$ids - {:database (mt/id) - :type :query - :query {:source-table $$orders - :aggregation [[:avg $orders.total]] - :breakout [!month.orders.created_at]}})} - Card model {:name "A model" - :collection_id (u/the-id source-coll) - :dataset true - :dataset_query - (mt/$ids - {:database (mt/id) - :type :query - :query {:source-table $$orders - :limit 4}})} - DashboardCard dashcard {:dashboard_id (u/the-id dashboard) - :card_id (u/the-id total-card) - :size_x 6, :size_y 6} - DashboardCard _textcard {:dashboard_id (u/the-id dashboard) - :visualization_settings - {:virtual_card - {:display :text} - :text "here is some text"}} - DashboardCard _ {:dashboard_id (u/the-id dashboard) - :card_id (u/the-id model) - :size_x 6, :size_y 6} - DashboardCardSeries _ {:dashboardcard_id (u/the-id dashcard) - :card_id (u/the-id avg-card) - :position 0}] + (mt/with-temp + [Collection source-coll {:name "Source collection"} + Collection dest-coll {:name "Destination collection"} + Dashboard dashboard {:name "Dashboard to be Copied" + :description "A description" + :collection_id (u/the-id source-coll) + :creator_id (mt/user->id :rasta)} + Card total-card {:name "Total orders per month" + :collection_id (u/the-id source-coll) + :display :line + :visualization_settings + {:graph.dimensions ["CREATED_AT"] + :graph.metrics ["sum"]} + :dataset_query + (mt/$ids + {:database (mt/id) + :type :query + :query {:source-table $$orders + :aggregation [[:sum $orders.total]] + :breakout [!month.orders.created_at]}})} + Card avg-card {:name "Average orders per month" + :collection_id (u/the-id source-coll) + :display :line + :visualization_settings + {:graph.dimensions ["CREATED_AT"] + :graph.metrics ["sum"]} + :dataset_query + (mt/$ids + {:database (mt/id) + :type :query + :query {:source-table $$orders + :aggregation [[:avg $orders.total]] + :breakout [!month.orders.created_at]}})} + Card model {:name "A model" + :collection_id (u/the-id source-coll) + :dataset true + :dataset_query + (mt/$ids + {:database (mt/id) + :type :query + :query {:source-table $$orders + :limit 4}})} + DashboardCard dashcard {:dashboard_id (u/the-id dashboard) + :card_id (u/the-id total-card) + :size_x 6, :size_y 6} + DashboardCard _textcard {:dashboard_id (u/the-id dashboard) + :visualization_settings + {:virtual_card + {:display :text} + :text "here is some text"}} + DashboardCard _ {:dashboard_id (u/the-id dashboard) + :card_id (u/the-id model) + :size_x 6, :size_y 6} + DashboardCardSeries _ {:dashboardcard_id (u/the-id dashcard) + :card_id (u/the-id avg-card) + :position 0}] (mt/with-model-cleanup [Card Dashboard DashboardCard DashboardCardSeries] (let [resp (mt/user-http-request :crowberto :post 200 (format "dashboard/%d/copy" (:id dashboard)) @@ -1170,27 +1214,27 @@ (testing "POST /api/dashboard/:id/copy" (testing "for a dashboard that has tabs" (with-simple-dashboard-with-tabs [{:keys [dashboard-id]}] - (let [new-dash-id (:id (mt/user-http-request :rasta :post 200 - (format "dashboard/%d/copy" dashboard-id) - {:name "New dashboard" - :description "A new description"})) - original-tabs (t2/select [:model/DashboardTab :id :position :name] - :dashboard_id dashboard-id - {:order-by [[:position :asc]]}) - new-tabs (t2/select [:model/DashboardTab :id :position :name] - :dashboard_id new-dash-id - {:order-by [[:position :asc]]}) - new->old-tab-id (zipmap (map :id new-tabs) (map :id original-tabs))] - (testing "Cards are located correctly between tabs" - (is (= (map #(select-keys % [:dashboard_tab_id :card_id :row :col :size_x :size_y :dashboard_tab_id]) - (dashcards-by-position dashboard-id)) - (map #(select-keys % [:dashboard_tab_id :card_id :row :col :size_x :size_y :dashboard_tab_id]) - (for [card (dashcards-by-position new-dash-id)] - (assoc card :dashboard_tab_id (new->old-tab-id (:dashboard_tab_id card)))))))) - - (testing "new tabs should have the same name and position" - (is (= (map #(dissoc % :id) original-tabs) - (map #(dissoc % :id) new-tabs))))))))) + (mt/with-model-cleanup [:model/Dashboard] + (let [new-dash-id (:id (mt/user-http-request :rasta :post 200 + (format "dashboard/%d/copy" dashboard-id) + {:name "New dashboard" + :description "A new description"})) + original-tabs (t2/select [:model/DashboardTab :id :position :name] + :dashboard_id dashboard-id + {:order-by [[:position :asc]]}) + new-tabs (t2/select [:model/DashboardTab :id :position :name] + :dashboard_id new-dash-id + {:order-by [[:position :asc]]}) + new->old-tab-id (zipmap (map :id new-tabs) (map :id original-tabs))] + (testing "Cards are located correctly between tabs" + (is (= (map #(select-keys % [:dashboard_tab_id :card_id :row :col :size_x :size_y :dashboard_tab_id]) + (dashcards-by-position dashboard-id)) + (map #(select-keys % [:dashboard_tab_id :card_id :row :col :size_x :size_y :dashboard_tab_id]) + (for [card (dashcards-by-position new-dash-id)] + (assoc card :dashboard_tab_id (new->old-tab-id (:dashboard_tab_id card)))))))) + (testing "new tabs should have the same name and position" + (is (= (map #(dissoc % :id) original-tabs) + (map #(dissoc % :id) new-tabs)))))))))) (def ^:dynamic ^:private ^{:doc "Set of ids that will report [[mi/can-write]] as true."} @@ -1214,7 +1258,7 @@ (deftest cards-to-copy-test (testing "Identifies all cards to be copied" (let [dashcards [{:card_id 1 :card (card-model {:id 1}) :series [(card-model {:id 2})]} - {:card_id 3 :card (card-model{:id 3})}]] + {:card_id 3 :card (card-model{:id 3})}]] (binding [*readable-card-ids* #{1 2 3}] (is (= {:copy {1 {:id 1} 2 {:id 2} 3 {:id 3}} :discard []} @@ -1222,14 +1266,14 @@ (testing "Identifies cards which cannot be copied" (testing "If they are in a series" (let [dashcards [{:card_id 1 :card (card-model {:id 1}) :series [(card-model {:id 2})]} - {:card_id 3 :card (card-model{:id 3})}]] + {:card_id 3 :card (card-model{:id 3})}]] (binding [*readable-card-ids* #{1 3}] (is (= {:copy {1 {:id 1} 3 {:id 3}} :discard [{:id 2}]} (#'api.dashboard/cards-to-copy dashcards)))))) (testing "When the base of a series lacks permissions" (let [dashcards [{:card_id 1 :card (card-model {:id 1}) :series [(card-model {:id 2})]} - {:card_id 3 :card (card-model{:id 3})}]] + {:card_id 3 :card (card-model{:id 3})}]] (binding [*readable-card-ids* #{3}] (is (= {:copy {3 {:id 3}} :discard [{:id 1} {:id 2}]} @@ -1238,7 +1282,7 @@ (deftest update-cards-for-copy-test (testing "When copy style is shallow returns original dashcards" (let [dashcards [{:card_id 1 :card {:id 1} :series [{:id 2}]} - {:card_id 3 :card {:id 3}}]] + {:card_id 3 :card {:id 3}}]] (is (= dashcards (api.dashboard/update-cards-for-copy 1 dashcards @@ -1267,7 +1311,7 @@ nil))))) (testing "Can omit whole card with series if not copied" (let [dashcards [{:card_id 1 :card {} :series [{:id 2} {:id 3}]} - {:card_id 4 :card {} :series [{:id 5} {:id 6}]}]] + {:card_id 4 :card {} :series [{:id 5} {:id 6}]}]] (is (= [{:card_id 7 :card {:id 7} :series [{:id 8} {:id 9}]}] (api.dashboard/update-cards-for-copy 1 dashcards @@ -2147,32 +2191,31 @@ :card_id 123 :series [8 9]}]} :message "updated"}] - (is (= [{:is_reversion false - :is_creation false - :message "updated" - :user (-> (user-details (mt/fetch-user :crowberto)) + (is (=? [{:is_reversion false + :is_creation false + :message "updated" + :user (-> (user-details (mt/fetch-user :crowberto)) + (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) + :metabase_version config/mb-version-string + :diff {:before {:name "b" + :description nil + :cards [{:series nil, :size_y 4, :size_x 4}]} + :after {:name "c" + :description "something" + :cards [{:series [8 9], :size_y 3, :size_x 5}]}} + :has_multiple_changes true + :description "added a description and renamed it from \"b\" to \"c\", modified the cards and added some series to card 123."} + {:is_reversion false + :is_creation true + :message nil + :user (-> (user-details (mt/fetch-user :rasta)) (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) - :metabase_version config/mb-version-string - :diff {:before {:name "b" - :description nil - :cards [{:series nil, :size_y 4, :size_x 4}]} - :after {:name "c" - :description "something" - :cards [{:series [8 9], :size_y 3, :size_x 5}]}} - :has_multiple_changes true - :description "added a description and renamed it from \"b\" to \"c\", modified the cards and added some series to card 123."} - {:is_reversion false - :is_creation true - :message nil - :metabase_version config/mb-version-string - :user (-> (user-details (mt/fetch-user :rasta)) - (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) - :diff nil - :has_multiple_changes false - :description "created this."}] - (doall (for [revision (mt/user-http-request :crowberto :get 200 (format "dashboard/%d/revisions" dashboard-id))] - (dissoc revision :timestamp :id)))))))) - + :metabase_version config/mb-version-string + :diff nil + :has_multiple_changes false + :description "created this."}] + (doall (for [revision (mt/user-http-request :crowberto :get 200 (format "dashboard/%d/revisions" dashboard-id))] + (dissoc revision :timestamp :id)))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | POST /api/dashboard/:id/revert | @@ -2199,22 +2242,7 @@ :description nil :cards []} :message "updated"}] - (is (= {:is_reversion true - :is_creation false - :message nil - :user (-> (user-details (mt/fetch-user :crowberto)) - (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) - :metabase_version config/mb-version-string - :diff {:before {:name "b"} - :after {:name "a"}} - :has_multiple_changes false - :description "reverted to an earlier version."} - (dissoc (mt/user-http-request :crowberto :post 200 (format "dashboard/%d/revert" dashboard-id) - {:revision_id revision-id}) - :id :timestamp))) - - (is (= [{:is_reversion true - :is_creation false + (is (=? {:is_reversion true :message nil :user (-> (user-details (mt/fetch-user :crowberto)) (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) @@ -2223,27 +2251,39 @@ :after {:name "a"}} :has_multiple_changes false :description "reverted to an earlier version."} - {:is_reversion false - :is_creation false - :message "updated" - :metabase_version config/mb-version-string - :user (-> (user-details (mt/fetch-user :crowberto)) - (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) - :diff {:before {:name "a"} - :after {:name "b"}} - :has_multiple_changes false - :description "renamed this Dashboard from \"a\" to \"b\"."} - {:is_reversion false - :is_creation true - :message nil - :metabase_version config/mb-version-string - :user (-> (user-details (mt/fetch-user :rasta)) - (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) - :diff nil - :has_multiple_changes false - :description "created this."}] - (doall (for [revision (mt/user-http-request :crowberto :get 200 (format "dashboard/%d/revisions" dashboard-id))] - (dissoc revision :timestamp :id)))))))) + (dissoc (mt/user-http-request :crowberto :post 200 (format "dashboard/%d/revert" dashboard-id) + {:revision_id revision-id}) + :id :timestamp))) + (is (=? [{:is_reversion true + :is_creation false + :message nil + :user (-> (user-details (mt/fetch-user :crowberto)) + (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) + :metabase_version config/mb-version-string + :diff {:before {:name "b"} + :after {:name "a"}} + :has_multiple_changes false + :description "reverted to an earlier version."} + {:is_reversion false + :is_creation false + :message "updated" + :user (-> (user-details (mt/fetch-user :crowberto)) + (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) + :metabase_version config/mb-version-string + :diff {:before {:name "a"} + :after {:name "b"}} + :has_multiple_changes false + :description "renamed this Dashboard from \"a\" to \"b\"."} + {:is_reversion false + :is_creation true + :message nil + :user (-> (user-details (mt/fetch-user :rasta)) + (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) + :diff nil + :has_multiple_changes false + :description "created this."}] + (doall (for [revision (mt/user-http-request :crowberto :get 200 (format "dashboard/%d/revisions" dashboard-id))] + (dissoc revision :timestamp :id)))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ diff --git a/test/metabase/api/revision_test.clj b/test/metabase/api/revision_test.clj index bb9fbaa60779d84f12e34abd9c765617177dc03f..4a185798794f274fb5b4645aa1f81c9186296646 100644 --- a/test/metabase/api/revision_test.clj +++ b/test/metabase/api/revision_test.clj @@ -61,15 +61,15 @@ (testing "Loading a single revision works" (t2.with-temp/with-temp [Card {:keys [id] :as card}] (create-card-revision! (:id card) true :rasta) - (is (= [{:is_reversion false - :is_creation true - :message nil - :user @rasta-revision-info - :metabase_version config/mb-version-string - :diff nil - :has_multiple_changes false - :description "created this."}] - (get-revisions :card id)))))) + (is (=? [{:is_reversion false + :is_creation true + :message nil + :user @rasta-revision-info + :metabase_version config/mb-version-string + :diff nil + :has_multiple_changes false + :description "created this."}] + (get-revisions :card id)))))) (deftest get-revision-for-entity-with-revision-exceeds-max-revision-test (t2.with-temp/with-temp [Card {:keys [id] :as card} {:name "A card"}] @@ -110,33 +110,33 @@ :message "because i wanted to" :is_creation false :is_reversion true) - (is (= [{:is_reversion true - :is_creation false - :message "because i wanted to" - :user @rasta-revision-info - :metabase_version config/mb-version-string - :diff {:before {:name "something else"} - :after {:name name}} - :description "reverted to an earlier version." - :has_multiple_changes false} - {:is_reversion false - :is_creation false - :message nil - :user @rasta-revision-info - :metabase_version config/mb-version-string - :diff {:before {:name name} - :after {:name "something else"}} - :description (format "renamed this Card from \"%s\" to \"something else\"." name) - :has_multiple_changes false} - {:is_reversion false - :is_creation true - :message nil - :metabase_version config/mb-version-string - :user @rasta-revision-info - :diff nil - :description "created this." - :has_multiple_changes false}] - (get-revisions :card id)))))) + (is (=? [{:is_reversion true + :is_creation false + :message "because i wanted to" + :user @rasta-revision-info + :metabase_version config/mb-version-string + :diff {:before {:name "something else"} + :after {:name name}} + :description "reverted to an earlier version." + :has_multiple_changes false} + {:is_reversion false + :is_creation false + :message nil + :user @rasta-revision-info + :metabase_version config/mb-version-string + :diff {:before {:name name} + :after {:name "something else"}} + :description (format "renamed this Card from \"%s\" to \"something else\"." name) + :has_multiple_changes false} + {:is_reversion false + :is_creation true + :message nil + :metabase_version config/mb-version-string + :user @rasta-revision-info + :diff nil + :description "created this." + :has_multiple_changes false}] + (get-revisions :card id)))))) ;;; # POST /revision/revert @@ -181,48 +181,47 @@ (mt/user-http-request :rasta :post 200 "revision/revert" {:entity :dashboard :id id :revision_id previous-revision-id}))))) - (is (= [{:is_reversion true - :is_creation false - :message nil - :user @rasta-revision-info - :metabase_version config/mb-version-string - :diff {:before {:cards nil} - :after {:cards [(merge default-revision-card {:card_id card-id :dashboard_id id})]}} - :has_multiple_changes false - :description "reverted to an earlier version."} - {:is_reversion false - :is_creation false - :message nil - :user @rasta-revision-info - :metabase_version config/mb-version-string - :diff {:before {:cards [(merge default-revision-card {:card_id card-id :dashboard_id id})]} - :after {:cards nil}} - :has_multiple_changes false - :description "removed a card."} - {:is_reversion false - :is_creation false - :message nil - :user @rasta-revision-info - :metabase_version config/mb-version-string - :diff {:before {:cards nil} - :after {:cards [(merge default-revision-card {:card_id card-id :dashboard_id id})]}} - :has_multiple_changes false - :description "added a card."} - {:is_reversion false - :is_creation true - :message nil - :user @rasta-revision-info - :metabase_version config/mb-version-string - :diff nil - :has_multiple_changes false - :description "created this."}] - (->> (get-revisions :dashboard id) - (mapv (fn [rev] - (if-not (:diff rev) - rev - (if (get-in rev [:diff :before :cards]) - (update-in rev [:diff :before :cards] strip-ids) - (update-in rev [:diff :after :cards] strip-ids))))))))))) + (is (=? [{:is_reversion true + :is_creation false + :message nil + :user @rasta-revision-info + :metabase_version config/mb-version-string + :diff {:before {:cards nil} + :after {:cards [(merge default-revision-card {:card_id card-id :dashboard_id id})]}} + :has_multiple_changes false + :description "reverted to an earlier version."} + {:is_reversion false + :is_creation false + :message nil + :user @rasta-revision-info + :metabase_version config/mb-version-string + :diff {:before {:cards [(merge default-revision-card {:card_id card-id :dashboard_id id})]} + :after {:cards nil}} + :has_multiple_changes false + :description "removed a card."} + {:is_reversion false + :is_creation false + :message nil + :user @rasta-revision-info + :diff {:before {:cards nil} + :after {:cards [(merge default-revision-card {:card_id card-id :dashboard_id id})]}} + :has_multiple_changes false + :description "added a card."} + {:is_reversion false + :is_creation true + :message nil + :user @rasta-revision-info + :metabase_version config/mb-version-string + :diff nil + :has_multiple_changes false + :description "created this."}] + (->> (get-revisions :dashboard id) + (mapv (fn [rev] + (if-not (:diff rev) + rev + (if (get-in rev [:diff :before :cards]) + (update-in rev [:diff :before :cards] strip-ids) + (update-in rev [:diff :after :cards] strip-ids))))))))))) (deftest permission-check-on-revert-test (testing "Are permissions enforced by the revert action in the revision api?" diff --git a/test/metabase/api/search_test.clj b/test/metabase/api/search_test.clj index de58acdb91129523d74a90a12d63fc38238ddacb..cdb105cbbea3854f5d51162b53a16b50217ad92d 100644 --- a/test/metabase/api/search_test.clj +++ b/test/metabase/api/search_test.clj @@ -3,19 +3,22 @@ [clojure.set :as set] [clojure.string :as str] [clojure.test :refer :all] + [java-time :as t] [metabase.analytics.snowplow-test :as snowplow-test] - [metabase.api.common :as api] [metabase.api.search :as api.search] [metabase.mbql.normalize :as mbql.normalize] [metabase.models :refer [Action Card CardBookmark Collection Dashboard DashboardBookmark DashboardCard Database Metric PermissionsGroup PermissionsGroupMembership Pulse PulseCard QueryAction Segment Table]] + [metabase.models.collection :as collection] [metabase.models.model-index :as model-index] [metabase.models.permissions :as perms] [metabase.models.permissions-group :as perms-group] + [metabase.models.revision :as revision] [metabase.public-settings.premium-features :as premium-features] - [metabase.search.config :as search-config] + [metabase.public-settings.premium-features-test :as premium-features-test] + [metabase.search.config :as search.config] [metabase.search.scoring :as scoring] [metabase.test :as mt] [metabase.util :as u] @@ -41,14 +44,21 @@ :collection_authority_level nil :collection_position nil :context nil + :created_at true + :creator_common_name nil + :creator_id false :dashboardcard_count nil :database_id false + :dataset_query nil :description nil :id true :initial_sync_status nil :model_id false :model_name nil :moderated_status nil + :last_editor_common_name nil + :last_editor_id false + :last_edited_at false :pk_ref nil :model_index_id false ;; columns ending in _id get booleaned :table_description nil @@ -64,7 +74,7 @@ (merge {:table_id true, :database_id true} (t2/select-one [Table [:name :table_name] [:schema :table_schema] [:description :table_description]] - :id (mt/id :checkins)))) + :id (mt/id :checkins)))) (defn- sorted-results [results] (->> results @@ -91,16 +101,17 @@ (defn- default-search-results [] (sorted-results - [(make-result "dashboard test dashboard", :model "dashboard", :bookmark false) + [(make-result "dashboard test dashboard", :model "dashboard", :bookmark false :creator_id true :creator_common_name "Rasta Toucan") test-collection - (make-result "card test card", :model "card", :bookmark false, :dashboardcard_count 0) - (make-result "dataset test dataset", :model "dataset", :bookmark false, :dashboardcard_count 0) - (make-result "action test action", :model "action", :model_name (:name action-model-params), :model_id true, :database_id true) + (make-result "card test card", :model "card", :bookmark false, :dashboardcard_count 0 :creator_id true :creator_common_name "Rasta Toucan" :dataset_query nil) + (make-result "dataset test dataset", :model "dataset", :bookmark false, :dashboardcard_count 0 :creator_id true :creator_common_name "Rasta Toucan" :dataset_query nil) + (make-result "action test action", :model "action", :model_name (:name action-model-params), :model_id true, + :database_id true :creator_id true :creator_common_name "Rasta Toucan" :dataset_query (update (mt/query venues) :type name)) (merge - (make-result "metric test metric", :model "metric", :description "Lookin' for a blueberry") + (make-result "metric test metric", :model "metric", :description "Lookin' for a blueberry" :creator_id true :creator_common_name "Rasta Toucan") (table-search-results)) (merge - (make-result "segment test segment", :model "segment", :description "Lookin' for a blueberry") + (make-result "segment test segment", :model "segment", :description "Lookin' for a blueberry" :creator_id true :creator_common_name "Rasta Toucan") (table-search-results))])) (defn- default-metric-segment-results [] @@ -136,6 +147,11 @@ Action {action-id :id :as action} (merge (data-map "action %s action") {:type :query, :model_id (u/the-id action-model)}) + Database {db-id :id + :as db} (data-map "database %s database") + Table table (merge (data-map "database %s database") + {:db_id db-id}) + QueryAction _qa (query-action action-id) Card card (coll-data-map "card %s card" coll) Card dataset (assoc (coll-data-map "dataset %s dataset" coll) @@ -146,9 +162,11 @@ (f {:action action :collection coll :card card + :database db :dataset dataset :dashboard dashboard :metric metric + :table table :segment segment})))) (defmacro ^:private with-search-items-in-root-collection [search-string & body] @@ -225,6 +243,7 @@ [:like [:lower :table_name] "%foo%"] [:inline 0] [:like [:lower :table_description] "%foo%"] [:inline 0] [:like [:lower :model_name] "%foo%"] [:inline 0] + [:like [:lower :dataset_query] "%foo%"] [:inline 0] :else [:inline 1]]] (api.search/order-clause "Foo"))))) @@ -240,7 +259,7 @@ (search-request-data :crowberto :q "test")))))) (testing "It prioritizes exact matches" (with-search-items-in-root-collection "test" - (with-redefs [search-config/*db-max-results* 1] + (with-redefs [search.config/*db-max-results* 1] (is (= [test-collection] (search-request-data :crowberto :q "test collection")))))) (testing "It limits matches properly" @@ -272,7 +291,7 @@ (is (= 2 (:limit (search-request :crowberto :q "test" :limit "2" :offset "3")))) (is (= 3 (:offset (search-request :crowberto :q "test" :limit "2" :offset "3"))))))) -(deftest query-model-set +(deftest archived-models-test (testing "It returns some stuff when you get results" (with-search-items-in-root-collection "test" ;; sometimes there is a "table" in these responses. might be do to garbage in CI @@ -287,17 +306,49 @@ (deftest query-model-set-test (let [search-term "query-model-set"] (with-search-items-in-root-collection search-term - (testing "should return a list of models that search result will return" - (is (= #{"dashboard" "dataset" "segment" "collection" "action" "metric" "card"} + (testing "should returns a list of models that search result will return" + (is (= #{"dashboard" "table" "dataset" "segment" "collection" "database" "action" "metric" "card"} (set (mt/user-http-request :crowberto :get 200 "search/models" :q search-term))))) - (testing "should not return models when there is no search result" - (is (= #{} - (set (mt/user-http-request :crowberto :get 200 "search/models" :q "noresults")))))))) + (testing "return a subset of model for created-by filter" + (is (= #{"dashboard" "dataset" "card" "action"} + (set (mt/user-http-request :crowberto :get 200 "search/models" + :q search-term + :created_by (mt/user->id :rasta)))))) + (testing "return a subset of model for verified filter" + (t2.with-temp/with-temp + [:model/Card {v-card-id :id} {:name (format "%s Verified Card" search-term)} + :model/Card {v-model-id :id} {:name (format "%s Verified Model" search-term) :dataset true} + :model/Collection {_v-coll-id :id} {:name (format "%s Verified Collection" search-term) :authority_level "official"}] + (testing "when has both :content-verification features" + (premium-features-test/with-premium-features #{:content-verification} + (mt/with-verified-cards [v-card-id v-model-id] + (is (= #{"card" "dataset"} + (set (mt/user-http-request :crowberto :get 200 "search/models" + :q search-term + :verified true))))))) + (testing "when has :content-verification feature only" + (premium-features-test/with-premium-features #{:content-verification} + (mt/with-verified-cards [v-card-id] + (is (= #{"card"} + (set (mt/user-http-request :crowberto :get 200 "search/models" + :q search-term + :verified true))))))))) + (testing "return a subset of model for created_at filter" + (is (= #{"dashboard" "table" "dataset" "collection" "database" "action" "card"} + (set (mt/user-http-request :crowberto :get 200 "search/models" + :q search-term + :created_at "today"))))) + + (testing "return a subset of model for search_native_query filter" + (is (= #{"dataset" "action" "card"} + (set (mt/user-http-request :crowberto :get 200 "search/models" + :q search-term + :search_native_query true)))))))) (def ^:private dashboard-count-results (letfn [(make-card [dashboard-count] (make-result (str "dashboard-count " dashboard-count) :dashboardcard_count dashboard-count, - :model "card", :bookmark false))] + :model "card", :bookmark false :creator_id true :creator_common_name "Rasta Toucan" :dataset_query nil))] (set [(make-card 5) (make-card 3) (make-card 0)]))) @@ -345,11 +396,13 @@ (perms/grant-collection-read-permissions! group (u/the-id collection)) (is (= (sorted-results (reverse ;; This reverse is hokey; it's because the test2 results happen to come first in the API response - (into - (default-results-with-collection) - (map #(merge default-search-row % (table-search-results)) - [{:name "metric test2 metric", :description "Lookin' for a blueberry", :model "metric"} - {:name "segment test2 segment", :description "Lookin' for a blueberry", :model "segment"}])))) + (into + (default-results-with-collection) + (map #(merge default-search-row % (table-search-results)) + [{:name "metric test2 metric", :description "Lookin' for a blueberry", + :model "metric" :creator_id true :creator_common_name "Rasta Toucan"} + {:name "segment test2 segment", :description "Lookin' for a blueberry", + :model "segment" :creator_id true :creator_common_name "Rasta Toucan"}])))) (search-request-data :rasta :q "test")))))))) (testing (str "Users with root collection permissions should be able to search root collection data long with " @@ -370,69 +423,71 @@ (update row :name #(str/replace % "test" "test2")))))) (search-request-data :rasta :q "test")))))))) - (testing "Users with access to multiple collections should see results from all collections they have access to" - (with-search-items-in-collection {coll-1 :collection} "test" - (with-search-items-in-collection {coll-2 :collection} "test2" - (mt/with-temp [PermissionsGroup group {} - PermissionsGroupMembership _ {:user_id (mt/user->id :rasta) :group_id (u/the-id group)}] - (perms/grant-collection-read-permissions! group (u/the-id coll-1)) - (perms/grant-collection-read-permissions! group (u/the-id coll-2)) - (is (ordered-subset? (sorted-results - (reverse - (into - (default-results-with-collection) - (map (fn [row] (update row :name #(str/replace % "test" "test2"))) - (default-results-with-collection))))) - (search-request-data :rasta :q "test"))))))) - - (testing "User should only see results in the collection they have access to" - (mt/with-non-admin-groups-no-root-collection-perms - (with-search-items-in-collection {coll-1 :collection} "test" - (with-search-items-in-collection _ "test2" - (mt/with-temp [PermissionsGroup group {} - PermissionsGroupMembership _ {:user_id (mt/user->id :rasta) :group_id (u/the-id group)}] - (perms/grant-collection-read-permissions! group (u/the-id coll-1)) - (is (= (sorted-results - (reverse - (into - (default-results-with-collection) - (map #(merge default-search-row % (table-search-results)) - [{:name "metric test2 metric" :description "Lookin' for a blueberry" :model "metric"} - {:name "segment test2 segment" :description "Lookin' for a blueberry" :model "segment"}])))) - (search-request-data :rasta :q "test")))))))) - - (testing "Metrics on tables for which the user does not have access to should not show up in results" - (mt/with-temp [Database {db-id :id} {} - Table {table-id :id} {:db_id db-id - :schema nil} - Metric _ {:table_id table-id - :name "test metric"}] - (perms/revoke-data-perms! (perms-group/all-users) db-id) - (is (= [] - (search-request-data :rasta :q "test"))))) - - (testing "Segments on tables for which the user does not have access to should not show up in results" - (mt/with-temp [Database {db-id :id} {} - Table {table-id :id} {:db_id db-id - :schema nil} - Segment _ {:table_id table-id - :name "test segment"}] - (perms/revoke-data-perms! (perms-group/all-users) db-id) - (is (= [] - (search-request-data :rasta :q "test"))))) - - (testing "Databases for which the user does not have access to should not show up in results" - (mt/with-temp [Database db-1 {:name "db-1"} - Database _db-2 {:name "db-2"}] - (is (set/subset? #{"db-2" "db-1"} - (->> (search-request-data-with sorted-results :rasta :q "db") - (map :name) - set))) - (perms/revoke-data-perms! (perms-group/all-users) (:id db-1)) - (is (nil? ((->> (search-request-data-with sorted-results :rasta :q "db") - (map :name) - set) - "db-1")))))) + (testing "Users with access to multiple collections should see results from all collections they have access to" + (with-search-items-in-collection {coll-1 :collection} "test" + (with-search-items-in-collection {coll-2 :collection} "test2" + (mt/with-temp [PermissionsGroup group {} + PermissionsGroupMembership _ {:user_id (mt/user->id :rasta) :group_id (u/the-id group)}] + (perms/grant-collection-read-permissions! group (u/the-id coll-1)) + (perms/grant-collection-read-permissions! group (u/the-id coll-2)) + (is (ordered-subset? (sorted-results + (reverse + (into + (default-results-with-collection) + (map (fn [row] (update row :name #(str/replace % "test" "test2"))) + (default-results-with-collection))))) + (search-request-data :rasta :q "test"))))))) + + (testing "User should only see results in the collection they have access to" + (mt/with-non-admin-groups-no-root-collection-perms + (with-search-items-in-collection {coll-1 :collection} "test" + (with-search-items-in-collection _ "test2" + (mt/with-temp [PermissionsGroup group {} + PermissionsGroupMembership _ {:user_id (mt/user->id :rasta) :group_id (u/the-id group)}] + (perms/grant-collection-read-permissions! group (u/the-id coll-1)) + (is (= (sorted-results + (reverse + (into + (default-results-with-collection) + (map #(merge default-search-row % (table-search-results)) + [{:name "metric test2 metric" :description "Lookin' for a blueberry" + :model "metric" :creator_id true :creator_common_name "Rasta Toucan"} + {:name "segment test2 segment" :description "Lookin' for a blueberry" :model "segment" + :creator_id true :creator_common_name "Rasta Toucan"}])))) + (search-request-data :rasta :q "test")))))))) + + (testing "Metrics on tables for which the user does not have access to should not show up in results" + (mt/with-temp [Database {db-id :id} {} + Table {table-id :id} {:db_id db-id + :schema nil} + Metric _ {:table_id table-id + :name "test metric"}] + (perms/revoke-data-perms! (perms-group/all-users) db-id) + (is (= [] + (search-request-data :rasta :q "test"))))) + + (testing "Segments on tables for which the user does not have access to should not show up in results" + (mt/with-temp [Database {db-id :id} {} + Table {table-id :id} {:db_id db-id + :schema nil} + Segment _ {:table_id table-id + :name "test segment"}] + (perms/revoke-data-perms! (perms-group/all-users) db-id) + (is (= [] + (search-request-data :rasta :q "test"))))) + + (testing "Databases for which the user does not have access to should not show up in results" + (mt/with-temp [Database db-1 {:name "db-1"} + Database _db-2 {:name "db-2"}] + (is (set/subset? #{"db-2" "db-1"} + (->> (search-request-data-with sorted-results :rasta :q "db") + (map :name) + set))) + (perms/revoke-data-perms! (perms-group/all-users) (:id db-1)) + (is (nil? ((->> (search-request-data-with sorted-results :rasta :q "db") + (map :name) + set) + "db-1")))))) (deftest bookmarks-test (testing "Bookmarks are per user, so other user's bookmarks don't cause search results to be altered" @@ -513,21 +568,7 @@ :model_name (:name model) :model_index_id #hawk/malli :int}} (into {} (comp relevant (map (juxt :name normalize))) - (search! "rom")))))))))) - - (testing "Sandboxing inhibits searching indexes" - (binding [api/*current-user-id* (mt/user->id :rasta)] - (is (= [:and - [:inline [:= 1 1]] - [:or [:like [:lower :model-index-value.name] "%foo%"]]] - (#'api.search/base-where-clause-for-model "indexed-entity" {:archived? false - :search-string "foo" - :current-user-perms #{"/"}}))) - (with-redefs [premium-features/sandboxed-or-impersonated-user? (constantly true)] - (is (= [:and [:inline [:= 1 1]] [:or [:= 0 1]]] - (#'api.search/base-where-clause-for-model "indexed-entity" {:archived? false - :search-string "foo" - :current-user-perms #{"/"}}))))))) + (search! "rom"))))))))))) (deftest archived-results-test (testing "Should return unarchived results by default" @@ -615,7 +656,7 @@ (deftest table-test (testing "You should see Tables in the search results!\n" - (t2.with-temp/with-temp [Table _ {:name "RoundTable"}] + (mt/with-temp [Table _ {:name "RoundTable"}] (do-test-users [user [:crowberto :rasta]] (is (= [(default-table-search-row "RoundTable")] (search-request-data user :q "RoundTable")))))) @@ -627,25 +668,25 @@ (search-request-data user :q "Foo")))))) (testing "You should be able to search by their display name" (let [lancelot "Lancelot's Favorite Furniture"] - (t2.with-temp/with-temp [Table _ {:name "RoundTable" :display_name lancelot}] + (mt/with-temp [Table _ {:name "RoundTable" :display_name lancelot}] (do-test-users [user [:crowberto :rasta]] (is (= [(assoc (default-table-search-row "RoundTable") :name lancelot)] (search-request-data user :q "Lancelot"))))))) (testing "You should be able to search by their description" (let [lancelot "Lancelot's Favorite Furniture"] - (t2.with-temp/with-temp [Table _ {:name "RoundTable" :description lancelot}] + (mt/with-temp [Table _ {:name "RoundTable" :description lancelot}] (do-test-users [user [:crowberto :rasta]] (is (= [(assoc (default-table-search-row "RoundTable") :description lancelot :table_description lancelot)] (search-request-data user :q "Lancelot"))))))) (testing "When searching with ?archived=true, normal Tables should not show up in the results" (let [table-name (mt/random-name)] - (t2.with-temp/with-temp [Table _ {:name table-name}] + (mt/with-temp [Table _ {:name table-name}] (do-test-users [user [:crowberto :rasta]] (is (= [] (search-request-data user :q table-name :archived true))))))) (testing "*archived* tables should not appear in search results" (let [table-name (mt/random-name)] - (t2.with-temp/with-temp [Table _ {:name table-name, :active false}] + (mt/with-temp [Table _ {:name table-name, :active false}] (do-test-users [user [:crowberto :rasta]] (is (= [] (search-request-data user :q table-name))))))) @@ -737,14 +778,17 @@ :name "segment count test 2"} Segment _ {:table_id table-id :name "segment count test 3"}] - (with-redefs [premium-features/sandboxed-or-impersonated-user? (constantly false)] + (mt/with-current-user (mt/user->id :crowberto) (t2.execute/with-call-count [call-count] - ;; there seems to be a bug with mu/def that if this is a list it fails validation - (#'api.search/search (#'api.search/search-context "count test" nil nil nil 100 0)) + (#'api.search/search {:search-string "count test" + :archived? false + :models search.config/all-models + :current-user-perms #{"/"} + :limit-int 100}) ;; the call count number here are expected to change if we change the search api ;; we have this test here just to keep tracks this number to remind us to put effort ;; into keep this number as low as we can - (is (= 16 (call-count))))))) + (is (= 11 (call-count))))))) (deftest snowplow-new-search-query-event-test (testing "Send a snowplow event when a new global search query is made" @@ -765,27 +809,550 @@ (mt/user-http-request :crowberto :get 200 "search" :q "test" :archived true) (is (empty? (snowplow-test/pop-event-data-and-user-id!)))))) -(deftest available-models-should-be-independent-of-models-param-test - (testing "if a search request includes `models` params, the `available_models` from the response should not be restricted by it" - (let [search-term "Available models"] - (with-search-items-in-root-collection search-term - (testing "GET /api/search" - (is (= #{"dashboard" "dataset" "segment" "collection" "action" "metric" "card"} - (-> (mt/user-http-request :crowberto :get 200 "search" :q search-term :models "card") - :available_models - set))) +;; ------------------------------------------------ Filter Tests ------------------------------------------------ ;; - (is (= #{"dashboard" "dataset" "segment" "collection" "action" "metric" "card"} - (-> (mt/user-http-request :crowberto :get 200 "search" :q search-term :models "card" :models "dashboard") +(deftest filter-by-creator-test + (let [search-term "Created by Filter"] + (with-search-items-in-root-collection search-term + (mt/with-temp + [:model/User {user-id :id} {:first_name "Explorer" :last_name "Curious"} + :model/User {user-id-2 :id} {:first_name "Explorer" :last_name "Hubble"} + :model/Card {card-id :id} {:name (format "%s Card 1" search-term) :creator_id user-id} + :model/Card {card-id-2 :id} {:name (format "%s Card 2" search-term) :creator_id user-id + :collection_id (:id (collection/user->personal-collection user-id))} + :model/Card {card-id-3 :id} {:name (format "%s Card 3" search-term) :creator_id user-id :archived true} + :model/Card {card-id-4 :id} {:name (format "%s Card 4" search-term) :creator_id user-id-2} + :model/Card {model-id :id} {:name (format "%s Dataset 1" search-term) :dataset true :creator_id user-id} + :model/Dashboard {dashboard-id :id} {:name (format "%s Dashboard 1" search-term) :creator_id user-id} + :model/Action {action-id :id} {:name (format "%s Action 1" search-term) :model_id model-id :creator_id user-id :type :http}] + + (testing "sanity check that without search by created_by we have more results than if a filter is provided" + (is (> (:total (mt/user-http-request :crowberto :get 200 "search" :q search-term)) + 5))) + + (testing "Able to filter by creator" + (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_by user-id)] + + (testing "only a subset of models are applicable" + (is (= #{"card" "dataset" "dashboard" "action"} (set (:available_models resp))))) + + (testing "results contains only entities with the specified creator" + (is (= #{[dashboard-id "dashboard" "Created by Filter Dashboard 1"] + [card-id "card" "Created by Filter Card 1"] + [card-id-2 "card" "Created by Filter Card 2"] + [model-id "dataset" "Created by Filter Dataset 1"] + [action-id "action" "Created by Filter Action 1"]} + (->> (:data resp) + (map (juxt :id :model :name)) + set)))))) + + (testing "Able to filter by multiple creators" + (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_by user-id :created_by user-id-2)] + + (testing "only a subset of models are applicable" + (is (= #{"card" "dataset" "dashboard" "action"} (set (:available_models resp))))) + + (testing "results contains only entities with the specified creator" + (is (= #{[dashboard-id "dashboard" "Created by Filter Dashboard 1"] + [card-id "card" "Created by Filter Card 1"] + [card-id-2 "card" "Created by Filter Card 2"] + [card-id-4 "card" "Created by Filter Card 4"] + [model-id "dataset" "Created by Filter Dataset 1"] + [action-id "action" "Created by Filter Action 1"]} + (->> (:data resp) + (map (juxt :id :model :name)) + set)))))) + + (testing "Works with archived filter" + (is (=? [{:model "card" + :id card-id-3 + :archived true}] + (:data (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_by user-id :archived true))))) + + (testing "Works with models filter" + (testing "return intersections of supported models with provided models" + (is (= #{"dashboard" "card"} + (->> (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_by user-id :models "card" :models "dashboard") + :data + (map :model) + set)))) + + (testing "return nothing if there is no intersection" + (is (= #{} + (->> (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_by user-id :models "table" :models "database") + :data + (map :model) + set))))) + + (testing "respect the read permissions" + (let [resp (mt/user-http-request :rasta :get 200 "search" :q search-term :created_by user-id)] + (is (not (contains? + (->> (:data resp) + (filter #(= (:model %) "card")) + (map :id) + set) + card-id-2))))) + + (testing "error if creator_id is not an integer" + (let [resp (mt/user-http-request :crowberto :get 400 "search" :q search-term :created_by "not-a-valid-user-id")] + (is (= {:created_by "nullable value must be an integer greater than zero., or sequence of value must be an integer greater than zero."} + (:errors resp))))))))) + +(deftest filter-by-last-edited-by-test + (let [search-term "last-edited-by"] + (mt/with-temp + [:model/Card {rasta-card-id :id} {:name search-term} + :model/Card {lucky-card-id :id} {:name search-term} + :model/Card {rasta-model-id :id} {:name search-term :dataset true} + :model/Card {lucky-model-id :id} {:name search-term :dataset true} + :model/Dashboard {rasta-dash-id :id} {:name search-term} + :model/Dashboard {lucky-dash-id :id} {:name search-term} + :model/Metric {rasta-metric-id :id} {:name search-term} + :model/Metric {lucky-metric-id :id} {:name search-term}] + (let [rasta-user-id (mt/user->id :rasta) + lucky-user-id (mt/user->id :lucky)] + (doseq [[model id user-id] [[:model/Card rasta-card-id rasta-user-id] [:model/Card rasta-model-id rasta-user-id] + [:model/Dashboard rasta-dash-id rasta-user-id] [:model/Metric rasta-metric-id rasta-user-id] + [:model/Card lucky-card-id lucky-user-id] [:model/Card lucky-model-id lucky-user-id] + [:model/Dashboard lucky-dash-id lucky-user-id] [:model/Metric lucky-metric-id lucky-user-id]]] + (revision/push-revision! + :entity model + :id id + :user-id user-id + :is_creation true + :object {:id id})) + + (testing "Able to filter by last editor" + (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :last_edited_by rasta-user-id)] + + (testing "only a subset of models are applicable" + (is (= #{"dashboard" "dataset" "metric" "card"} (set (:available_models resp))))) + + (testing "results contains only entities with the specified creator" + (is (= #{[rasta-metric-id "metric"] + [rasta-card-id "card"] + [rasta-model-id "dataset"] + [rasta-dash-id "dashboard"]} + (->> (:data resp) + (map (juxt :id :model)) + set)))))) + + (testing "Able to filter by multiple last editor" + (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :last_edited_by rasta-user-id :last_edited_by lucky-user-id)] + + (testing "only a subset of models are applicable" + (is (= #{"dashboard" "dataset" "metric" "card"} (set (:available_models resp))))) + + (testing "results contains only entities with the specified creator" + (is (= #{[rasta-metric-id "metric"] + [rasta-card-id "card"] + [rasta-model-id "dataset"] + [rasta-dash-id "dashboard"] + [lucky-metric-id "metric"] + [lucky-card-id "card"] + [lucky-model-id "dataset"] + [lucky-dash-id "dashboard"]} + (->> (:data resp) + (map (juxt :id :model)) + set)))))) + + (testing "error if last_edited_by is not an integer" + (let [resp (mt/user-http-request :crowberto :get 400 "search" :q search-term :last_edited_by "not-a-valid-user-id")] + (is (= {:last_edited_by "nullable value must be an integer greater than zero., or sequence of value must be an integer greater than zero."} + (:errors resp))))))))) + +(deftest verified-filter-test + (let [search-term "Verified filter"] + (t2.with-temp/with-temp + [:model/Card {v-card-id :id} {:name (format "%s Verified Card" search-term)} + :model/Card {_card-id :id} {:name (format "%s Normal Card" search-term)} + :model/Card {_model-id :id} {:name (format "%s Normal Model" search-term) :dataset true} + :model/Card {v-model-id :id} {:name (format "%s Verified Model" search-term) :dataset true}] + (mt/with-verified-cards [v-card-id v-model-id] + (premium-features-test/with-premium-features #{:content-verification} + (testing "Able to filter only verified items" + (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :verified true)] + (testing "do not returns duplicated verified cards" + (is (= 1 (->> resp + :data + (filter #(= {:model "card" :id v-card-id} (select-keys % [:model :id]))) + count)))) + + (testing "only a subset of models are applicable" + (is (= #{"card" "dataset"} (set (:available_models resp))))) + + (testing "results contains only verified entities" + (is (= #{[v-card-id "card" "Verified filter Verified Card"] + [v-model-id "dataset" "Verified filter Verified Model"]} + + (->> (:data resp) + (map (juxt :id :model :name)) + set)))))) + + (testing "Returns schema error if attempt to serach for non-verified items" + (is (= {:verified "nullable true"} + (:errors (mt/user-http-request :crowberto :get 400 "search" :q "x" :verified false))))) + + (testing "Works with models filter" + (testing "return intersections of supported models with provided models" + (is (= #{"card"} + (->> (mt/user-http-request :crowberto :get 200 "search" + :q search-term :verified true :models "card" :models "dashboard" :model "table") + :data + (map :model) + set)))))) + + (premium-features-test/with-premium-features #{:content-verification} + (testing "Returns verified cards and models only if :content-verification is enabled" + (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :verified true)] + + (testing "only a subset of models are applicable" + (is (= #{"card" "dataset"} (set (:available_models resp))))) + + (testing "results contains only verified entities" + (is (= #{[v-card-id "card" "Verified filter Verified Card"] + [v-model-id "dataset" "Verified filter Verified Model"]} + (->> (:data resp) + (map (juxt :id :model :name)) + set))))))) + + (testing "error if doesn't have premium-features" + (premium-features-test/with-premium-features #{} + (is (= "Content Management or Official Collections is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/" + (mt/user-http-request :crowberto :get 402 "search" :q search-term :verified true))))))))) + +(deftest created-at-api-test + (let [search-term "created-at-filtering"] + (with-search-items-in-root-collection search-term + (testing "returns only applicable models" + (is (= #{"dashboard" "table" "dataset" "collection" "database" "action" "card"} + (-> (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_at "today") + :available_models + set)))) + + (testing "works with others filter too" + (is (= #{"dashboard" "table" "dataset" "collection" "database" "action" "card"} + (-> (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_at "today" :creator_id (mt/user->id :rasta)) + :available_models + set)))) + + (testing "error if invalids created_at string" + (is (= "Failed to parse datetime value: today~" + (mt/user-http-request :crowberto :get 400 "search" :q search-term :created_at "today~" :creator_id (mt/user->id :rasta)))))))) + +(deftest filter-by-last-edited-at-test + (let [search-term "last-edited-at-filtering"] + (t2.with-temp/with-temp + [:model/Card {card-id :id} {:name search-term} + :model/Card {model-id :id} {:name search-term :dataset true} + :model/Dashboard {dash-id :id} {:name search-term} + :model/Metric {metric-id :id} {:name search-term} + :model/Action {action-id :id} {:name search-term + :model_id model-id + :type :http}] + (doseq [[model id] [[:model/Card card-id] [:model/Card model-id] + [:model/Dashboard dash-id] [:model/Metric metric-id]]] + (revision/push-revision! + :entity model + :id id + :user-id (mt/user->id :rasta) + :is_creation true + :object {:id id})) + (testing "returns only applicable models" + (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :last_edited_at "today")] + (is (= #{[action-id "action"] + [card-id "card"] + [dash-id "dashboard"] + [model-id "dataset"] + [metric-id "metric"]} + (->> (:data resp) + (map (juxt :id :model)) + set))) + + (is (= #{"action" "card" "dashboard" "dataset" "metric"} + (-> resp :available_models - set)))) - - (testing "GET /api/search/models" - (is (= #{"dashboard" "dataset" "segment" "collection" "action" "metric" "card"} - (set (mt/user-http-request :crowberto :get 200 "search/models" :q search-term :models "card")))) + set))))) + + (testing "works with the last_edited_by filter too" + (doseq [[model id] [[:model/Card card-id] [:model/Card model-id] + [:model/Dashboard dash-id] [:model/Metric metric-id]]] + (revision/push-revision! + :entity model + :id id + :user-id (mt/user->id :rasta) + :is_creation true + :object {:id id})) + (is (= #{"dashboard" "dataset" "metric" "card"} + (-> (mt/user-http-request :crowberto :get 200 "search" :q search-term :last_edited_at "today" :last_edited_by (mt/user->id :rasta)) + :available_models + set)))) + + (testing "error if invalids last_edited_at string" + (is (= "Failed to parse datetime value: today~" + (mt/user-http-request :crowberto :get 400 "search" :q search-term :last_edited_at "today~" :creator_id (mt/user->id :rasta)))))))) + +(deftest created-at-correctness-test + (let [search-term "created-at-filtering" + new #t "2023-05-04T10:00Z[UTC]" + two-years-ago (t/minus new (t/years 2))] + (mt/with-clock new + (t2.with-temp/with-temp + [:model/Dashboard {dashboard-new :id}{:name search-term + :created_at new} + :model/Dashboard {dashboard-old :id}{:name search-term + :created_at two-years-ago} + :model/Database {db-new :id} {:name search-term + :created_at new} + :model/Database {db-old :id } {:name search-term + :created_at two-years-ago} + :model/Table {table-new :id} {:name search-term + :db_id db-new + :created_at new} + :model/Table {table-old :id} {:name search-term + :db_id db-old + :created_at two-years-ago} + :model/Collection {coll-new :id} {:name search-term + :created_at new} + :model/Collection {coll-old :id} {:name search-term + :created_at two-years-ago} + :model/Card {card-new :id} {:name search-term + :created_at new} + :model/Card {card-old :id} {:name search-term + :created_at two-years-ago} + :model/Card {model-new :id} {:name search-term + :dataset true + :created_at new} + :model/Card {model-old :id} {:name search-term + :dataset true + :created_at two-years-ago} + :model/Action {action-new :id} {:name search-term + :model_id model-new + :type :http + :created_at new} + :model/Action {action-old :id} {:name search-term + :model_id model-old + :type :http + :created_at two-years-ago} + :model/Segment {_segment-new :id} {:name search-term + :created_at new} + :model/Metric {_metric-new :id} {:name search-term + :created_at new}] + ;; with clock doesn't work if calling via API, so we call the search function directly + (let [test-search (fn [created-at expected] + (testing (format "searching with created-at = %s" created-at) + (mt/with-current-user (mt/user->id :crowberto) + (is (= expected + (->> (#'api.search/search (#'api.search/search-context + {:search-string search-term + :archived false + :models search.config/all-models + :created-at created-at})) + :data + (map (juxt :model :id)) + set)))))) + new-result #{["action" action-new] + ["card" card-new] + ["collection" coll-new] + ["database" db-new] + ["dataset" model-new] + ["dashboard" dashboard-new] + ["table" table-new]} + old-result #{["action" action-old] + ["card" card-old] + ["collection" coll-old] + ["database" db-old] + ["dataset" model-old] + ["dashboard" dashboard-old] + ["table" table-old]}] + ;; absolute datetime + (test-search "Q2-2021" old-result) + (test-search "2023-05-04" new-result) + (test-search "2021-05-03~" (set/union old-result new-result)) + ;; range is inclusive of the start but exclusive of the end, so this does not contain new-result + (test-search "2021-05-04~2023-05-03" old-result) + (test-search "2021-05-05~2023-05-04" new-result) + (test-search "~2023-05-03" old-result) + (test-search "2021-05-04T09:00:00~2021-05-04T10:00:10" old-result) + + ;; relative times + (test-search "thisyear" new-result) + (test-search "past1years-from-12months" old-result) + (test-search "today" new-result)))))) + +(deftest last-edited-at-correctness-test + (let [search-term "last-edited-at-filtering" + new #t "2023-05-04T10:00Z[UTC]" + two-years-ago (t/minus new (t/years 2))] + (mt/with-clock new + (t2.with-temp/with-temp + [:model/Dashboard {dashboard-new :id} {:name search-term} + :model/Dashboard {dashboard-old :id} {:name search-term} + :model/Card {card-new :id} {:name search-term} + :model/Card {card-old :id} {:name search-term} + :model/Card {model-new :id} {:name search-term + :dataset true} + :model/Card {model-old :id} {:name search-term + :dataset true} + :model/Metric {metric-new :id} {:name search-term} + :model/Metric {metric-old :id} {:name search-term} + :model/Action {action-new :id} {:name search-term + :model_id model-new + :type :http + :updated_at new} + :model/Action {action-old :id} {:name search-term + :model_id model-old + :type :http + :updated_at two-years-ago}] + (t2/insert! (t2/table-name :model/Revision) (for [[model model-id timestamp] + [["Dashboard" dashboard-new new] + ["Dashboard" dashboard-old two-years-ago] + ["Card" card-new new] + ["Card" card-old two-years-ago] + ["Card" model-new new] + ["Card" model-old two-years-ago] + ["Metric" metric-new new] + ["Metric" metric-old two-years-ago]]] + {:model model + :model_id model-id + :object "{}" + :user_id (mt/user->id :rasta) + :timestamp timestamp + :most_recent true})) + ;; with clock doesn't work if calling via API, so we call the search function directly + (let [test-search (fn [last-edited-at expected] + (testing (format "searching with last-edited-at = %s" last-edited-at) + (mt/with-current-user (mt/user->id :crowberto) + (is (= expected + (->> (#'api.search/search (#'api.search/search-context + {:search-string search-term + :archived false + :models search.config/all-models + :last-edited-at last-edited-at})) + :data + (map (juxt :model :id)) + set)))))) + new-result #{["action" action-new] + ["card" card-new] + ["dataset" model-new] + ["dashboard" dashboard-new] + ["metric" metric-new]} + old-result #{["action" action-old] + ["card" card-old] + ["dataset" model-old] + ["dashboard" dashboard-old] + ["metric" metric-old]}] + ;; absolute datetime + (test-search "Q2-2021" old-result) + (test-search "2023-05-04" new-result) + (test-search "2021-05-03~" (set/union old-result new-result)) + ;; range is inclusive of the start but exclusive of the end, so this does not contain new-result + (test-search "2021-05-04~2023-05-03" old-result) + (test-search "2021-05-05~2023-05-04" new-result) + (test-search "~2023-05-03" old-result) + (test-search "2021-05-04T09:00:00~2021-05-04T10:00:10" old-result) + + ;; relative times + (test-search "thisyear" new-result) + (test-search "past1years-from-12months" old-result) + (test-search "today" new-result)))))) - (is (= #{"dashboard" "dataset" "segment" "collection" "action" "metric" "card"} - (set (mt/user-http-request :crowberto :get 200 "search/models" :q search-term :models "card" :models "dashboard"))))))))) +(deftest available-models-should-be-independent-of-models-param-test + (testing "if a search request includes `models` params, the `available_models` from the response should not be restricted by it" + (let [search-term "Available models"] + (with-search-items-in-root-collection search-term + (testing "GET /api/search" + (is (= #{"dashboard" "dataset" "segment" "collection" "action" "metric" "card" "table" "database"} + (-> (mt/user-http-request :crowberto :get 200 "search" :q search-term :models "card") + :available_models + set))) + + (is (= #{"dashboard" "dataset" "segment" "collection" "action" "metric" "card" "table" "database"} + (-> (mt/user-http-request :crowberto :get 200 "search" :q search-term :models "card" :models "dashboard") + :available_models + set)))) + + (testing "GET /api/search/models" + (is (= #{"dashboard" "dataset" "segment" "collection" "action" "metric" "card" "table" "database"} + (set (mt/user-http-request :crowberto :get 200 "search/models" :q search-term :models "card")))) + + (is (= #{"dashboard" "dataset" "segment" "collection" "action" "metric" "card" "table" "database"} + (set (mt/user-http-request :crowberto :get 200 "search/models" :q search-term :models "card" :models "dashboard"))))))))) + +(deftest search-native-query-test + (let [search-term "search-native-query"] + (mt/with-temp + [:model/Card {mbql-card :id} {:name search-term} + :model/Card {native-card-in-name :id} {:name search-term} + :model/Card {native-card-in-query :id} {:dataset_query (mt/native-query {:query (format "select %s" search-term)})} + :model/Card {mbql-model :id} {:name search-term :dataset true} + :model/Card {native-model-in-name :id} {:name search-term :dataset true} + :model/Card {native-model-in-query :id} {:dataset_query (mt/native-query {:query (format "select %s" search-term)}) :dataset true}] + (mt/with-actions + [_ {:dataset true :dataset_query (mt/mbql-query venues)} + {http-action :action-id} {:type :http :name search-term} + {query-action :action-id} {:type :query :dataset_query (mt/native-query {:query (format "delete from %s" search-term)})}] + (testing "by default do not search for native content" + (is (= #{["card" mbql-card] + ["card" native-card-in-name] + ["dataset" mbql-model] + ["dataset" native-model-in-name] + ["action" http-action]} + (->> (mt/user-http-request :crowberto :get 200 "search" :q search-term) + :data + (map (juxt :model :id)) + set)))) + + (testing "if search-native-query is true, search both dataset_query and the name" + (is (= #{["card" mbql-card] + ["card" native-card-in-name] + ["dataset" mbql-model] + ["dataset" native-model-in-name] + ["action" http-action] + + ["card" native-card-in-query] + ["dataset" native-model-in-query] + ["action" query-action]} + (->> (mt/user-http-request :crowberto :get 200 "search" :q search-term :search_native_query true) + :data + (map (juxt :model :id)) + set)))))))) + +(deftest search-result-with-user-metadata-test + (let [search-term "with-user-metadata"] + (mt/with-temp + [:model/User {user-id-1 :id} {:first_name "Ngoc" + :last_name "Khuat"} + :model/User {user-id-2 :id} {:first_name nil + :last_name nil + :email "ngoc@metabase.com"} + :model/Card {card-id-1 :id} {:creator_id user-id-1 + :name search-term} + :model/Card {card-id-2 :id} {:creator_id user-id-2 + :name search-term}] + + (revision/push-revision! + :entity :model/Card + :id card-id-1 + :user-id user-id-1 + :is_creation true + :object {:id card-id-1}) + + (revision/push-revision! + :entity :model/Card + :id card-id-2 + :user-id user-id-2 + :is_creation true + :object {:id card-id-2}) + + (testing "search result should returns creator_common_name and last_editor_common_name" + (is (= #{["card" card-id-1 "Ngoc Khuat" "Ngoc Khuat"] + ;; for user that doesn't have first_name or last_name, should fall backs to email + ["card" card-id-2 "ngoc@metabase.com" "ngoc@metabase.com"]} + (->> (mt/user-http-request :crowberto :get 200 "search" :q search-term) + :data + (map (juxt :model :id :creator_common_name :last_editor_common_name)) + set))))))) (deftest models-table-db-id-test (testing "search/models request includes `table-db-id` param" @@ -807,12 +1374,9 @@ Action _ (archived {:name "test action" :type :query :model_id model-id})] - (testing "`archived-string` is invalid" - (is (=? {:message "Invalid input: [\"value must be a valid boolean string ('true' or 'false').\"]"} - (mt/user-http-request :crowberto :get 500 "search/models" :archived-string 1)))) (testing "`archived-string` is 'false'" - (is (= #{"dashboard" "database" "segment" "collection" "action" "metric" "card" "dataset" "table"} - (set (mt/user-http-request :crowberto :get 200 "search/models" :archived-string "false"))))) + (is (= #{"dashboard" "table" "dataset" "segment" "collection" "database" "action" "metric" "card"} + (set (mt/user-http-request :crowberto :get 200 "search/models" :archived "false"))))) (testing "`archived-string` is 'true'" (is (= #{"action"} - (set (mt/user-http-request :crowberto :get 200 "search/models" :archived-string "true"))))))))) + (set (mt/user-http-request :crowberto :get 200 "search/models" :archived "true"))))))))) diff --git a/test/metabase/db/custom_migrations_test.clj b/test/metabase/db/custom_migrations_test.clj index ca66f2690069bee0e402ea79b5f34410191a08c2..d1b1333dc3c71ccd6902b8841cbc94a2637828f5 100644 --- a/test/metabase/db/custom_migrations_test.clj +++ b/test/metabase/db/custom_migrations_test.clj @@ -405,13 +405,11 @@ {:row 0 :col 0 :size_x 24 :size_y 2} {:row 36 :col 0 :size_x 23 :size_y 1} {:row 36 :col 23 :size_x 1 :size_y 1}] - (-> (t2/select-one (t2/table-name :model/Revision) :id revision-id) - :object (json/parse-string true) :cards)))) - (migrate-down! 46) - (testing "downgrade works correctly" - (is (= cards - (-> (t2/select-one (t2/table-name :model/Revision) :id revision-id) - :object (json/parse-string true) :cards))))))) + (t2/select-one-fn (comp :cards :object) :model/Revision :id revision-id)))) + (migrate-down! 46) + (testing "downgrade works correctly" + (is (= cards (-> (t2/select-one (t2/table-name :model/Revision) :id revision-id) + :object (json/parse-string true) :cards))))))) (deftest migrate-dashboard-revision-grid-from-18-to-24-handle-faliure-test (impl/test-migrations ["v47.00-032" "v47.00-033"] [migrate!] @@ -799,22 +797,22 @@ :size_y 4 :col 1 :row 1})] - (migrate!) - (testing "After the migration, column_settings field refs are updated to include join-alias" - (is (= expected - (-> (t2/query-one {:select [:visualization_settings] - :from [:report_dashboardcard] - :where [:= :id dashcard-id]}) - :visualization_settings - json/parse-string)))) - (db.setup/migrate! db-type data-source :down 46) - (testing "After reversing the migration, column_settings field refs are updated to remove join-alias" - (is (= visualization-settings - (-> (t2/query-one {:select [:visualization_settings] - :from [:report_dashboardcard] - :where [:= :id dashcard-id]}) - :visualization_settings - json/parse-string)))))))) + (migrate!) + (testing "After the migration, column_settings field refs are updated to include join-alias" + (is (= expected + (-> (t2/query-one {:select [:visualization_settings] + :from [:report_dashboardcard] + :where [:= :id dashcard-id]}) + :visualization_settings + json/parse-string)))) + (db.setup/migrate! db-type data-source :down 46) + (testing "After reversing the migration, column_settings field refs are updated to remove join-alias" + (is (= visualization-settings + (-> (t2/query-one {:select [:visualization_settings] + :from [:report_dashboardcard] + :where [:= :id dashcard-id]}) + :visualization_settings + json/parse-string)))))))) (deftest revision-migrate-legacy-dashboard-card-column-settings-field-refs-test (testing "Migrations v47.00-045: update dashboard cards' visualization_settings.column_settings legacy field refs" @@ -939,25 +937,25 @@ :user_id user-id :object (json/generate-string dashboard) :timestamp :%now})] - (migrate!) - (testing "column_settings field refs are updated" - (is (= expected - (-> (t2/query-one {:select [:object] - :from [:revision] - :where [:= :id revision-id]}) - :object - json/parse-string - (get-in ["cards" 0 "visualization_settings"]))))) - (db.setup/migrate! db-type data-source :down 46) - (testing "down migration restores original visualization_settings, except it's okay if join-alias are missing" - (is (= (m/dissoc-in visualization-settings - ["column_settings" (json/generate-string ["ref" ["field" 1 {"join-alias" "Joined table"}]])]) - (-> (t2/query-one {:select [:object] - :from [:revision] - :where [:= :id revision-id]}) - :object - json/parse-string - (get-in ["cards" 0 "visualization_settings"]))))))))) + (migrate!) + (testing "column_settings field refs are updated" + (is (= expected + (-> (t2/query-one {:select [:object] + :from [:revision] + :where [:= :id revision-id]}) + :object + json/parse-string + (get-in ["cards" 0 "visualization_settings"]))))) + (db.setup/migrate! db-type data-source :down 46) + (testing "down migration restores original visualization_settings, except it's okay if join-alias are missing" + (is (= (m/dissoc-in visualization-settings + ["column_settings" (json/generate-string ["ref" ["field" 1 {"join-alias" "Joined table"}]])]) + (-> (t2/query-one {:select [:object] + :from [:revision] + :where [:= :id revision-id]}) + :object + json/parse-string + (get-in ["cards" 0 "visualization_settings"]))))))))) (deftest migrate-database-options-to-database-settings-test (let [do-test diff --git a/test/metabase/db/schema_migrations_test.clj b/test/metabase/db/schema_migrations_test.clj index 8756cb593d83bc24867b9b13942c207aff67d1f4..5ec2ec79e6b6733aa0d290a3432356b0ffbb1eaf 100644 --- a/test/metabase/db/schema_migrations_test.clj +++ b/test/metabase/db/schema_migrations_test.clj @@ -1116,6 +1116,55 @@ (is (= [{:id 1, :group_id 1, :table_id table-id, :card_id nil, :attribute_remappings "{\"foo\", 1}", :permission_id perm-id}] (mdb.query/query {:select [:*] :from [:sandboxes]}))))))) +(deftest add-revision-most-recent-test + (testing "Migrations v48.00-008-v48.00-009: add `revision.most_recent`" + (impl/test-migrations ["v48.00-007" "v48.00-009"] [migrate!] + (let [user-id (:id (create-raw-user! (tu.random/random-email))) + old (t/minus (t/local-date-time) (t/hours 1)) + rev-dash-1-old (first (t2/insert-returning-pks! (t2/table-name :model/Revision) + {:model "dashboard" + :model_id 1 + :user_id user-id + :object "{}" + :is_creation true + :timestamp old})) + rev-dash-1-new (first (t2/insert-returning-pks! (t2/table-name :model/Revision) + {:model "dashboard" + :model_id 1 + :user_id user-id + :object "{}" + :timestamp :%now})) + rev-dash-2-old (first (t2/insert-returning-pks! (t2/table-name :model/Revision) + {:model "dashboard" + :model_id 2 + :user_id user-id + :object "{}" + :is_creation true + :timestamp old})) + rev-dash-2-new (first (t2/insert-returning-pks! (t2/table-name :model/Revision) + {:model "dashboard" + :model_id 2 + :user_id user-id + :object "{}" + :timestamp :%now})) + rev-card-1-old (first (t2/insert-returning-pks! (t2/table-name :model/Revision) + {:model "card" + :model_id 1 + :user_id user-id + :object "{}" + :is_creation true + :timestamp old})) + rev-card-1-new (first (t2/insert-returning-pks! (t2/table-name :model/Revision) + {:model "card" + :model_id 1 + :user_id user-id + :object "{}" + :timestamp :%now}))] + (migrate!) + (is (= #{false} (t2/select-fn-set :most_recent (t2/table-name :model/Revision) + :id [:in [rev-dash-1-old rev-dash-2-old rev-card-1-old]]))) + (is (= #{true} (t2/select-fn-set :most_recent (t2/table-name :model/Revision) + :id [:in [rev-dash-1-new rev-dash-2-new rev-card-1-new]]))))))) (deftest fks-are-indexed-test (mt/test-driver :postgres (testing "all FKs should be indexed" diff --git a/test/metabase/driver/common/parameters/dates_test.clj b/test/metabase/driver/common/parameters/dates_test.clj index db519bfec06a04b4b8a105239aba7f600c950d7f..9e90ea01b9cee6cc7a5dfc3be6e29d0b93f379f0 100644 --- a/test/metabase/driver/common/parameters/dates_test.clj +++ b/test/metabase/driver/common/parameters/dates_test.clj @@ -5,8 +5,7 @@ [clojure.test.check.generators :as gen] [clojure.test.check.properties :as prop] [metabase.driver.common.parameters.dates :as params.dates] - [metabase.test :as mt] - [metabase.util.date-2 :as u.date])) + [metabase.test :as mt])) (deftest ^:parallel date-string->filter-test (testing "year and month" @@ -133,101 +132,313 @@ (is (thrown? clojure.lang.ExceptionInfo #"Don't know how to parse date string \"exclude-minutes-15-30\"" (params.dates/date-string->filter "exclude-minutes-15-30" [:field "field" {:base-type :type/DateTime}]))))))) -(deftest ^:parallel date-string->range-test +(defn do-date-string-range-test + [s->expected] (mt/with-clock #t "2016-06-07T12:13:55Z" - (doseq [[group s->expected] - {"absolute datetimes" {"Q1-2016" {:start "2016-01-01", :end "2016-03-31"} - "2016-02" {:start "2016-02-01", :end "2016-02-29"} - "2016-04-18" {:start "2016-04-18", :end "2016-04-18"} - "2016-04-18~2016-04-23" {:start "2016-04-18", :end "2016-04-23"} - "2016-04-18~" {:start "2016-04-18"} - "~2016-04-18" {:end "2016-04-18"}} - "relative (past)" {"past30seconds" {:start "2016-06-07T12:13:25", :end "2016-06-07T12:13:54"} - "past5minutes~" {:start "2016-06-07T12:08:00", :end "2016-06-07T12:13:00"} - "past3hours" {:start "2016-06-07T09:00:00", :end "2016-06-07T11:00:00"} - "past3days" {:start "2016-06-04", :end "2016-06-06"} - "past3days~" {:start "2016-06-04", :end "2016-06-07"} - "past7days" {:start "2016-05-31", :end "2016-06-06"} - "past30days" {:start "2016-05-08", :end "2016-06-06"} - "past2months" {:start "2016-04-01", :end "2016-05-31"} - "past2months~" {:start "2016-04-01", :end "2016-06-30"} - "past13months" {:start "2015-05-01", :end "2016-05-31"} - "past2quarters" {:start "2015-10-01", :end "2016-03-31"} - "past2quarters~" {:start "2015-10-01", :end "2016-06-30"} - "past1years" {:start "2015-01-01", :end "2015-12-31"} - "past1years~" {:start "2015-01-01", :end "2016-12-31"} - "past16years" {:start "2000-01-01", :end "2015-12-31"}} - "relative (next)" {"next45seconds" {:start "2016-06-07T12:13:56", :end "2016-06-07T12:14:40"} - "next20minutes" {:start "2016-06-07T12:14:00", :end "2016-06-07T12:33:00"} - "next6hours" {:start "2016-06-07T13:00:00", :end "2016-06-07T18:00:00"} - "next3days" {:start "2016-06-08", :end "2016-06-10"} - "next3days~" {:start "2016-06-07", :end "2016-06-10"} - "next7days" {:start "2016-06-08", :end "2016-06-14"} - "next30days" {:start "2016-06-08", :end "2016-07-07"} - "next2months" {:start "2016-07-01", :end "2016-08-31"} - "next2months~" {:start "2016-06-01", :end "2016-08-31"} - "next2quarters" {:start "2016-07-01", :end "2016-12-31"} - "next2quarters~" {:start "2016-04-01", :end "2016-12-31"} - "next13months" {:start "2016-07-01", :end "2017-07-31"} - "next1years" {:start "2017-01-01", :end "2017-12-31"} - "next1years~" {:start "2016-01-01", :end "2017-12-31"} - "next16years" {:start "2017-01-01", :end "2032-12-31"}} - "relative (this)" {"thissecond" {:start "2016-06-07T12:13:55", :end "2016-06-07T12:13:55"} - "thisminute" {:start "2016-06-07T12:13:00", :end "2016-06-07T12:13:00"} - "thishour" {:start "2016-06-07T12:00:00", :end "2016-06-07T12:00:00"} - "thisday" {:start "2016-06-07", :end "2016-06-07"} - "thisweek" {:start "2016-06-05", :end "2016-06-11"} - "thismonth" {:start "2016-06-01", :end "2016-06-30"} - "thisquarter" {:start "2016-04-01", :end "2016-06-30"} - "thisyear" {:start "2016-01-01", :end "2016-12-31"}} - "relative (last)" {"lastsecond" {:start "2016-06-07T12:13:54", :end "2016-06-07T12:13:54"} - "lastminute" {:start "2016-06-07T12:12:00", :end "2016-06-07T12:12:00"} - "lasthour" {:start "2016-06-07T11:00:00", :end "2016-06-07T11:00:00"} - "lastweek" {:start "2016-05-29", :end "2016-06-04"} - "lastmonth" {:start "2016-05-01", :end "2016-05-31"} - "lastquarter" {:start "2016-01-01", :end "2016-03-31"} - "lastyear" {:start "2015-01-01", :end "2015-12-31"}} - "relative (today/yesterday)" {"yesterday" {:start "2016-06-06", :end "2016-06-06"} - "today" {:start "2016-06-07", :end "2016-06-07"}} - "relative (past) with starting from" {"past1days-from-0days" {:start "2016-06-06", :end "2016-06-06"} - "past1months-from-0months" {:start "2016-05-01", :end "2016-05-31"} - "past1months-from-36months" {:start "2013-05-01", :end "2013-05-31"} - "past1years-from-36months" {:start "2012-01-01", :end "2012-12-31"} - "past3days-from-3years" {:start "2013-06-04", :end "2013-06-06"}} - "relative (next) with starting from" {"next2days-from-1months" {:start "2016-07-08", :end "2016-07-09"} - "next1months-from-0months" {:start "2016-07-01", :end "2016-07-31"} - "next1months-from-36months" {:start "2019-07-01", :end "2019-07-31"} - "next1years-from-36months" {:start "2020-01-01", :end "2020-12-31"} - "next3days-from-3years" {:start "2019-06-08", :end "2019-06-10"} - "next7hours-from-13months" {:start "2017-07-07T13:00:00", :end "2017-07-07T19:00:00"}}}] - (testing group - (doseq [[s inclusive-range] s->expected - [options range-xform] (letfn [(adjust [m k amount] - (if-not (get m k) - m - (update m k #(u.date/format (u.date/add (u.date/parse %) :day amount))))) - (adjust-start [m] - (adjust m :start -1)) - (adjust-end [m] - (adjust m :end 1))] - {nil identity - {:inclusive-start? false} adjust-start - {:inclusive-end? false} adjust-end - {:inclusive-start? false, :inclusive-end? false} (comp adjust-start adjust-end)}) - :let [expected (range-xform inclusive-range)]] - (is (= expected - (params.dates/date-string->range s options)) - (format "%s with options %s should parse to %s" (pr-str s) (pr-str options) (pr-str expected)))))))) + (doseq [[s ranges] s->expected + [expected-range option] (map vector ranges [nil + {:inclusive-start? false} + {:inclusive-end? false} + {:inclusive-start? false :inclusive-end? false}])] + (testing (format "%s with options %s should parse to %s" (pr-str s) (pr-str option) (pr-str ranges)) + (is (= expected-range + (params.dates/date-string->range s option))))))) + + +(deftest ^:parallel date-string->range-absolute-datetimes-test + (do-date-string-range-test + {"Q1-2016" [{:start "2016-01-01" :end "2016-03-31"} ;; inclusive start + end = true (default) + {:start "2015-12-31" :end "2016-03-31"} ;; inclusive start = false + {:start "2016-01-01" :end "2016-04-01"} ;; inclusive end = false + {:start "2015-12-31" :end "2016-04-01"}] ;; inclusive start + end = false + "2016-02" [{:start "2016-02-01" :end "2016-02-29"} + {:start "2016-01-31" :end "2016-02-29"} + {:start "2016-02-01" :end "2016-03-01"} + {:start "2016-01-31" :end "2016-03-01"}] + "2016-04-18" [{:start "2016-04-18" :end "2016-04-18"} + {:start "2016-04-17" :end "2016-04-18"} + {:start "2016-04-18" :end "2016-04-19"} + {:start "2016-04-17" :end "2016-04-19"}] + "2016-04-18~2016-04-23" [{:start "2016-04-18" :end "2016-04-23"} + {:start "2016-04-17" :end "2016-04-23"} + {:start "2016-04-18" :end "2016-04-24"} + {:start "2016-04-17" :end "2016-04-24"}] + "2016-04-18T10:30:00~2016-04-23T10:30:00" [{:start "2016-04-18T10:30:00" :end "2016-04-23T10:30:00"} + {:start "2016-04-18T10:29:00" :end "2016-04-23T10:30:00"} + {:start "2016-04-18T10:30:00" :end "2016-04-23T10:31:00"} + {:start "2016-04-18T10:29:00" :end "2016-04-23T10:31:00"}] + + "2016-04-18~" [{:start "2016-04-18"} + {:start "2016-04-17"} + {:start "2016-04-18"} + {:start "2016-04-17"}] + "~2016-04-18" [{:end "2016-04-18"} + {:end "2016-04-18"} + {:end "2016-04-19"} + {:end "2016-04-19"}]})) + +(deftest ^:parallel date-string->range-relative-past-test + (do-date-string-range-test + {"past30seconds" [{:start "2016-06-07T12:13:25" :end "2016-06-07T12:13:54"} + {:start "2016-06-07T12:13:24" :end "2016-06-07T12:13:54"} + {:start "2016-06-07T12:13:25" :end "2016-06-07T12:13:55"} + {:start "2016-06-07T12:13:24" :end "2016-06-07T12:13:55"}] + "past5minutes~" [{:start "2016-06-07T12:08:00" :end "2016-06-07T12:13:00"} + {:start "2016-06-07T12:07:00" :end "2016-06-07T12:13:00"} + {:start "2016-06-07T12:08:00" :end "2016-06-07T12:14:00"} + {:start "2016-06-07T12:07:00" :end "2016-06-07T12:14:00"}] + "past3hours" [{:start "2016-06-07T09:00:00" :end "2016-06-07T11:00:00"} + {:start "2016-06-07T08:00:00" :end "2016-06-07T11:00:00"} + {:start "2016-06-07T09:00:00" :end "2016-06-07T12:00:00"} + {:start "2016-06-07T08:00:00" :end "2016-06-07T12:00:00"}] + "past3days" [{:start "2016-06-04" :end "2016-06-06"} + {:start "2016-06-03" :end "2016-06-06"} + {:start "2016-06-04" :end "2016-06-07"} + {:start "2016-06-03" :end "2016-06-07"}] + "past3days~" [{:start "2016-06-04" :end "2016-06-07"} + {:start "2016-06-03" :end "2016-06-07"} + {:start "2016-06-04" :end "2016-06-08"} + {:start "2016-06-03" :end "2016-06-08"}] + "past7days" [{:start "2016-05-31" :end "2016-06-06"} + {:start "2016-05-30" :end "2016-06-06"} + {:start "2016-05-31" :end "2016-06-07"} + {:start "2016-05-30" :end "2016-06-07"}] + "past30days" [{:start "2016-05-08" :end "2016-06-06"} + {:start "2016-05-07" :end "2016-06-06"} + {:start "2016-05-08" :end "2016-06-07"} + {:start "2016-05-07" :end "2016-06-07"}] + "past2months" [{:start "2016-04-01" :end "2016-05-31"} + {:start "2016-03-31" :end "2016-05-31"} + {:start "2016-04-01" :end "2016-06-01"} + {:start "2016-03-31" :end "2016-06-01"}] + "past2months~" [{:start "2016-04-01" :end "2016-06-30"} + {:start "2016-03-31" :end "2016-06-30"} + {:start "2016-04-01" :end "2016-07-01"} + {:start "2016-03-31" :end "2016-07-01"}] + "past13months" [{:start "2015-05-01" :end "2016-05-31"} + {:start "2015-04-30" :end "2016-05-31"} + {:start "2015-05-01" :end "2016-06-01"} + {:start "2015-04-30" :end "2016-06-01"}] + "past2quarters" [{:start "2015-10-01" :end "2016-03-31"} + {:start "2015-09-30" :end "2016-03-31"} + {:start "2015-10-01" :end "2016-04-01"} + {:start "2015-09-30" :end "2016-04-01"}] + "past2quarters~" [{:start "2015-10-01" :end "2016-06-30"} + {:start "2015-09-30" :end "2016-06-30"} + {:start "2015-10-01" :end "2016-07-01"} + {:start "2015-09-30" :end "2016-07-01"}] + "past1years" [{:start "2015-01-01" :end "2015-12-31"} + {:start "2014-12-31" :end "2015-12-31"} + {:start "2015-01-01" :end "2016-01-01"} + {:start "2014-12-31" :end "2016-01-01"}] + "past1years~" [{:start "2015-01-01" :end "2016-12-31"} + {:start "2014-12-31" :end "2016-12-31"} + {:start "2015-01-01" :end "2017-01-01"} + {:start "2014-12-31" :end "2017-01-01"}] + "past16years" [{:start "2000-01-01" :end "2015-12-31"} + {:start "1999-12-31" :end "2015-12-31"} + {:start "2000-01-01" :end "2016-01-01"} + {:start "1999-12-31" :end "2016-01-01"}]})) + +(deftest ^:parallel date-string->range-relative-next-test + (do-date-string-range-test + {"next45seconds" [{:start "2016-06-07T12:13:56" :end "2016-06-07T12:14:40"} + {:start "2016-06-07T12:13:55" :end "2016-06-07T12:14:40"} + {:start "2016-06-07T12:13:56" :end "2016-06-07T12:14:41"} + {:start "2016-06-07T12:13:55" :end "2016-06-07T12:14:41"}] + "next20minutes" [{:start "2016-06-07T12:14:00" :end "2016-06-07T12:33:00"} + {:start "2016-06-07T12:13:00" :end "2016-06-07T12:33:00"} + {:start "2016-06-07T12:14:00" :end "2016-06-07T12:34:00"} + {:start "2016-06-07T12:13:00" :end "2016-06-07T12:34:00"}] + "next6hours" [{:start "2016-06-07T13:00:00" :end "2016-06-07T18:00:00"} + {:start "2016-06-07T12:00:00" :end "2016-06-07T18:00:00"} + {:start "2016-06-07T13:00:00" :end "2016-06-07T19:00:00"} + {:start "2016-06-07T12:00:00" :end "2016-06-07T19:00:00"}] + "next3days" [{:start "2016-06-08" :end "2016-06-10"} + {:start "2016-06-07" :end "2016-06-10"} + {:start "2016-06-08" :end "2016-06-11"} + {:start "2016-06-07" :end "2016-06-11"}] + "next3days~" [{:start "2016-06-07" :end "2016-06-10"} + {:start "2016-06-06" :end "2016-06-10"} + {:start "2016-06-07" :end "2016-06-11"} + {:start "2016-06-06" :end "2016-06-11"}] + "next7days" [{:start "2016-06-08" :end "2016-06-14"} + {:start "2016-06-07" :end "2016-06-14"} + {:start "2016-06-08" :end "2016-06-15"} + {:start "2016-06-07" :end "2016-06-15"}] + "next30days" [{:start "2016-06-08" :end "2016-07-07"} + {:start "2016-06-07" :end "2016-07-07"} + {:start "2016-06-08" :end "2016-07-08"} + {:start "2016-06-07" :end "2016-07-08"}] + "next2months" [{:start "2016-07-01" :end "2016-08-31"} + {:start "2016-06-30" :end "2016-08-31"} + {:start "2016-07-01" :end "2016-09-01"} + {:start "2016-06-30" :end "2016-09-01"}] + "next2months~" [{:start "2016-06-01" :end "2016-08-31"} + {:start "2016-05-31" :end "2016-08-31"} + {:start "2016-06-01" :end "2016-09-01"} + {:start "2016-05-31" :end "2016-09-01"}] + "next2quarters" [{:start "2016-07-01" :end "2016-12-31"} + {:start "2016-06-30" :end "2016-12-31"} + {:start "2016-07-01" :end "2017-01-01"} + {:start "2016-06-30" :end "2017-01-01"}] + "next2quarters~" [{:start "2016-04-01" :end "2016-12-31"} + {:start "2016-03-31" :end "2016-12-31"} + {:start "2016-04-01" :end "2017-01-01"} + {:start "2016-03-31" :end "2017-01-01"}] + "next13months" [{:start "2016-07-01" :end "2017-07-31"} + {:start "2016-06-30" :end "2017-07-31"} + {:start "2016-07-01" :end "2017-08-01"} + {:start "2016-06-30" :end "2017-08-01"}] + "next1years" [{:start "2017-01-01" :end "2017-12-31"} + {:start "2016-12-31" :end "2017-12-31"} + {:start "2017-01-01" :end "2018-01-01"} + {:start "2016-12-31" :end "2018-01-01"}] + "next1years~" [{:start "2016-01-01" :end "2017-12-31"} + {:start "2015-12-31" :end "2017-12-31"} + {:start "2016-01-01" :end "2018-01-01"} + {:start "2015-12-31" :end "2018-01-01"}] + "next16years" [{:start "2017-01-01" :end "2032-12-31"} + {:start "2016-12-31" :end "2032-12-31"} + {:start "2017-01-01" :end "2033-01-01"} + {:start "2016-12-31" :end "2033-01-01"}]})) + +(deftest ^:parallel date-string->range-relative-this-test + (do-date-string-range-test + {"thissecond" [{:start "2016-06-07T12:13:55" :end "2016-06-07T12:13:55"} + {:start "2016-06-07T12:13:54" :end "2016-06-07T12:13:55"} + {:start "2016-06-07T12:13:55" :end "2016-06-07T12:13:56"} + {:start "2016-06-07T12:13:54" :end "2016-06-07T12:13:56"}] + "thisminute" [{:start "2016-06-07T12:13:00" :end "2016-06-07T12:13:00"} + {:start "2016-06-07T12:12:00" :end "2016-06-07T12:13:00"} + {:start "2016-06-07T12:13:00" :end "2016-06-07T12:14:00"} + {:start "2016-06-07T12:12:00" :end "2016-06-07T12:14:00"}] + "thishour" [{:start "2016-06-07T12:00:00" :end "2016-06-07T12:00:00"} + {:start "2016-06-07T11:00:00" :end "2016-06-07T12:00:00"} + {:start "2016-06-07T12:00:00" :end "2016-06-07T13:00:00"} + {:start "2016-06-07T11:00:00" :end "2016-06-07T13:00:00"}] + "thisday" [{:start "2016-06-07" :end "2016-06-07"} + {:start "2016-06-06" :end "2016-06-07"} + {:start "2016-06-07" :end "2016-06-08"} + {:start "2016-06-06" :end "2016-06-08"}] + "thisweek" [{:start "2016-06-05" :end "2016-06-11"} + {:start "2016-06-04" :end "2016-06-11"} + {:start "2016-06-05" :end "2016-06-12"} + {:start "2016-06-04" :end "2016-06-12"}] + "thismonth" [{:start "2016-06-01" :end "2016-06-30"} + {:start "2016-05-31" :end "2016-06-30"} + {:start "2016-06-01" :end "2016-07-01"} + {:start "2016-05-31" :end "2016-07-01"}] + "thisquarter" [{:start "2016-04-01" :end "2016-06-30"} + {:start "2016-03-31" :end "2016-06-30"} + {:start "2016-04-01" :end "2016-07-01"} + {:start "2016-03-31" :end "2016-07-01"}] + "thisyear" [{:start "2016-01-01" :end "2016-12-31"} + {:start "2015-12-31" :end "2016-12-31"} + {:start "2016-01-01" :end "2017-01-01"} + {:start "2015-12-31" :end "2017-01-01"}]})) + +(deftest ^:parallel date-string->range-relative-last-test + (do-date-string-range-test + {"lastsecond" [{:start "2016-06-07T12:13:54" :end "2016-06-07T12:13:54"} + {:start "2016-06-07T12:13:53" :end "2016-06-07T12:13:54"} + {:start "2016-06-07T12:13:54" :end "2016-06-07T12:13:55"} + {:start "2016-06-07T12:13:53" :end "2016-06-07T12:13:55"}] + "lastminute" [{:start "2016-06-07T12:12:00" :end "2016-06-07T12:12:00"} + {:start "2016-06-07T12:11:00" :end "2016-06-07T12:12:00"} + {:start "2016-06-07T12:12:00" :end "2016-06-07T12:13:00"} + {:start "2016-06-07T12:11:00" :end "2016-06-07T12:13:00"}] + "lasthour" [{:start "2016-06-07T11:00:00" :end "2016-06-07T11:00:00"} + {:start "2016-06-07T10:00:00" :end "2016-06-07T11:00:00"} + {:start "2016-06-07T11:00:00" :end "2016-06-07T12:00:00"} + {:start "2016-06-07T10:00:00" :end "2016-06-07T12:00:00"}] + "lastweek" [{:start "2016-05-29" :end "2016-06-04"} + {:start "2016-05-28" :end "2016-06-04"} + {:start "2016-05-29" :end "2016-06-05"} + {:start "2016-05-28" :end "2016-06-05"}] + "lastmonth" [{:start "2016-05-01" :end "2016-05-31"} + {:start "2016-04-30" :end "2016-05-31"} + {:start "2016-05-01" :end "2016-06-01"} + {:start "2016-04-30" :end "2016-06-01"}] + "lastquarter" [{:start "2016-01-01" :end "2016-03-31"} + {:start "2015-12-31" :end "2016-03-31"} + {:start "2016-01-01" :end "2016-04-01"} + {:start "2015-12-31" :end "2016-04-01"}] + "lastyear" [{:start "2015-01-01" :end "2015-12-31"} + {:start "2014-12-31" :end "2015-12-31"} + {:start "2015-01-01" :end "2016-01-01"} + {:start "2014-12-31" :end "2016-01-01"}]})) + +(deftest ^:parallel date-string->range-relative-today-yesterday-test + (do-date-string-range-test + {"yesterday" [{:start "2016-06-06" :end "2016-06-06"} + {:start "2016-06-05" :end "2016-06-06"} + {:start "2016-06-06" :end "2016-06-07"} + {:start "2016-06-05" :end "2016-06-07"}] + "today" [{:start "2016-06-07" :end "2016-06-07"} + {:start "2016-06-06" :end "2016-06-07"} + {:start "2016-06-07" :end "2016-06-08"} + {:start "2016-06-06" :end "2016-06-08"}]})) + +(deftest ^:parallel date-string->range-relative-past-from-test + (do-date-string-range-test + {"past1days-from-0days" [{:start "2016-06-06" :end "2016-06-06"} + {:start "2016-06-05" :end "2016-06-06"} + {:start "2016-06-06" :end "2016-06-07"} + {:start "2016-06-05" :end "2016-06-07"}] + "past1months-from-0months" [{:start "2016-05-01" :end "2016-05-31"} + {:start "2016-04-30" :end "2016-05-31"} + {:start "2016-05-01" :end "2016-06-01"} + {:start "2016-04-30" :end "2016-06-01"}] + "past1months-from-36months" [{:start "2013-05-01" :end "2013-05-31"} + {:start "2013-04-30" :end "2013-05-31"} + {:start "2013-05-01" :end "2013-06-01"} + {:start "2013-04-30" :end "2013-06-01"}] + "past1years-from-36months" [{:start "2012-01-01" :end "2012-12-31"} + {:start "2011-12-31" :end "2012-12-31"} + {:start "2012-01-01" :end "2013-01-01"} + {:start "2011-12-31" :end "2013-01-01"}] + "past3days-from-3years" [{:start "2013-06-04" :end "2013-06-06"} + {:start "2013-06-03" :end "2013-06-06"} + {:start "2013-06-04" :end "2013-06-07"} + {:start "2013-06-03" :end "2013-06-07"}]})) + +(deftest ^:parallel date-string->range-relative-next-from-test + (do-date-string-range-test + {"next2days-from-1months" [{:start "2016-07-08" :end "2016-07-09"} + {:start "2016-07-07" :end "2016-07-09"} + {:start "2016-07-08" :end "2016-07-10"} + {:start "2016-07-07" :end "2016-07-10"}] + "next1months-from-0months" [{:start "2016-07-01" :end "2016-07-31"} + {:start "2016-06-30" :end "2016-07-31"} + {:start "2016-07-01" :end "2016-08-01"} + {:start "2016-06-30" :end "2016-08-01"}] + "next1months-from-36months" [{:start "2019-07-01" :end "2019-07-31"} + {:start "2019-06-30" :end "2019-07-31"} + {:start "2019-07-01" :end "2019-08-01"} + {:start "2019-06-30" :end "2019-08-01"}] + "next1years-from-36months" [{:start "2020-01-01" :end "2020-12-31"} + {:start "2019-12-31" :end "2020-12-31"} + {:start "2020-01-01" :end "2021-01-01"} + {:start "2019-12-31" :end "2021-01-01"}] + "next3days-from-3years" [{:start "2019-06-08" :end "2019-06-10"} + {:start "2019-06-07" :end "2019-06-10"} + {:start "2019-06-08" :end "2019-06-11"} + {:start "2019-06-07" :end "2019-06-11"}] + "next7hours-from-13months" [{:start "2017-07-07T13:00:00" :end "2017-07-07T19:00:00"} + {:start "2017-07-07T12:00:00" :end "2017-07-07T19:00:00"} + {:start "2017-07-07T13:00:00" :end "2017-07-07T20:00:00"} + {:start "2017-07-07T12:00:00" :end "2017-07-07T20:00:00"}]})) (deftest ^:parallel relative-dates-with-starting-from-zero-must-match (testing "relative dates need to behave the same way, offset or not." (mt/with-clock #t "2016-06-07T12:13:55Z" (testing "'past1months-from-0months' should be the same as: 'past1months'" - (is (= {:start "2016-05-01", :end "2016-05-31"} + (is (= {:start "2016-05-01" :end "2016-05-31"} (params.dates/date-string->range "past1months") (params.dates/date-string->range "past1months-from-0months")))) (testing "'next1months-from-0months' should be the same as: 'next1months'" - (is (= {:start "2016-07-01", :end "2016-07-31"} + (is (= {:start "2016-07-01" :end "2016-07-31"} (params.dates/date-string->range "next1months") (params.dates/date-string->range "next1months-from-0months"))))))) @@ -251,13 +462,13 @@ (deftest custom-start-of-week-test (testing "Relative filters should respect the custom `start-of-week` Setting (#14294)" (mt/with-clock #t "2021-03-01T14:15:00-08:00[US/Pacific]" - (doseq [[first-day-of-week expected] {"sunday" {:start "2021-02-21", :end "2021-02-27"} - "monday" {:start "2021-02-22", :end "2021-02-28"} - "tuesday" {:start "2021-02-16", :end "2021-02-22"} - "wednesday" {:start "2021-02-17", :end "2021-02-23"} - "thursday" {:start "2021-02-18", :end "2021-02-24"} - "friday" {:start "2021-02-19", :end "2021-02-25"} - "saturday" {:start "2021-02-20", :end "2021-02-26"}}] + (doseq [[first-day-of-week expected] {"sunday" {:start "2021-02-21" :end "2021-02-27"} + "monday" {:start "2021-02-22" :end "2021-02-28"} + "tuesday" {:start "2021-02-16" :end "2021-02-22"} + "wednesday" {:start "2021-02-17" :end "2021-02-23"} + "thursday" {:start "2021-02-18" :end "2021-02-24"} + "friday" {:start "2021-02-19" :end "2021-02-25"} + "saturday" {:start "2021-02-20" :end "2021-02-26"}}] (mt/with-temporary-setting-values [start-of-week first-day-of-week] (is (= expected (params.dates/date-string->range "past1weeks")))))))) diff --git a/test/metabase/models/revision_test.clj b/test/metabase/models/revision_test.clj index 8ca1788dd2189f1dd53c29ee9b064e0a00e01db2..352fe1a5fedaa539fd7f082a0452695f13809456 100644 --- a/test/metabase/models/revision_test.clj +++ b/test/metabase/models/revision_test.clj @@ -108,240 +108,272 @@ (revision/revisions ::FakedCard card-id)))))) (deftest add-revision-test - (testing "Test that we can add a revision" - (t2.with-temp/with-temp [Card {card-id :id}] - (push-fake-revision! card-id, :name "Tips Created by Day", :message "yay!") - (is (= [(mi/instance - Revision - {:model "FakedCard" - :user_id (mt/user->id :rasta) - :object (mi/instance 'FakedCard {:name "Tips Created by Day", :serialized true}) - :is_reversion false - :is_creation false - :message "yay!" - :metabase_version config/mb-version-string})] - (for [revision (revision/revisions ::FakedCard card-id)] - (dissoc revision :timestamp :id :model_id))))))) + (mt/with-model-cleanup [:model/Revision] + (testing "Test that we can add a revision" + (t2.with-temp/with-temp [Card {card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day", :message "yay!") + (is (=? [(mi/instance + Revision + {:model "FakedCard" + :user_id (mt/user->id :rasta) + :object (mi/instance ::FakedCard {:name "Tips Created by Day", :serialized true}) + :is_reversion false + :is_creation false + :message "yay!"})] + (for [revision (revision/revisions ::FakedCard card-id)] + (dissoc revision :timestamp :id :model_id)))))) + + (testing "test that most_recent is correct" + (t2.with-temp/with-temp [Card {card-id :id}] + (doseq [i (range 3)] + (push-fake-revision! card-id :name (format "%d Tips Created by Day" i) :message "yay!")) + (is (=? [{:model "FakedCard" + :model_id card-id + :most_recent true} + {:model "FakedCard" + :model_id card-id + :most_recent false} + {:model "FakedCard" + :model_id card-id + :most_recent false}] + (t2/select :model/Revision :model "FakedCard" :model_id card-id {:order-by [[:timestamp :desc] [:id :desc]]}))))))) + +(deftest update-revision-does-not-update-timestamp-test + ;; Realistically this only happens on mysql and mariadb for some reasons + ;; and we can't update revision anyway, except for when we need to change most_recent + (t2.with-temp/with-temp [Card {card-id :id} {}] + (let [revision (first (t2/insert-returning-instances! :model/Revision {:model "Card" + :user_id (mt/user->id :crowberto) + :model_id card-id + :object {} + :most_recent false}))] + (t2/update! (t2/table-name :model/Revision) (:id revision) {:most_recent true}) + (is (= (:timestamp revision) + (t2/select-one-fn :timestamp :model/Revision (:id revision))))))) (deftest sorting-test (testing "Test that revisions are sorted in reverse chronological order" - (t2.with-temp/with-temp [Card {card-id :id}] - (push-fake-revision! card-id, :name "Tips Created by Day") - (push-fake-revision! card-id, :name "Spots Created by Day") - (testing `revision/revisions - (is (= [(mi/instance - Revision - {:model "FakedCard" - :user_id (mt/user->id :rasta) - :object (mi/instance 'FakedCard {:name "Spots Created by Day", :serialized true}) - :is_reversion false - :is_creation false - :message nil - :metabase_version config/mb-version-string}) - (mi/instance - Revision - {:model "FakedCard" - :user_id (mt/user->id :rasta) - :object (mi/instance 'FakedCard {:name "Tips Created by Day", :serialized true}) - :is_reversion false - :is_creation false - :message nil - :metabase_version config/mb-version-string})] - (->> (revision/revisions ::FakedCard card-id) - (map #(dissoc % :timestamp :id :model_id))))))))) + (mt/with-model-cleanup [:model/Revision] + (t2.with-temp/with-temp [Card {card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day") + (push-fake-revision! card-id, :name "Spots Created by Day") + (testing "revision/revisions" + (is (=? [(mi/instance + Revision + {:model "FakedCard" + :user_id (mt/user->id :rasta) + :object (mi/instance ::FakedCard {:name "Spots Created by Day", :serialized true}) + :is_reversion false + :is_creation false + :message nil}) + (mi/instance + Revision + {:model "FakedCard" + :user_id (mt/user->id :rasta) + :object (mi/instance ::FakedCard {:name "Tips Created by Day", :serialized true}) + :is_reversion false + :is_creation false + :message nil})] + (->> (revision/revisions ::FakedCard card-id) + (map #(dissoc % :timestamp :id :model_id)))))))))) (deftest delete-old-revisions-test (testing "Check that old revisions get deleted" - (t2.with-temp/with-temp [Card {card-id :id}] - ;; e.g. if max-revisions is 15 then insert 16 revisions - (dorun (doseq [i (range (inc revision/max-revisions))] - (push-fake-revision! card-id, :name (format "Tips Created by Day %d" i)))) - (is (= revision/max-revisions - (count (revision/revisions ::FakedCard card-id))))))) + (mt/with-model-cleanup [:model/Revision] + (t2.with-temp/with-temp [Card {card-id :id}] + ;; e.g. if max-revisions is 15 then insert 16 revisions + (dorun (doseq [i (range (inc revision/max-revisions))] + (push-fake-revision! card-id, :name (format "Tips Created by Day %d" i)))) + (is (= revision/max-revisions + (count (revision/revisions ::FakedCard card-id)))))))) (deftest do-not-record-if-object-is-not-changed-test - (testing "Check that we don't record a revision if the object hasn't changed" - (t2.with-temp/with-temp [Card {card-id :id}] - (let [new-revision (fn [x] - (push-fake-revision! card-id, :name (format "Tips Created by Day %s" x)))] - (testing "first revision should be recorded" - (new-revision 1) - (is (= 1 (count (revision/revisions ::FakedCard card-id))))) - - (testing "repeatedly push reivisions with the same object shouldn't create new revision" - (dorun (repeatedly 5 #(new-revision 1))) - (is (= 1 (count (revision/revisions ::FakedCard card-id))))) - - (testing "push a revision with different object should create new revision" - (new-revision 2) - (is (= 2 (count (revision/revisions ::FakedCard card-id)))))))) - - (testing "Check that we don't record revision on dashboard if it has a filter" - (t2.with-temp/with-temp - [:model/Dashboard {dash-id :id} {:parameters [{:name "Category Name" - :slug "category_name" - :id "_CATEGORY_NAME_" - :type "category"}]} - :model/Card {card-id :id} {} - :model/DashboardCard {} {:dashboard_id dash-id - :card_id card-id - :parameter_mappings [{:parameter_id "_CATEGORY_NAME_" - :card_id card-id - :target [:dimension (mt/$ids $categories.name)]}]}] - (let [push-revision (fn [] (revision/push-revision! - :entity :model/Dashboard - :id dash-id - :user-id (mt/user->id :rasta) - :object (t2/select-one :model/Dashboard dash-id)))] - (testing "first revision should be recorded" - (push-revision) - (is (= 1 (count (revision/revisions :model/Dashboard dash-id))))) - (testing "push again without changes shouldn't record new revision" - (push-revision) - (is (= 1 (count (revision/revisions :model/Dashboard dash-id))))) - (testing "now do some updates and new revision should be reocrded" - (t2/update! :model/Dashboard :id dash-id {:name "New name"}) - (push-revision) - (is (= 2 (count (revision/revisions :model/Dashboard dash-id))))))))) + (mt/with-model-cleanup [:model/Revision] + (testing "Check that we don't record a revision if the object hasn't changed" + (mt/with-model-cleanup [:model/Revision] + (t2.with-temp/with-temp [Card {card-id :id}] + (let [new-revision (fn [x] + (push-fake-revision! card-id, :name (format "Tips Created by Day %s" x)))] + (testing "first revision should be recorded" + (new-revision 1) + (is (= 1 (count (revision/revisions ::FakedCard card-id))))) + + (testing "repeatedly push reivisions with the same object shouldn't create new revision" + (dorun (repeatedly 5 #(new-revision 1))) + (is (= 1 (count (revision/revisions ::FakedCard card-id))))) + + (testing "push a revision with different object should create new revision" + (new-revision 2) + (is (= 2 (count (revision/revisions ::FakedCard card-id)))))))) + + (testing "Check that we don't record revision on dashboard if it has a filter" + (t2.with-temp/with-temp + [:model/Dashboard {dash-id :id} {:parameters [{:name "Category Name" + :slug "category_name" + :id "_CATEGORY_NAME_" + :type "category"}]} + :model/Card {card-id :id} {} + :model/DashboardCard {} {:dashboard_id dash-id + :card_id card-id + :parameter_mappings [{:parameter_id "_CATEGORY_NAME_" + :card_id card-id + :target [:dimension (mt/$ids $categories.name)]}]}] + (let [push-revision (fn [] (revision/push-revision! + :entity :model/Dashboard + :id dash-id + :user-id (mt/user->id :rasta) + :object (t2/select-one :model/Dashboard dash-id)))] + (testing "first revision should be recorded" + (push-revision) + (is (= 1 (count (revision/revisions :model/Dashboard dash-id))))) + (testing "push again without changes shouldn't record new revision" + (push-revision) + (is (= 1 (count (revision/revisions :model/Dashboard dash-id))))) + (testing "now do some updates and new revision should be reocrded" + (t2/update! :model/Dashboard :id dash-id {:name "New name"}) + (push-revision) + (is (= 2 (count (revision/revisions :model/Dashboard dash-id))))))))))) ;;; # REVISIONS+DETAILS (deftest add-revision-details-test - (testing "Test that add-revision-details properly enriches our revision objects" - (t2.with-temp/with-temp [Card {card-id :id}] - (push-fake-revision! card-id, :name "Initial Name") - (push-fake-revision! card-id, :name "Modified Name") - (is (= {:is_creation false - :is_reversion false - :message nil - :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"} - :diff {:o1 {:name "Initial Name", :serialized true} - :o2 {:name "Modified Name", :serialized true}} - :has_multiple_changes false - :description "BEFORE={:name \"Initial Name\", :serialized true},AFTER={:name \"Modified Name\", :serialized true}." - :metabase_version config/mb-version-string} - (let [revisions (revision/revisions ::FakedCard card-id)] - (assert (= 2 (count revisions))) - (-> (revision/add-revision-details ::FakedCard (first revisions) (last revisions)) - (dissoc :timestamp :id :model_id) - mt/derecordize)))))) - - (testing "test that we return a description even when there is no change between revision" - (is (= "created a revision with no change." - (str (:description (revision/add-revision-details ::FakedCard {:name "Apple"} {:name "Apple"})))))) - - (testing "that we return a descrtiopn when there is no previous revision" - (is (= "modified this." - (str (:description (revision/add-revision-details ::FakedCard {:name "Apple"} nil))))))) + (mt/with-model-cleanup [:model/Revision] + (testing "Test that add-revision-details properly enriches our revision objects" + (t2.with-temp/with-temp [Card {card-id :id}] + (push-fake-revision! card-id, :name "Initial Name") + (push-fake-revision! card-id, :name "Modified Name") + (is (=? {:is_creation false + :is_reversion false + :message nil + :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"} + :diff {:o1 {:name "Initial Name", :serialized true} + :o2 {:name "Modified Name", :serialized true}} + :has_multiple_changes false + :description "BEFORE={:name \"Initial Name\", :serialized true},AFTER={:name \"Modified Name\", :serialized true}."} + (let [revisions (revision/revisions ::FakedCard card-id)] + (assert (= 2 (count revisions))) + (-> (revision/add-revision-details ::FakedCard (first revisions) (last revisions)) + (dissoc :timestamp :id :model_id) + mt/derecordize)))))) + + (testing "test that we return a description even when there is no change between revision" + (is (= "created a revision with no change." + (str (:description (revision/add-revision-details ::FakedCard {:name "Apple"} {:name "Apple"})))))) + + (testing "that we return a descrtiopn when there is no previous revision" + (is (= "modified this." + (str (:description (revision/add-revision-details ::FakedCard {:name "Apple"} nil)))))))) (deftest revisions+details-test - (testing "Check that revisions+details pulls in user info and adds description" - (t2.with-temp/with-temp [Card {card-id :id}] - (push-fake-revision! card-id, :name "Tips Created by Day") - (is (= [(mi/instance - Revision - {:is_reversion false, - :is_creation false, - :message nil, - :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, - :diff {:o1 nil - :o2 {:name "Tips Created by Day", :serialized true}} - :has_multiple_changes false - :description "modified this." - :metabase_version config/mb-version-string})] - (->> (revision/revisions+details ::FakedCard card-id) - (map #(dissoc % :timestamp :id :model_id)) - (map #(update % :description str)))))))) + (mt/with-model-cleanup [:model/Revision] + (testing "Check that revisions+details pulls in user info and adds description" + (t2.with-temp/with-temp [Card {card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day") + (is (=? [(mi/instance + Revision + {:is_reversion false, + :is_creation false, + :message nil, + :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, + :diff {:o1 nil + :o2 {:name "Tips Created by Day", :serialized true}} + :has_multiple_changes false + :description "modified this."})] + (->> (revision/revisions+details ::FakedCard card-id) + (map #(dissoc % :timestamp :id :model_id)) + (map #(update % :description str))))))))) (deftest defer-to-describe-diff-test - (testing "Check that revisions properly defer to describe-diff" - (t2.with-temp/with-temp [Card {card-id :id}] - (push-fake-revision! card-id, :name "Tips Created by Day") - (push-fake-revision! card-id, :name "Spots Created by Day") - (is (= [(mi/instance - Revision - {:is_reversion false, - :is_creation false, - :message nil - :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, - :diff {:o1 {:name "Tips Created by Day", :serialized true} - :o2 {:name "Spots Created by Day", :serialized true}} - :has_multiple_changes false - :description (str "BEFORE={:name \"Tips Created by Day\", :serialized true},AFTER=" - "{:name \"Spots Created by Day\", :serialized true}.") - :metabase_version config/mb-version-string}) - (mi/instance - Revision - {:is_reversion false, - :is_creation false, - :message nil - :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, - :diff {:o1 nil - :o2 {:name "Tips Created by Day", :serialized true}} - :has_multiple_changes false - :description "modified this." - :metabase_version config/mb-version-string})] - (->> (revision/revisions+details ::FakedCard card-id) - (map #(dissoc % :timestamp :id :model_id)) - (map #(update % :description str)))))))) + (mt/with-model-cleanup [:model/Revision] + (testing "Check that revisions properly defer to describe-diff" + (t2.with-temp/with-temp [Card {card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day") + (push-fake-revision! card-id, :name "Spots Created by Day") + (is (=? [(mi/instance + Revision + {:is_reversion false, + :is_creation false, + :message nil + :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, + :diff {:o1 {:name "Tips Created by Day", :serialized true} + :o2 {:name "Spots Created by Day", :serialized true}} + :has_multiple_changes false + :description (str "BEFORE={:name \"Tips Created by Day\", :serialized true},AFTER=" + "{:name \"Spots Created by Day\", :serialized true}.")}) + (mi/instance + Revision + {:is_reversion false, + :is_creation false, + :message nil + :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, + :diff {:o1 nil + :o2 {:name "Tips Created by Day", :serialized true}} + :has_multiple_changes false + :description "modified this."})] + (->> (revision/revisions+details ::FakedCard card-id) + (map #(dissoc % :timestamp :id :model_id)) + (map #(update % :description str))))))))) ;;; # REVERT (deftest revert-defer-to-revert-to-revision!-test - (testing "Check that revert defers to revert-to-revision!" - (t2.with-temp/with-temp [Card {card-id :id}] - (push-fake-revision! card-id, :name "Tips Created by Day") - (let [[{revision-id :id}] (revision/revisions ::FakedCard card-id)] - (revision/revert! :entity ::FakedCard, :id card-id, :user-id (mt/user->id :rasta), :revision-id revision-id) - (is (= {:name "Tips Created by Day"} - @reverted-to)))))) + (mt/with-model-cleanup [:model/Revision] + (testing "Check that revert defers to revert-to-revision!" + (t2.with-temp/with-temp [Card {card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day") + (let [[{revision-id :id}] (revision/revisions ::FakedCard card-id)] + (revision/revert! :entity ::FakedCard, :id card-id, :user-id (mt/user->id :rasta), :revision-id revision-id) + (is (= {:name "Tips Created by Day"} + @reverted-to))))))) (deftest revert-to-revision!-default-impl-test - (testing "Check default impl of revert-to-revision! just does mapply upd" - (t2.with-temp/with-temp [Card {card-id :id} {:name "Spots Created By Day"}] - (revision/push-revision! :entity Card, :id card-id, :user-id (mt/user->id :rasta), :object {:name "Tips Created by Day"}) - (revision/push-revision! :entity Card, :id card-id, :user-id (mt/user->id :rasta), :object {:name "Spots Created by Day"}) - (is (= "Spots Created By Day" - (:name (t2/select-one Card :id card-id)))) - (let [[_ {old-revision-id :id}] (revision/revisions Card card-id)] - (revision/revert! :entity Card, :id card-id, :user-id (mt/user->id :rasta), :revision-id old-revision-id) - (is (= "Tips Created by Day" - (:name (t2/select-one Card :id card-id)))))))) + (mt/with-model-cleanup [:model/Revision] + (testing "Check default impl of revert-to-revision! just does mapply upd" + (t2.with-temp/with-temp [Card {card-id :id} {:name "Spots Created By Day"}] + (revision/push-revision! :entity Card, :id card-id, :user-id (mt/user->id :rasta), :object {:name "Tips Created by Day"}) + (revision/push-revision! :entity Card, :id card-id, :user-id (mt/user->id :rasta), :object {:name "Spots Created by Day"}) + (is (= "Spots Created By Day" + (:name (t2/select-one Card :id card-id)))) + (let [[_ {old-revision-id :id}] (revision/revisions Card card-id)] + (revision/revert! :entity Card, :id card-id, :user-id (mt/user->id :rasta), :revision-id old-revision-id) + (is (= "Tips Created by Day" + (:name (t2/select-one Card :id card-id))))))))) (deftest reverting-should-add-revision-test - (testing "Check that reverting to a previous revision adds an appropriate revision" - (t2.with-temp/with-temp [Card {card-id :id}] - (push-fake-revision! card-id, :name "Tips Created by Day") - (push-fake-revision! card-id, :name "Spots Created by Day") - (let [[_ {old-revision-id :id}] (revision/revisions ::FakedCard card-id)] - (revision/revert! :entity ::FakedCard, :id card-id, :user-id (mt/user->id :rasta), :revision-id old-revision-id) - (is (partial= - [(mi/instance - Revision - {:model "FakedCard" - :user_id (mt/user->id :rasta) - :object {:name "Tips Created by Day", :serialized true} - :is_reversion true - :is_creation false - :message nil}) - (mi/instance - Revision - {:model "FakedCard", - :user_id (mt/user->id :rasta) - :object {:name "Spots Created by Day", :serialized true} - :is_reversion false - :is_creation false - :message nil}) - (mi/instance - Revision - {:model "FakedCard", - :user_id (mt/user->id :rasta) - :object {:name "Tips Created by Day", :serialized true} - :is_reversion false - :is_creation false - :message nil})] - (->> (revision/revisions ::FakedCard card-id) - (map #(dissoc % :timestamp :id :model_id))))))))) + (mt/with-model-cleanup [:model/Revision] + (testing "Check that reverting to a previous revision adds an appropriate revision" + (t2.with-temp/with-temp [Card {card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day") + (push-fake-revision! card-id, :name "Spots Created by Day") + (let [[_ {old-revision-id :id}] (revision/revisions ::FakedCard card-id)] + (revision/revert! :entity ::FakedCard, :id card-id, :user-id (mt/user->id :rasta), :revision-id old-revision-id) + (is (partial= + [(mi/instance + Revision + {:model "FakedCard" + :user_id (mt/user->id :rasta) + :object {:name "Tips Created by Day", :serialized true} + :is_reversion true + :is_creation false + :message nil}) + (mi/instance + Revision + {:model "FakedCard", + :user_id (mt/user->id :rasta) + :object {:name "Spots Created by Day", :serialized true} + :is_reversion false + :is_creation false + :message nil}) + (mi/instance + Revision + {:model "FakedCard", + :user_id (mt/user->id :rasta) + :object {:name "Tips Created by Day", :serialized true} + :is_reversion false + :is_creation false + :message nil})] + (->> (revision/revisions ::FakedCard card-id) + (map #(dissoc % :timestamp :id :model_id)))))))))) (deftest generic-models-revision-title+description-test (do-with-model-i18n-strs! diff --git a/test/metabase/search/filter_test.clj b/test/metabase/search/filter_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..93b3d4d841e0ca4688c536c4a837e91422109a16 --- /dev/null +++ b/test/metabase/search/filter_test.clj @@ -0,0 +1,413 @@ +(ns ^:mb/once metabase.search.filter-test + (:require + [clojure.test :refer :all] + [metabase.public-settings.premium-features :as premium-features] + [metabase.public-settings.premium-features-test :as premium-features-test] + [metabase.search.config :as search.config] + [metabase.search.filter :as search.filter] + [metabase.test :as mt])) + +(def ^:private default-search-ctx + {:search-string nil + :archived? false + :models search.config/all-models + :current-user-perms #{"/"}}) + +(deftest ^:parallel ->applicable-models-test + (testing "without optional filters" + (testing "return :models as is" + (is (= search.config/all-models + (search.filter/search-context->applicable-models + default-search-ctx))) + + (is (= #{} + (search.filter/search-context->applicable-models + (assoc default-search-ctx :models #{})))) + + (is (= search.config/all-models + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:archived? true})))))) + + (testing "optional filters will return intersection of support models and provided models\n" + (testing "created by" + (is (= #{"dashboard" "dataset" "action" "card"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:created-by #{1}})))) + + (is (= #{"dashboard" "dataset"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:models #{"dashboard" "dataset" "table"} + :created-by #{1}}))))) + + (testing "created at" + (is (= #{"dashboard" "table" "dataset" "collection" "database" "action" "card"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:created-at "past3days"})))) + + (is (= #{"dashboard" "table" "dataset"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:models #{"dashboard" "dataset" "table"} + :created-at "past3days"}))))) + + (testing "verified" + (is (= #{"dataset" "card"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:verified true})))) + + (is (= #{"dataset"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:models #{"dashboard" "dataset" "table"} + :verified true}))))) + + (testing "last edited by" + (is (= #{"dashboard" "dataset" "card" "metric"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:last-edited-by #{1}})))) + + (is (= #{"dashboard" "dataset"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:models #{"dashboard" "dataset" "table"} + :last-edited-by #{1}}))))) + + (testing "last edited at" + (is (= #{"dashboard" "dataset" "action" "metric" "card"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:last-edited-at "past3days"})))) + + (is (= #{"dashboard" "dataset"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:models #{"dashboard" "dataset" "table"} + :last-edited-at "past3days"}))))) + + (testing "search native query" + (is (= #{"dataset" "action" "card"} + (search.filter/search-context->applicable-models + (merge default-search-ctx + {:search-native-query true}))))))) + +(deftest joined-with-table?-test + (are [expected args] + (= expected (apply #'search.filter/joined-with-table? args)) + + false + [{} :join :a] + + true + [{:join [:a [:= :a.b :c.d]]} :join :a] + + false + [{:join [:a [:= :a.b :c.d]]} :join :d] + + ;; work with multiple join types + false + [{:join [:a [:= :a.b :c.d]]} :left-join :d] + + ;; do the same with other join types too + true + [{:left-join [:a [:= :a.b :c.d]]} :left-join :a] + + false + [{:left-join [:a [:= :a.b :c.d]]} :left-join :d])) + + +(def ^:private base-search-query + {:select [:*] + :from [:table]}) + +(deftest ^:parallel build-archived-filter-test + (testing "archived filters" + (is (= [:= :card.archived false] + (:where (search.filter/build-filters + base-search-query "card" default-search-ctx)))) + + (is (= [:and [:= :table.active true] [:= :table.visibility_type nil]] + (:where (search.filter/build-filters + base-search-query "table" default-search-ctx)))))) + +(deftest ^:parallel build-filter-with-search-string-test + (testing "with search string" + (is (= [:and + [:or + [:like [:lower :card.name] "%a%"] + [:like [:lower :card.name] "%string%"] + [:like [:lower :card.description] "%a%"] + [:like [:lower :card.description] "%string%"]] + [:= :card.archived false]] + (:where (search.filter/build-filters + base-search-query "card" + (merge default-search-ctx {:search-string "a string"}))))))) + +(deftest date-range-filter-clause-test + (mt/with-clock #t "2023-05-04T10:02:05Z[UTC]" + (are [created-at expected-where] + (= expected-where (#'search.filter/date-range-filter-clause :card.created_at created-at)) + ;; absolute datetime + "Q1-2023" [:and [:>= [:cast :card.created_at :date] #t "2023-01-01"] + [:< [:cast :card.created_at :date] #t "2023-04-01"]] + "2016-04-18~2016-04-23" [:and [:>= [:cast :card.created_at :date] #t "2016-04-18"] + [:< [:cast :card.created_at :date] #t "2016-04-24"]] + "2016-04-18" [:and [:>= [:cast :card.created_at :date] #t "2016-04-18"] + [:< [:cast :card.created_at :date] #t "2016-04-19"]] + "2023-05-04~" [:> [:cast :card.created_at :date] #t "2023-05-04"] + "~2023-05-04" [:< [:cast :card.created_at :date] #t "2023-05-05"] + "2016-04-18T10:30:00~2016-04-23T11:30:00" [:and [:>= :card.created_at #t "2016-04-18T10:30"] + [:< :card.created_at #t "2016-04-23T11:31:00"]] + "2016-04-23T10:00:00" [:and [:>= :card.created_at #t "2016-04-23T10:00"] + [:< :card.created_at #t "2016-04-23T10:01"]] + "2016-04-18T10:30:00~" [:> :card.created_at #t "2016-04-18T10:30"] + "~2016-04-18T10:30:00" [:< :card.created_at #t "2016-04-18T10:31"] + ;; relative datetime + "past3days" [:and [:>= [:cast :card.created_at :date] #t "2023-05-01"] + [:< [:cast :card.created_at :date] #t "2023-05-04"]] + "past3days~" [:and [:>= [:cast :card.created_at :date] #t "2023-05-01"] + [:< [:cast :card.created_at :date] #t "2023-05-05"]] + "past3hours~" [:and [:>= :card.created_at #t "2023-05-04T07:00"] + [:< :card.created_at #t "2023-05-04T11:00"]] + "next3days" [:and [:>= [:cast :card.created_at :date] #t "2023-05-05"] + [:< [:cast :card.created_at :date] #t "2023-05-08"]] + "thisminute" [:and [:>= :card.created_at #t "2023-05-04T10:02"] + [:< :card.created_at #t "2023-05-04T10:03"]] + "lasthour" [:and [:>= :card.created_at #t "2023-05-04T09:00"] + [:< :card.created_at #t "2023-05-04T10:00"]] + "past1months-from-36months" [:and [:>= [:cast :card.created_at :date] #t "2020-04-01"] + [:< [:cast :card.created_at :date] #t "2020-05-01"]] + "today" [:and [:>= [:cast :card.created_at :date] #t "2023-05-04"] + [:< [:cast :card.created_at :date] #t "2023-05-05"]] + "yesterday" [:and [:>= [:cast :card.created_at :date] #t "2023-05-03"] + [:< [:cast :card.created_at :date] #t "2023-05-04"]]))) + +;; both created at and last-edited-at use [[search.filter/date-range-filter-clause]] +;; to generate the filter clause so for the full test cases, check [[date-range-filter-clause-test]] +;; these 2 tests are for checking the shape of the query +(deftest ^:parallel created-at-filter-test + (testing "created-at filter" + (is (= {:select [:*] + :from [:table] + :where [:and + [:= :card.archived false] + [:>= [:cast :card.created_at :date] #t "2016-04-18"] + [:< [:cast :card.created_at :date] #t "2016-04-24"]]} + (search.filter/build-filters + base-search-query "card" + (merge default-search-ctx {:created-at "2016-04-18~2016-04-23"})))))) + +(deftest ^:parallel last-edited-at-filter-test + (testing "last edited at filter" + (is (= {:select [:*] + :from [:table] + :join [:revision [:= :revision.model_id :card.id]] + :where [:and + [:= :card.archived false] + [:= :revision.most_recent true] + [:= :revision.model "Card"] + [:>= [:cast :revision.timestamp :date] #t "2016-04-18"] + [:< [:cast :revision.timestamp :date] #t "2016-04-24"]]} + (search.filter/build-filters + base-search-query "dataset" + (merge default-search-ctx {:last-edited-at "2016-04-18~2016-04-23"})))) + + (testing "do not join twice if has both last-edited-at and last-edited-by" + (is (= {:select [:*] + :from [:table] + :join [:revision [:= :revision.model_id :card.id]] + :where [:and + [:= :card.archived false] + [:= :revision.most_recent true] + [:= :revision.model "Card"] + [:>= [:cast :revision.timestamp :date] #t "2016-04-18"] + [:< [:cast :revision.timestamp :date] #t "2016-04-24"] + [:= :revision.user_id 1]]} + (search.filter/build-filters + base-search-query "dataset" + (merge default-search-ctx {:last-edited-at "2016-04-18~2016-04-23" + :last-edited-by #{1}}))))) + + (testing "for actiion" + (is (= {:select [:*] + :from [:table] + :where [:and [:= :action.archived false] + [:>= [:cast :action.updated_at :date] #t "2016-04-18"] + [:< [:cast :action.updated_at :date] #t "2016-04-24"]]} + (search.filter/build-filters + base-search-query "action" + (merge default-search-ctx {:last-edited-at "2016-04-18~2016-04-23"}))))))) + +(deftest ^:parallel build-created-by-filter-test + (testing "created-by filter" + (is (= [:and [:= :card.archived false] [:= :card.creator_id 1]] + (:where (search.filter/build-filters + base-search-query "card" + (merge default-search-ctx + {:created-by #{1}}))))) + (is (= [:and [:= :card.archived false] [:in :card.creator_id #{1 2}]] + (:where (search.filter/build-filters + base-search-query "card" + (merge default-search-ctx + {:created-by #{1 2}}))))))) + +(deftest ^:parallel build-last-edited-by-filter-test + (testing "last edited by filter" + (is (= {:select [:*] + :from [:table] + :where [:and + [:= :card.archived false] + [:= :revision.most_recent true] + [:= :revision.model "Card"] + [:= :revision.user_id 1]] + :join [:revision [:= :revision.model_id :card.id]]} + (search.filter/build-filters + base-search-query "dataset" + (merge default-search-ctx + {:last-edited-by #{1}}))))) + + (testing "last edited by filter" + (is (= {:select [:*] + :from [:table] + :where [:and + [:= :card.archived false] + [:= :revision.most_recent true] + [:= :revision.model "Card"] + [:in :revision.user_id #{1 2}]] + :join [:revision [:= :revision.model_id :card.id]]} + (search.filter/build-filters + base-search-query "dataset" + (merge default-search-ctx + {:last-edited-by #{1 2}})))))) + +(deftest build-verified-filter-test + (testing "verified filter" + (premium-features-test/with-premium-features #{:content-verification} + (testing "for cards" + (is (= (merge + base-search-query + {:where [:and + [:= :card.archived false] + [:= :moderation_review.status "verified"] + [:= :moderation_review.moderated_item_type "card"] + [:= :moderation_review.most_recent true]] + :join [:moderation_review [:= :moderation_review.moderated_item_id :card.id]]}) + (search.filter/build-filters + base-search-query "card" + (merge default-search-ctx {:verified true}))))) + + (testing "for models" + (is (= (merge + base-search-query + {:where [:and + [:= :card.archived false] + [:= :moderation_review.status "verified"] + [:= :moderation_review.moderated_item_type "card"] + [:= :moderation_review.most_recent true]] + :join [:moderation_review [:= :moderation_review.moderated_item_id :card.id]]}) + (search.filter/build-filters + base-search-query "dataset" + (merge default-search-ctx {:verified true})))))) + + (premium-features-test/with-premium-features #{} + (testing "for cards without ee features" + (is (= (merge + base-search-query + {:where [:and + [:= :card.archived false] + [:inline [:= 0 1]]]}) + (search.filter/build-filters + base-search-query "card" + (merge default-search-ctx {:verified true}))))) + + (testing "for models without ee features" + (is (= (merge + base-search-query + {:where [:and + [:= :card.archived false] + [:inline [:= 0 1]]]}) + (search.filter/build-filters + base-search-query "dataset" + (merge default-search-ctx {:verified true})))))))) + +(deftest ^:parallel build-filter-throw-error-for-unsuported-filters-test + (testing "throw error for filtering with unsupport models" + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #":created-by filter for database is not supported" + (search.filter/build-filters + base-search-query + "database" + (merge default-search-ctx + {:created-by #{1}})))))) + +(deftest build-filters-indexed-entity-test + (testing "users that are not sandboxed or impersonated can search for indexed entity" + (with-redefs [premium-features/sandboxed-or-impersonated-user? (constantly false)] + (is (= [:and + [:or [:like [:lower :model-index-value.name] "%foo%"]] + [:inline [:= 1 1]]] + (:where (search.filter/build-filters + base-search-query + "indexed-entity" + (merge default-search-ctx {:search-string "foo"}))))))) + + (testing "otherwise search result is empty" + (with-redefs [premium-features/sandboxed-or-impersonated-user? (constantly true)] + (is (= [:and + [:or [:= 0 1]] + [:inline [:= 1 1]]] + (:where (search.filter/build-filters + base-search-query + "indexed-entity" + (merge default-search-ctx {:search-string "foo"})))))))) + +(deftest build-filters-search-native-query + (doseq [model ["dataset" "card"]] + (testing model + (testing "do not search for native query by default" + (is (= [:and + [:or [:like [:lower :card.name] "%foo%"] [:like [:lower :card.description] "%foo%"]] + [:= :card.archived false]] + (:where (search.filter/build-filters + base-search-query + model + (merge default-search-ctx {:search-string "foo"})))))) + + (testing "search in both name, description and dataset_query if is enabled" + (is (= [:and [:or + [:like [:lower :card.name] "%foo%"] + [:and [:= :card.query_type "native"] [:like [:lower :card.dataset_query] "%foo%"]] + [:like [:lower :card.description] "%foo%"]] + [:= :card.archived false]] + (:where (search.filter/build-filters + base-search-query + model + (merge default-search-ctx {:search-string "foo" :search-native-query true}))))))) + + (testing "action" + (testing "do not search for native query by default" + (is (= [:and + [:or [:like [:lower :action.name] "%foo%"] [:like [:lower :action.description] "%foo%"]] + [:= :action.archived false]] + (:where (search.filter/build-filters + base-search-query + "action" + (merge default-search-ctx {:search-string "foo"})))))) + + (testing "search in both name, description and dataset_query if is enabled" + (is (= [:and + [:or + [:like [:lower :action.name] "%foo%"] + [:like [:lower :query_action.dataset_query] "%foo%"] + [:like [:lower :action.description] "%foo%"]] + [:= :action.archived false]] + (:where (search.filter/build-filters + base-search-query + "action" + (merge default-search-ctx {:search-string "foo" :search-native-query true}))))))))) diff --git a/test/metabase/search/scoring_test.clj b/test/metabase/search/scoring_test.clj index 879f43a67d1b05f4d5a02e943ea48c278583a13a..aaf168aef4d9591f65c6f5e62dd5961ba1d30353 100644 --- a/test/metabase/search/scoring_test.clj +++ b/test/metabase/search/scoring_test.clj @@ -1,8 +1,9 @@ (ns metabase.search.scoring-test (:require + [cheshire.core :as json] [clojure.test :refer :all] [java-time.api :as t] - [metabase.search.config :as search-config] + [metabase.search.config :as search.config] [metabase.search.scoring :as scoring])) (defn- result-row @@ -136,7 +137,7 @@ (is (= (map :result items) (scoring/top-results items large xf))))) (testing "a full queue only saves the top items" - (let [sorted-items (->> (+ small search-config/max-filtered-results) + (let [sorted-items (->> (+ small search.config/max-filtered-results) range reverse ;; descending order (map (fn [i] @@ -217,7 +218,7 @@ reverse (map :id))))) (testing "it treats stale items as being equally old" - (let [stale search-config/stale-time-in-days] + (let [stale search.config/stale-time-in-days] (is (= [1 2 3 4] (->> [(item 1 (days-ago (+ stale 1))) (item 2 (days-ago (+ stale 50))) @@ -272,7 +273,21 @@ {:weight 100 :score 0 :name "Some other score type"}])] (is (= 0 (:score (scoring/score-and-result "" {:name "racing yo" :model "card"}))))))) -(deftest force-weight-test +(deftest ^:parallel serialize-test + (testing "It normalizes dataset queries from strings" + (let [query {:type :query + :query {:source-query {:source-table 1}} + :database 1} + result {:name "card" + :model "card" + :dataset_query (json/generate-string query)}] + (is (= query (-> result (#'scoring/serialize {} {}) :dataset_query))))) + (testing "Doesn't error on other models without a query" + (is (nil? (-> {:name "dash" :model "dashboard"} + (#'scoring/serialize {} {}) + :dataset_query))))) + +(deftest ^:parallel force-weight-test (is (= [{:weight 10}] (scoring/force-weight [{:weight 1}] 10))) diff --git a/test/metabase/search/util_test.clj b/test/metabase/search/util_test.clj index 116d1cfadf625cdd7d5b9a3426a83fce7b8c4e8f..f9f9d822b7a1af49e63fedfc15086f6734e49c80 100644 --- a/test/metabase/search/util_test.clj +++ b/test/metabase/search/util_test.clj @@ -1,23 +1,23 @@ (ns metabase.search.util-test (:require [clojure.test :refer :all] - [metabase.search.util :as search-util])) + [metabase.search.util :as search.util])) (deftest ^:parallel tokenize-test (testing "basic tokenization" (is (= ["Rasta" "the" "Toucan's" "search"] - (search-util/tokenize "Rasta the Toucan's search"))) + (search.util/tokenize "Rasta the Toucan's search"))) (is (= ["Rasta" "the" "Toucan"] - (search-util/tokenize " Rasta\tthe \tToucan "))) + (search.util/tokenize " Rasta\tthe \tToucan "))) (is (= [] - (search-util/tokenize " \t\n\t "))) + (search.util/tokenize " \t\n\t "))) (is (= [] - (search-util/tokenize ""))) + (search.util/tokenize ""))) (is (thrown-with-msg? Exception #"should be a string" - (search-util/tokenize nil))))) + (search.util/tokenize nil))))) (deftest ^:parallel test-largest-common-subseq-length - (let [subseq-length (partial search-util/largest-common-subseq-length =)] + (let [subseq-length (partial search.util/largest-common-subseq-length =)] (testing "greedy choice can't be taken" (is (= 3 (subseq-length ["garden" "path" "this" "is" "not" "a" "garden" "path"] diff --git a/test/metabase/test.clj b/test/metabase/test.clj index b0635ddd3de8ad297265f8485e22cd40c3d9cf3c..e2e48d825fb66808ca56d8d26fdb0b34e887e1ed 100644 --- a/test/metabase/test.clj +++ b/test/metabase/test.clj @@ -244,7 +244,8 @@ with-temp-vals-in-db with-temporary-setting-values with-temporary-raw-setting-values - with-user-in-groups] + with-user-in-groups + with-verified-cards] [tu.async wait-for-result diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj index 6beb24a169220afc754449932d5ff4529dea335e..808f09ded64dc3decd26601631d2bb4e97700710 100644 --- a/test/metabase/test/util.clj +++ b/test/metabase/test/util.clj @@ -28,6 +28,7 @@ User]] [metabase.models.collection :as collection] [metabase.models.interface :as mi] + [metabase.models.moderation-review :as moderation-review] [metabase.models.permissions :as perms] [metabase.models.permissions-group :as perms-group] [metabase.models.setting :as setting] @@ -118,6 +119,9 @@ :model/Collection (fn [_] (default-created-at-timestamped {:name (tu.random/random-name)})) + :model/Action + (fn [_] {:creator_id (rasta-id)}) + :model/Dashboard (fn [_] (default-timestamped {:creator_id (rasta-id) @@ -556,6 +560,8 @@ ~@body))) + + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | SCHEDULER | ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -704,6 +710,49 @@ (testing "Shouldn't delete other Cards" (is (pos? (t2/count Card))))))))) +(defn do-with-verified-cards + "Impl for [[with-verified-cards]]." + [card-or-ids thunk] + (with-model-cleanup [:model/ModerationReview] + (doseq [card-or-id card-or-ids] + (doseq [status ["verified" nil "verified"]] + ;; create multiple moderation review for a card, but the end result is it's still verified + (moderation-review/create-review! + {:moderated_item_id (u/the-id card-or-id) + :moderated_item_type "card" + :moderator_id ((requiring-resolve 'metabase.test.data.users/user->id) :rasta) + :status status}))) + (thunk))) + +(defmacro with-verified-cards + "Execute the body with all `card-or-ids` verified." + [card-or-ids & body] + `(do-with-verified-cards ~card-or-ids (fn [] ~@body))) + +(deftest with-verified-cards-test + (t2.with-temp/with-temp + [:model/Card {card-id :id} {}] + (with-verified-cards [card-id] + (is (=? #{{:moderated_item_id card-id + :moderated_item_type :card + :most_recent true + :status "verified"} + {:moderated_item_id card-id + :moderated_item_type :card + :most_recent false + :status nil} + {:moderated_item_id card-id + :moderated_item_type :card + :most_recent false + :status "verified"}} + (t2/select-fn-set #(select-keys % [:moderated_item_id :moderated_item_type :most_recent :status]) + :model/ModerationReview + :moderated_item_id card-id + :moderated_item_type "card")))) + (testing "everything is cleaned up after the macro" + (is (= 0 (t2/count :model/ModerationReview + :moderated_item_id card-id + :moderated_item_type "card")))))) ;; TODO - not 100% sure I understand (defn call-with-paused-query