Compare commits
140 Commits
python-run
...
make-searc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca7935bea | ||
|
|
1cee43f381 | ||
|
|
e16211ab2b | ||
|
|
d0c9871eab | ||
|
|
7e0d994203 | ||
|
|
8f088a7835 | ||
|
|
6c76fd36c4 | ||
|
|
1a564d3520 | ||
|
|
31d0e03f30 | ||
|
|
b66459f644 | ||
|
|
7b7b660056 | ||
|
|
5b540f4d65 | ||
|
|
eaf1ececc4 | ||
|
|
38b4595e6c | ||
|
|
36e5cc2d95 | ||
|
|
d996a5cf9a | ||
|
|
f545a4d634 | ||
|
|
0fb96c3b25 | ||
|
|
1d12a1bff2 | ||
|
|
24a1958a82 | ||
|
|
80bb91690c | ||
|
|
e6fca179c4 | ||
|
|
8e687d1fed | ||
|
|
146da3bd92 | ||
|
|
d6dba0b9f7 | ||
|
|
e57dc1eca6 | ||
|
|
ae21008363 | ||
|
|
7db84816dc | ||
|
|
965d324912 | ||
|
|
9498bcc152 | ||
|
|
d5e1a1c215 | ||
|
|
8f3ec8beab | ||
|
|
8d926c6ea9 | ||
|
|
03678e4e55 | ||
|
|
1a006b2dd2 | ||
|
|
e9f8b5390d | ||
|
|
23029db26d | ||
|
|
7d5c4daf85 | ||
|
|
e8b93000bd | ||
|
|
85de08d12b | ||
|
|
cfc62492f3 | ||
|
|
3592d02bac | ||
|
|
ef9329f15f | ||
|
|
94e8471d08 | ||
|
|
7b18a67c09 | ||
|
|
00e7e73878 | ||
|
|
cbd4a9f90b | ||
|
|
89d4004c1e | ||
|
|
0b2d499bf7 | ||
|
|
fd978928f6 | ||
|
|
276348f2f2 | ||
|
|
be759bd91f | ||
|
|
ec3db0d4b8 | ||
|
|
5c0e2a1a93 | ||
|
|
6929159f04 | ||
|
|
803b7a0b8e | ||
|
|
61984359ed | ||
|
|
87985bc2da | ||
|
|
3e385fbad9 | ||
|
|
3eb14ce157 | ||
|
|
817b3d0e17 | ||
|
|
dff6ccb99e | ||
|
|
a287b1896b | ||
|
|
d5af52606a | ||
|
|
5fe25bf0d7 | ||
|
|
8c21e30e9a | ||
|
|
fd503617b3 | ||
|
|
dfd7164791 | ||
|
|
747506db0c | ||
|
|
612afb80fb | ||
|
|
b9d9585294 | ||
|
|
7a5451fb47 | ||
|
|
7fc021b9cf | ||
|
|
36060882b7 | ||
|
|
517c54d948 | ||
|
|
5a9d8e0bae | ||
|
|
2682493059 | ||
|
|
7bd8b0d077 | ||
|
|
fa8e3ac9d3 | ||
|
|
37b2cc851a | ||
|
|
0d7f5a6578 | ||
|
|
ceca1b0dd8 | ||
|
|
a04ffc2ee8 | ||
|
|
93b3051cb3 | ||
|
|
e6a9fa8aa2 | ||
|
|
b55a0ff492 | ||
|
|
f022e61bc3 | ||
|
|
fac62c76e3 | ||
|
|
8781ffed9c | ||
|
|
59b7fb84ca | ||
|
|
19808cb2e9 | ||
|
|
99069f692a | ||
|
|
8dfa2e4826 | ||
|
|
83a9c73193 | ||
|
|
f3991f50e0 | ||
|
|
2b77f67dbe | ||
|
|
31ec2bda32 | ||
|
|
1efcb2870f | ||
|
|
978deef162 | ||
|
|
c678d91b7c | ||
|
|
4298cb93f0 | ||
|
|
52148308f9 | ||
|
|
dd794348b7 | ||
|
|
506d37d889 | ||
|
|
c08fc16e22 | ||
|
|
79a4a1f5bd | ||
|
|
848f0ab6be | ||
|
|
6d3d4c602c | ||
|
|
351a5516dd | ||
|
|
058059ad80 | ||
|
|
0a37e91a8e | ||
|
|
cc173c1084 | ||
|
|
2bdea851ad | ||
|
|
cf46ea89f3 | ||
|
|
3908ca2fb9 | ||
|
|
1209f7e6da | ||
|
|
91cdac4138 | ||
|
|
a174f295ff | ||
|
|
bb9707a3c8 | ||
|
|
66325f89c0 | ||
|
|
d9703e868c | ||
|
|
81136d88ae | ||
|
|
4bda5f2f0a | ||
|
|
656261531b | ||
|
|
eaca06eaad | ||
|
|
001bfc2f6a | ||
|
|
5ab143660c | ||
|
|
069d427081 | ||
|
|
f6f0bc9420 | ||
|
|
83a3395981 | ||
|
|
f815f990b8 | ||
|
|
11fdaa55bd | ||
|
|
f44cef6eea | ||
|
|
3a9a06d848 | ||
|
|
9885a61d09 | ||
|
|
539892c85a | ||
|
|
5e083d8529 | ||
|
|
d23a485341 | ||
|
|
af476e8a73 | ||
|
|
85b981e7f8 |
@@ -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
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
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 {
|
||||
NORMAL_USER_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
ORDERS_COUNT_QUESTION_ID,
|
||||
ADMIN_USER_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";
|
||||
|
||||
@@ -47,21 +56,52 @@ const typeFilters = [
|
||||
{
|
||||
label: "Action",
|
||||
filterName: "action",
|
||||
resultInfoText: "for",
|
||||
},
|
||||
];
|
||||
|
||||
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();
|
||||
|
||||
@@ -122,41 +162,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();
|
||||
cy.visit("/");
|
||||
|
||||
setActionsEnabledForDB(SAMPLE_DB_ID);
|
||||
getSearchBar().click().type("{enter}");
|
||||
|
||||
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.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,10 +205,574 @@ 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}`);
|
||||
describe("type filter", () => {
|
||||
beforeEach(() => {
|
||||
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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
typeFilters.forEach(({ label, filterName, resultInfoText }) => {
|
||||
it(`should hydrate search with search text and ${label} filter`, () => {
|
||||
cy.visit(`/search?q=e&type=${filterName}`);
|
||||
cy.wait("@search");
|
||||
|
||||
getSearchBar().should("have.value", "e");
|
||||
|
||||
cy.findByTestId("search-app").within(() => {
|
||||
cy.findByText('Results for "e"').should("exist");
|
||||
});
|
||||
|
||||
cy.findAllByTestId("result-link-info-text").each(result => {
|
||||
cy.wrap(result).should("contain.text", resultInfoText);
|
||||
});
|
||||
|
||||
cy.findByTestId("type-search-filter").within(() => {
|
||||
cy.findByText(label).should("exist");
|
||||
cy.findByLabelText("close icon").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
cy.findAllByTestId("result-link-info-text").each(result => {
|
||||
if (resultInfoText) {
|
||||
cy.wrap(result).should("contain.text", resultInfoText);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove type filter when `X` is clicked on search filter", () => {
|
||||
const { label, filterName } = typeFilters[0];
|
||||
cy.visit(`/search?q=e&type=${filterName}`);
|
||||
cy.wait("@search");
|
||||
|
||||
cy.findByTestId("type-search-filter").within(() => {
|
||||
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");
|
||||
|
||||
// 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(
|
||||
$resultTypeDescriptions => {
|
||||
const uniqueTypeDescriptions = new Set(
|
||||
$resultTypeDescriptions.toArray().map(el => el.textContent),
|
||||
);
|
||||
expect(uniqueTypeDescriptions.size).to.be.greaterThan(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("created_by filter", () => {
|
||||
beforeEach(() => {
|
||||
// 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");
|
||||
});
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [NORMAL_USER_TEST_QUESTION.name, ADMIN_TEST_QUESTION.name],
|
||||
});
|
||||
|
||||
// TODO: Add more assertions for search results when we redesign the search result elements to include users.
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [NORMAL_USER_TEST_QUESTION.name],
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [NORMAL_USER_TEST_QUESTION.name, ADMIN_TEST_QUESTION.name],
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [NORMAL_USER_TEST_QUESTION.name, ADMIN_TEST_QUESTION.name],
|
||||
});
|
||||
|
||||
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}`);
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [NORMAL_USER_TEST_QUESTION.name],
|
||||
});
|
||||
|
||||
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("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.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");
|
||||
});
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [LAST_EDITED_BY_NORMAL_USER_QUESTION.name],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter last_edited results by one user", () => {
|
||||
cy.visit("/");
|
||||
|
||||
getSearchBar().clear().type("reviews{enter}");
|
||||
cy.wait("@search");
|
||||
|
||||
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("Apply").click();
|
||||
});
|
||||
cy.url().should("contain", "last_edited_by");
|
||||
|
||||
cy.findByTestId("search-result-item-name").should(
|
||||
"contain.text",
|
||||
LAST_EDITED_BY_NORMAL_USER_QUESTION.name,
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter last_edited results by more than user", () => {
|
||||
cy.visit("/");
|
||||
|
||||
getSearchBar().clear().type("reviews{enter}");
|
||||
cy.wait("@search");
|
||||
|
||||
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");
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [
|
||||
LAST_EDITED_BY_NORMAL_USER_QUESTION.name,
|
||||
LAST_EDITED_BY_ADMIN_QUESTION.name,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [
|
||||
LAST_EDITED_BY_NORMAL_USER_QUESTION.name,
|
||||
LAST_EDITED_BY_ADMIN_QUESTION.name,
|
||||
],
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [LAST_EDITED_BY_ADMIN_QUESTION.name],
|
||||
});
|
||||
});
|
||||
|
||||
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}`,
|
||||
);
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [
|
||||
LAST_EDITED_BY_NORMAL_USER_QUESTION.name,
|
||||
LAST_EDITED_BY_ADMIN_QUESTION.name,
|
||||
],
|
||||
});
|
||||
|
||||
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("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");
|
||||
});
|
||||
|
||||
// TODO: Add more tests once the search result elements are redesigned to include 'created_at' times.
|
||||
});
|
||||
});
|
||||
|
||||
// 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],
|
||||
});
|
||||
|
||||
cy.findByTestId("created_at-search-filter").click();
|
||||
popover().within(() => {
|
||||
cy.findByText("Today").click();
|
||||
});
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [NORMAL_USER_TEST_QUESTION.name],
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove created_at filter when `X` is clicked on search filter", () => {
|
||||
cy.visit(`/search?q=Reviews&created_at=thisday`);
|
||||
cy.wait("@search");
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [NORMAL_USER_TEST_QUESTION.name],
|
||||
});
|
||||
|
||||
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],
|
||||
});
|
||||
|
||||
// TODO: Add more assertions for search results when we redesign the search result elements to include
|
||||
// creation times
|
||||
});
|
||||
});
|
||||
|
||||
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=orders&last_edited_at=${filter}`);
|
||||
|
||||
cy.wait("@search");
|
||||
|
||||
cy.findByTestId("last_edited_at-search-filter").within(() => {
|
||||
cy.findByText(label).should("exist");
|
||||
cy.findByLabelText("close icon").should("exist");
|
||||
});
|
||||
|
||||
// TODO: Add more tests once the search result elements are redesigned to include 'last_edited_at' times.
|
||||
});
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [LAST_EDITED_BY_NORMAL_USER_QUESTION.name],
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [LAST_EDITED_BY_NORMAL_USER_QUESTION.name],
|
||||
});
|
||||
|
||||
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,
|
||||
],
|
||||
});
|
||||
|
||||
// TODO: Add more assertions for search results when we redesign the search result elements to include
|
||||
// "last edited" times
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -177,96 +781,172 @@ describe("scenarios > search", () => {
|
||||
cy.findByText('Results for "orders"').should("exist");
|
||||
});
|
||||
|
||||
cy.findAllByTestId("result-link-info-text").each(result => {
|
||||
cy.wrap(result).should("contain.text", resultInfoText);
|
||||
});
|
||||
|
||||
cy.findByTestId("type-search-filter").within(() => {
|
||||
cy.findByText("Question").should("exist");
|
||||
cy.findByLabelText("close icon").should("exist");
|
||||
cy.findAllByTestId("search-result-item").each(result => {
|
||||
cy.wrap(result).within(() => {
|
||||
cy.findByLabelText("verified icon").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
it("should filter results by verified items", () => {
|
||||
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", "/");
|
||||
});
|
||||
|
||||
it("should render full page search when search text is present and user clicks 'Enter'", () => {
|
||||
cy.visit("/");
|
||||
|
||||
getSearchBar().click().type("orders{enter}");
|
||||
getSearchBar().clear().type("e{enter}");
|
||||
cy.wait("@search");
|
||||
|
||||
cy.findByTestId("search-app").within(() => {
|
||||
cy.findByText('Results for "orders"').should("exist");
|
||||
cy.findByTestId("verified-search-filter")
|
||||
.findByTestId("toggle-filter-switch")
|
||||
.click();
|
||||
|
||||
cy.findAllByTestId("search-result-item").each(result => {
|
||||
cy.wrap(result).within(() => {
|
||||
cy.findByLabelText("verified icon").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not filter results when verified items is off", () => {
|
||||
cy.visit("/search?q=e&verified=true");
|
||||
|
||||
cy.wait("@search");
|
||||
|
||||
cy.findByTestId("verified-search-filter")
|
||||
.findByTestId("toggle-filter-switch")
|
||||
.click();
|
||||
cy.url().should("not.include", "verified=true");
|
||||
|
||||
let verifiedElementCount = 0;
|
||||
let unverifiedElementCount = 0;
|
||||
cy.findAllByTestId("search-result-item")
|
||||
.each($el => {
|
||||
if (!$el.find('[aria-label="verified 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.location().should(loc => {
|
||||
expect(loc.pathname).to.eq("/search");
|
||||
expect(loc.search).to.eq("?q=orders");
|
||||
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: ["Native Query", TEST_NATIVE_QUESTION_NAME],
|
||||
});
|
||||
});
|
||||
|
||||
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").within(() => {
|
||||
cy.findByTestId("toggle-filter-switch").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").within(() => {
|
||||
cy.findByTestId("toggle-filter-switch").click();
|
||||
});
|
||||
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [TEST_NATIVE_QUESTION_NAME],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("search filters", () => {
|
||||
describe("type filters", () => {
|
||||
typeFilters.forEach(({ label, resultInfoText }) => {
|
||||
it(`should filter results by ${label}`, () => {
|
||||
cy.visit("/");
|
||||
it("should persist filters when the user changes the text query", () => {
|
||||
cy.visit("/search?q=orders");
|
||||
|
||||
getSearchBar().clear().type("e{enter}");
|
||||
cy.wait("@search");
|
||||
// add created_by filter
|
||||
cy.findByTestId("created_by-search-filter").click();
|
||||
popover().within(() => {
|
||||
cy.findByText("Bobby Tables").click();
|
||||
cy.findByText("Apply").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("type-search-filter").click();
|
||||
popover().within(() => {
|
||||
cy.findByText(label).click();
|
||||
cy.findByText("Apply filters").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();
|
||||
});
|
||||
|
||||
cy.findAllByTestId("result-link-info-text").each(result => {
|
||||
if (resultInfoText) {
|
||||
cy.wrap(result).should("contain.text", resultInfoText);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// add type filter
|
||||
cy.findByTestId("type-search-filter").click();
|
||||
popover().within(() => {
|
||||
cy.findByText("Question").click();
|
||||
cy.findByText("Apply").click();
|
||||
});
|
||||
|
||||
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");
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [
|
||||
"Orders",
|
||||
"Orders, Count",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
],
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
getSearchBar().clear().type("count{enter}");
|
||||
|
||||
cy.url().should("not.contain", "type");
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
expectSearchResultItemNameContent({
|
||||
itemNames: [
|
||||
"Orders, Count",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -305,3 +985,14 @@ function getProductsSearchResults() {
|
||||
function getSearchBar() {
|
||||
return cy.findByPlaceholderText("Search…");
|
||||
}
|
||||
|
||||
function expectSearchResultItemNameContent({ itemNames }) {
|
||||
cy.findAllByTestId("search-result-item-name").then($searchResultLabel => {
|
||||
const searchResultLabelList = $searchResultLabel
|
||||
.toArray()
|
||||
.map(el => el.textContent);
|
||||
|
||||
expect(searchResultLabelList).to.have.length(itemNames.length);
|
||||
expect(searchResultLabelList).to.include.members(itemNames);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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."))))))
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VerifiedFilter";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import "./sandboxes";
|
||||
import "./auth";
|
||||
import "./caching";
|
||||
import "./collections";
|
||||
import "./content_verification";
|
||||
import "./whitelabel";
|
||||
import "./embedding";
|
||||
import "./snippets";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const createMockUser = (opts?: Partial<User>): User => ({
|
||||
...opts,
|
||||
});
|
||||
|
||||
export const createMockerUserListResult = (
|
||||
export const createMockUserListResult = (
|
||||
opts?: Partial<UserListResult>,
|
||||
): UserListResult => ({
|
||||
id: 1,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
import cx from "classnames";
|
||||
|
||||
import DatePicker from "metabase/query_builder/components/filters/pickers/DatePicker/DatePicker";
|
||||
import type { DateOperator } from "metabase/query_builder/components/filters/pickers/DatePicker/DatePicker";
|
||||
import DatePicker, {
|
||||
DATE_OPERATORS,
|
||||
} from "metabase/query_builder/components/filters/pickers/DatePicker/DatePicker";
|
||||
import { filterToUrlEncoded } from "metabase/parameters/utils/date-formatting";
|
||||
|
||||
import {
|
||||
@@ -10,23 +13,33 @@ import {
|
||||
UpdateButton,
|
||||
} from "metabase/parameters/components/widgets/Widget.styled";
|
||||
|
||||
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";
|
||||
|
||||
// Use a placeholder value as field references are not used in dashboard filters
|
||||
const noopRef = null;
|
||||
|
||||
interface DateAllOptionsWidgetProps {
|
||||
export type DateAllOptionsWidgetProps = {
|
||||
setValue: (value: string | null) => void;
|
||||
value?: string;
|
||||
onClose: () => void;
|
||||
value?: string | null;
|
||||
onClose?: () => void;
|
||||
disableOperatorSelection?: boolean;
|
||||
}
|
||||
className?: string;
|
||||
withPadding?: boolean;
|
||||
operators?: DateOperator[];
|
||||
dateShortcutOptions?: DateShortcutOptions;
|
||||
};
|
||||
|
||||
export const DateAllOptionsWidget = ({
|
||||
setValue,
|
||||
onClose,
|
||||
disableOperatorSelection,
|
||||
value,
|
||||
className,
|
||||
withPadding = true,
|
||||
dateShortcutOptions = DATE_SHORTCUT_OPTIONS,
|
||||
operators = DATE_OPERATORS,
|
||||
}: DateAllOptionsWidgetProps) => {
|
||||
const [filter, setFilter] = useState(
|
||||
value != null ? dateParameterValueToMBQL(value, noopRef) || [] : [],
|
||||
@@ -43,7 +56,7 @@ export const DateAllOptionsWidget = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<WidgetRoot>
|
||||
<WidgetRoot className={className}>
|
||||
<DatePicker
|
||||
filter={filter as any}
|
||||
onFilterChange={setFilter}
|
||||
@@ -51,6 +64,9 @@ export const DateAllOptionsWidget = ({
|
||||
hideEmptinessOperators
|
||||
disableOperatorSelection={disableOperatorSelection}
|
||||
supportsExpressions
|
||||
operators={operators}
|
||||
withPadding={withPadding}
|
||||
dateShortcutOptions={dateShortcutOptions}
|
||||
>
|
||||
<UpdateButton
|
||||
className={cx({
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { renderWithProviders, screen } from "__support__/ui";
|
||||
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 type { DateAllOptionsWidgetProps } from "metabase/components/DateAllOptionsWidget/DateAllOptionsWidget";
|
||||
import { DateAllOptionsWidget } from "metabase/components/DateAllOptionsWidget/DateAllOptionsWidget";
|
||||
|
||||
const TEST_DAY_MONTH_OPTIONS: Record<string, string> = {
|
||||
Today: "thisday",
|
||||
Yesterday: "past1days",
|
||||
"Last Week": "past1weeks",
|
||||
"Last 7 Days": "past7days",
|
||||
"Last 30 Days": "past30days",
|
||||
"Last Month": "past1months",
|
||||
"Last 3 Months": "past3months",
|
||||
"Last 12 Months": "past12months",
|
||||
};
|
||||
|
||||
const TEST_MISC_OPTIONS = [
|
||||
"Specific dates...",
|
||||
"Relative dates...",
|
||||
"Exclude...",
|
||||
];
|
||||
|
||||
const TEST_DAY_MONTH_SHORTCUTS = Object.keys(TEST_DAY_MONTH_OPTIONS);
|
||||
|
||||
const filterShortcut = (filterValue: string): DateShortcutOptions => {
|
||||
return {
|
||||
DAY_OPTIONS: DATE_SHORTCUT_OPTIONS.DAY_OPTIONS.filter(
|
||||
({ displayName }) => displayName !== filterValue,
|
||||
),
|
||||
MONTH_OPTIONS: DATE_SHORTCUT_OPTIONS.MONTH_OPTIONS.filter(
|
||||
({ displayName }) => displayName !== filterValue,
|
||||
),
|
||||
MISC_OPTIONS: DATE_SHORTCUT_OPTIONS.MISC_OPTIONS.filter(
|
||||
({ displayName }) => displayName !== filterValue,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const TestDateAllOptions = ({
|
||||
dateShortcutOptions,
|
||||
onClose,
|
||||
setValue,
|
||||
}: DateAllOptionsWidgetProps) => {
|
||||
const [testValue, setTestValue] = useState<string | null>(null);
|
||||
|
||||
const onSetValue = (val: string | null) => {
|
||||
setTestValue(val);
|
||||
setValue(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<DateAllOptionsWidget
|
||||
value={testValue ?? undefined}
|
||||
setValue={onSetValue}
|
||||
onClose={onClose}
|
||||
disableOperatorSelection={false}
|
||||
dateShortcutOptions={dateShortcutOptions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const setup = ({
|
||||
value = undefined,
|
||||
dateShortcutOptions = undefined,
|
||||
}: {
|
||||
value?: string;
|
||||
dateShortcutOptions?: DateShortcutOptions;
|
||||
} = {}) => {
|
||||
const setValueMock = jest.fn();
|
||||
const onCloseMock = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<TestDateAllOptions
|
||||
setValue={setValueMock}
|
||||
onClose={onCloseMock}
|
||||
value={value}
|
||||
dateShortcutOptions={dateShortcutOptions}
|
||||
/>,
|
||||
);
|
||||
|
||||
return {
|
||||
setValueMock,
|
||||
onCloseMock,
|
||||
};
|
||||
};
|
||||
|
||||
describe("DateAllOptionsWidget", () => {
|
||||
it("should render shortcut options", () => {
|
||||
setup();
|
||||
for (const option of TEST_DAY_MONTH_SHORTCUTS) {
|
||||
expect(screen.getByText(option)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
describe("when a value is selected", () => {
|
||||
it.each(TEST_DAY_MONTH_SHORTCUTS)(
|
||||
"should call setValue with the correct value when %s is clicked",
|
||||
option => {
|
||||
const { onCloseMock, setValueMock } = setup();
|
||||
userEvent.click(screen.getByText(option));
|
||||
expect(setValueMock).toHaveBeenCalledWith(
|
||||
TEST_DAY_MONTH_OPTIONS[option],
|
||||
);
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("should call setValue in the correct string format when 'Specific dates...' is clicked", () => {
|
||||
const { onCloseMock, setValueMock } = setup();
|
||||
userEvent.click(screen.getByText("Specific dates..."));
|
||||
userEvent.click(screen.getAllByText("1")[0]);
|
||||
userEvent.click(screen.getAllByText("15")[0]);
|
||||
userEvent.click(screen.getByText("Update filter"));
|
||||
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = `0${date.getMonth() + 1}`.slice(-2);
|
||||
|
||||
expect(setValueMock).toHaveBeenCalledWith(
|
||||
`${year}-${month}-01~${year}-${month}-15`,
|
||||
);
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call setValue in the correct string format when 'Relative dates...' is clicked", () => {
|
||||
const { onCloseMock, setValueMock } = setup();
|
||||
userEvent.click(screen.getByText("Relative dates..."));
|
||||
userEvent.clear(screen.getByTestId("relative-datetime-value"));
|
||||
userEvent.type(screen.getByTestId("relative-datetime-value"), "90");
|
||||
userEvent.click(screen.getByTestId("relative-datetime-unit"));
|
||||
userEvent.click(screen.getByText("quarters"));
|
||||
|
||||
userEvent.click(screen.getByText("Update filter"));
|
||||
|
||||
expect(setValueMock).toHaveBeenCalledWith("past90quarters");
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call setValue in the correct string format when 'Exclude...' is clicked", () => {
|
||||
const { onCloseMock, setValueMock } = setup();
|
||||
userEvent.click(screen.getByText("Exclude..."));
|
||||
userEvent.click(screen.getByText("Days of the week..."));
|
||||
userEvent.click(screen.getByText("Monday"));
|
||||
userEvent.click(screen.getByText("Tuesday"));
|
||||
userEvent.click(screen.getByText("Wednesday"));
|
||||
|
||||
userEvent.click(screen.getByText("Update filter"));
|
||||
|
||||
expect(setValueMock).toHaveBeenCalledWith("exclude-days-Mon-Tue-Wed");
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when shortcuts should be excluded", () => {
|
||||
it.each([...TEST_MISC_OPTIONS, ...TEST_DAY_MONTH_SHORTCUTS])(
|
||||
"should not show %s when %s is filtered",
|
||||
shortcut => {
|
||||
setup({
|
||||
dateShortcutOptions: filterShortcut(shortcut),
|
||||
});
|
||||
|
||||
expect(screen.queryByText(shortcut)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -161,4 +161,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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -286,3 +287,7 @@ export const PLUGIN_MODEL_PERSISTENCE = {
|
||||
export const PLUGIN_EMBEDDING = {
|
||||
isEnabled: () => false,
|
||||
};
|
||||
|
||||
export const PLUGIN_CONTENT_VERIFICATION = {
|
||||
VerifiedFilter: {} as SearchFilterComponent<"verified">,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ type Props = {
|
||||
primaryColor?: string;
|
||||
minWidth?: number | null;
|
||||
maxWidth?: number | null;
|
||||
withPadding?: boolean;
|
||||
|
||||
onBack?: () => void;
|
||||
onCommit: (filter: any[]) => void;
|
||||
@@ -156,6 +157,7 @@ const DatePicker: React.FC<Props> = props => {
|
||||
children,
|
||||
hideTimeSelectors,
|
||||
operators = DATE_OPERATORS,
|
||||
withPadding = true,
|
||||
} = props;
|
||||
|
||||
const operator = getOperator(filter, operators);
|
||||
@@ -180,7 +182,7 @@ const DatePicker: React.FC<Props> = props => {
|
||||
<div className={cx(className)} data-testid="date-picker">
|
||||
{!operator || showShortcuts ? (
|
||||
<DatePickerShortcuts
|
||||
className="p2"
|
||||
className={withPadding ? "p2" : ""}
|
||||
primaryColor={primaryColor}
|
||||
dateShortcutOptions={dateShortcutOptions}
|
||||
onFilterChange={filter => {
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -0,0 +1,161 @@
|
||||
/* 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>
|
||||
<Stack mah={maxHeight}>
|
||||
<ContentComponent
|
||||
value={value}
|
||||
onChange={selected => onApplyFilter(selected)}
|
||||
width={popoverWidth}
|
||||
/>
|
||||
</Stack>
|
||||
</SearchEventSandbox>
|
||||
)
|
||||
}
|
||||
</Popover>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
@@ -0,0 +1 @@
|
||||
export { DropdownSidebarFilter } from "./DropdownSidebarFilter";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SearchFilterDateDisplay";
|
||||
@@ -0,0 +1,7 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { DateAllOptionsWidget } from "metabase/components/DateAllOptionsWidget";
|
||||
|
||||
export const SearchFilterDatePickerWrapper = styled(DateAllOptionsWidget)`
|
||||
padding: 0.5rem;
|
||||
min-width: unset;
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DATE_SHORTCUT_OPTIONS } from "metabase/query_builder/components/filters/pickers/DatePicker/DatePickerShortcutOptions";
|
||||
import { SearchFilterDatePickerWrapper } from "metabase/search/components/SearchFilterDatePicker/SearchFilterDatePicker.styled";
|
||||
|
||||
const CREATED_AT_SHORTCUTS = {
|
||||
...DATE_SHORTCUT_OPTIONS,
|
||||
MISC_OPTIONS: DATE_SHORTCUT_OPTIONS.MISC_OPTIONS.filter(
|
||||
({ displayName }) => displayName !== "Exclude…",
|
||||
),
|
||||
};
|
||||
|
||||
export const SearchFilterDatePicker = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
}) => (
|
||||
<SearchFilterDatePickerWrapper
|
||||
value={value}
|
||||
setValue={(value: string | null) => onChange(value)}
|
||||
dateShortcutOptions={CREATED_AT_SHORTCUTS}
|
||||
withPadding={false}
|
||||
/>
|
||||
);
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SearchFilterDatePicker";
|
||||
@@ -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};
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { t } from "ttag";
|
||||
import type { ReactNode } from "react";
|
||||
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;
|
||||
};
|
||||
|
||||
export const SearchFilterPopoverWrapper = ({
|
||||
children,
|
||||
onApply,
|
||||
isLoading = false,
|
||||
}: SearchFilterPopoverWrapperProps) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center p="lg">
|
||||
<Loader data-testid="loading-spinner" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FocusTrap active>
|
||||
<SearchPopoverContainer spacing={0}>
|
||||
{children}
|
||||
<DropdownApplyButtonDivider />
|
||||
<Group position="right" align="center" px="sm" pb="sm">
|
||||
<Button onClick={onApply}>{t`Apply`}</Button>
|
||||
</Group>
|
||||
</SearchPopoverContainer>
|
||||
</FocusTrap>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { SearchFilterPopoverWrapper } from "./SearchFilterPopoverWrapper";
|
||||
@@ -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.LastEditedBy)}
|
||||
{getFilter(SearchFilterKeys.LastEditedAt)}
|
||||
</Stack>
|
||||
<Stack spacing="sm">
|
||||
{getFilter(SearchFilterKeys.CreatedBy)}
|
||||
{getFilter(SearchFilterKeys.CreatedAt)}
|
||||
</Stack>
|
||||
{getFilter(SearchFilterKeys.Verified)}
|
||||
{getFilter(SearchFilterKeys.NativeQuery)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { SearchSidebar } from "./SearchSidebar";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { SearchUserPicker } from "./SearchUserPicker";
|
||||
@@ -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]};
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* TODO: Change mantine switch import before merging!!
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* */
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Switch } from "@mantine/core";
|
||||
import type { SearchFilterToggle } from "metabase/search/types";
|
||||
import { Group, Text } from "metabase/ui";
|
||||
|
||||
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 (
|
||||
<Group noWrap spacing="xs" data-testid={dataTestId}>
|
||||
<Text w="100%" c="text.1" size="md" fw={700}>
|
||||
{label()}
|
||||
</Text>
|
||||
<Switch
|
||||
wrapperProps={{
|
||||
"data-testid": "toggle-filter-switch",
|
||||
}}
|
||||
size="sm"
|
||||
data-is-checked={value}
|
||||
checked={value}
|
||||
onChange={event => onChange(event.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ToggleSidebarFilter } from "./ToggleSidebarFilter";
|
||||
export type { ToggleSidebarFilterProps } from "./ToggleSidebarFilter";
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { UserListElement } from "./UserListElement";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { UserNameDisplay } from "./UserNameDisplay";
|
||||
export type { UserNameDisplayProps } from "./UserNameDisplay";
|
||||
@@ -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}>
|
||||
<SearchFilterDatePicker value={value} onChange={onChange} />
|
||||
</Box>
|
||||
),
|
||||
fromUrl: value => value,
|
||||
toUrl: value => value,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/* 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 } from "metabase/search/components/SearchUserPicker";
|
||||
import {
|
||||
stringifyUserIdArray,
|
||||
parseUserIdArray,
|
||||
} from "metabase/search/utils/user-search-params";
|
||||
import { Box } from "metabase/ui";
|
||||
|
||||
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 }) => (
|
||||
<Box w={width}>
|
||||
<SearchUserPicker value={value} onChange={onChange} />
|
||||
</Box>
|
||||
),
|
||||
fromUrl: parseUserIdArray,
|
||||
toUrl: stringifyUserIdArray,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./CreatedByFilter";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import { t } from "ttag";
|
||||
import { Box } from "metabase/ui";
|
||||
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 }) => (
|
||||
<Box m={width}>
|
||||
<SearchUserPicker value={value} onChange={onChange} />
|
||||
</Box>
|
||||
),
|
||||
fromUrl: parseUserIdArray,
|
||||
toUrl: stringifyUserIdArray,
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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`;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { NativeQueryFilter } from "./NativeQueryFilter";
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,50 @@
|
||||
/* 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"] =
|
||||
export const TypeFilterContent: SearchFilterDropdown<"type">["ContentComponent"] =
|
||||
({ value, onChange }) => {
|
||||
const { metadata, isLoading } = useSearchListQuery({
|
||||
query: EMPTY_SEARCH_QUERY,
|
||||
});
|
||||
|
||||
const availableModels = (metadata && metadata.available_models) ?? [];
|
||||
const typeFilters: EnabledSearchModelType[] = enabledSearchTypes.filter(
|
||||
model => availableModels.includes(model),
|
||||
);
|
||||
const [selectedTypes, setSelectedTypes] = useState<
|
||||
EnabledSearchModelType[]
|
||||
>(value ?? []);
|
||||
|
||||
return isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<Checkbox.Group
|
||||
data-testid="type-filter-checkbox-group"
|
||||
w="100%"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
const availableModels = (metadata && metadata.available_models) ?? [];
|
||||
const typeFilters = filterEnabledSearchTypes(availableModels);
|
||||
|
||||
return (
|
||||
<SearchFilterPopoverWrapper
|
||||
isLoading={isLoading}
|
||||
onApply={() => onChange(selectedTypes)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./TypeFilter";
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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,
|
||||
@@ -82,10 +82,7 @@ function SearchApp({ location }) {
|
||||
{({ list, metadata }) => (
|
||||
<SearchBody direction="column" justify="center">
|
||||
<SearchControls>
|
||||
<SearchSidebar
|
||||
value={searchFilters}
|
||||
onChangeFilters={onFilterChange}
|
||||
/>
|
||||
<SearchSidebar value={searchFilters} onChange={onFilterChange} />
|
||||
</SearchControls>
|
||||
<SearchResultContainer>
|
||||
{list.length === 0 ? (
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
setupDatabasesEndpoints,
|
||||
setupSearchEndpoints,
|
||||
setupTableEndpoints,
|
||||
setupUsersEndpoints,
|
||||
} from "__support__/server-mocks";
|
||||
import {
|
||||
createMockDatabase,
|
||||
createMockSearchResult,
|
||||
createMockTable,
|
||||
createMockUserListResult,
|
||||
} from "metabase-types/api/mocks";
|
||||
import type { EnabledSearchModelType, SearchResult } from "metabase-types/api";
|
||||
|
||||
@@ -53,6 +55,7 @@ const TEST_SEARCH_RESULTS: SearchResult[] = TEST_ITEMS.map((metadata, index) =>
|
||||
|
||||
const TEST_DATABASE = createMockDatabase();
|
||||
const TEST_TABLE = createMockTable();
|
||||
const TEST_USER_LIST = [createMockUserListResult()];
|
||||
|
||||
const setup = async ({
|
||||
searchText,
|
||||
@@ -66,6 +69,7 @@ const setup = async ({
|
||||
setupDatabasesEndpoints([TEST_DATABASE]);
|
||||
setupSearchEndpoints(searchItems);
|
||||
setupTableEndpoints(TEST_TABLE);
|
||||
setupUsersEndpoints(TEST_USER_LIST);
|
||||
|
||||
// for testing the hydration of search text and filters on page load
|
||||
const params = {
|
||||
@@ -158,7 +162,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 +178,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 +203,12 @@ describe("SearchApp", () => {
|
||||
name,
|
||||
);
|
||||
|
||||
const fieldSetContent = within(screen.getByTestId("field-set-content"));
|
||||
const typeFilter = within(screen.getByTestId("type-search-filter"));
|
||||
const fieldSetContent = typeFilter.getByTestId("field-set-content");
|
||||
|
||||
expect(
|
||||
fieldSetContent.getByText(
|
||||
TYPE_FILTER_LABELS[model as EnabledSearchModelType],
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
fieldSetContent.getByLabelText("close icon"),
|
||||
).toBeInTheDocument();
|
||||
expect(fieldSetContent).toHaveTextContent(
|
||||
TYPE_FILTER_LABELS[model as EnabledSearchModelType],
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
@@ -21,26 +22,67 @@ export interface WrappedResult extends SearchResult {
|
||||
}
|
||||
|
||||
export type TypeFilterProps = EnabledSearchModelType[];
|
||||
export type CreatedByFilterProps = UserId[];
|
||||
export type LastEditedByProps = UserId[];
|
||||
export type VerifiedFilterProps = true | null;
|
||||
export type NativeQueryFilterProps = true | null;
|
||||
|
||||
export type SearchFilterPropTypes = {
|
||||
[SearchFilterKeys.Type]: TypeFilterProps;
|
||||
[SearchFilterKeys.Verified]: VerifiedFilterProps;
|
||||
[SearchFilterKeys.CreatedBy]: CreatedByFilterProps;
|
||||
[SearchFilterKeys.CreatedAt]: string;
|
||||
[SearchFilterKeys.LastEditedBy]: LastEditedByProps;
|
||||
[SearchFilterKeys.LastEditedAt]: string;
|
||||
[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";
|
||||
|
||||
export type SearchSidebarFilterComponent<T extends FilterTypeKeys = any> = {
|
||||
title: string;
|
||||
iconName: IconName;
|
||||
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 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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
isEnabledSearchModelType,
|
||||
filterEnabledSearchTypes,
|
||||
} from "./enabled-search-type";
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./search-location";
|
||||
export * from "./user-search-params";
|
||||
export * from "./enabled-search-type";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { parseUserIdArray, stringifyUserIdArray } from "./user-search-params";
|
||||
@@ -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)) : [];
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]])}
|
||||
|
||||
@@ -287,6 +289,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])
|
||||
@@ -304,26 +313,26 @@
|
||||
;; 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)])}
|
||||
;; 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
|
||||
@@ -351,38 +360,58 @@
|
||||
(parser-result-decoder parser-result decoder-param)))
|
||||
decoders))
|
||||
|
||||
(def ^:private TemporalRange
|
||||
{(s/optional-key :start) Temporal, (s/optional-key :end) Temporal})
|
||||
(def ^:private TemporalUnit
|
||||
(into [:enum] u.date/add-units))
|
||||
|
||||
(s/defn ^:private adjust-inclusive-range-if-needed :- (s/maybe TemporalRange)
|
||||
(def ^:private TemporalRange
|
||||
[:map
|
||||
[:start {:optional true} [:fn #(instance? Temporal %)]]
|
||||
[:end {:optional true} [:fn #(instance? Temporal %)]]
|
||||
[:unit TemporalUnit]])
|
||||
|
||||
(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")))
|
||||
[: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)))))]])
|
||||
|
||||
(s/defn date-string->range :- DateStringRange
|
||||
(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
|
||||
@@ -397,20 +426,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -73,7 +73,9 @@
|
||||
|
||||
(t2/define-before-insert :model/Revision
|
||||
[revision]
|
||||
(assoc revision :timestamp :%now))
|
||||
(assoc revision
|
||||
:timestamp :%now
|
||||
:most_recent true))
|
||||
|
||||
(t2/define-before-update :model/Revision
|
||||
[_revision]
|
||||
@@ -90,6 +92,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
|
||||
@@ -143,17 +167,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."
|
||||
@@ -186,7 +199,6 @@
|
||||
:is_creation is-creation?
|
||||
:is_reversion false
|
||||
:message message)
|
||||
(delete-old-revisions! entity id)
|
||||
object)))
|
||||
|
||||
(defn revert!
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -7,14 +7,12 @@
|
||||
|
||||
(def moderated-item-types
|
||||
"Schema enum of the acceptable values for the `moderated_item_type` column"
|
||||
[:enum "card" "dashboard" :card :dashboard])
|
||||
[:enum "card" :card])
|
||||
|
||||
(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
|
||||
:card 'Card})
|
||||
|
||||
(defn- object->type
|
||||
"Convert a moderated item instance to the keyword stored in the database"
|
||||
|
||||
@@ -252,8 +252,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?
|
||||
@@ -261,6 +260,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?
|
||||
|
||||
@@ -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)
|
||||
"")))
|
||||
|
||||
308
src/metabase/search/filter.clj
Normal file
308
src/metabase/search/filter.clj
Normal file
@@ -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)))))
|
||||
@@ -1,10 +1,12 @@
|
||||
(ns metabase.search.scoring
|
||||
(:require
|
||||
[cheshire.core :as json]
|
||||
[clojure.string :as str]
|
||||
[java-time :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?
|
||||
@@ -18,7 +20,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)
|
||||
@@ -47,13 +49,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
|
||||
@@ -68,7 +70,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)
|
||||
@@ -137,7 +139,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]}]
|
||||
@@ -148,7 +150,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}]))
|
||||
|
||||
@@ -172,13 +174,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)
|
||||
@@ -201,12 +203,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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -137,7 +137,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
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user