Compare commits

...

140 Commits

Author SHA1 Message Date
Oisin Coveney
7ca7935bea Replace with common function 2023-10-12 12:21:19 +03:00
Oisin Coveney
1cee43f381 Fix test and ensure copy 2023-10-12 10:45:43 +03:00
Oisin Coveney
e16211ab2b Merge branch '27982-milestone-5/created-at' into 27982-milestone-5/last-edited-at 2023-10-12 10:38:32 +03:00
Oisin Coveney
d0c9871eab Fix tests 2023-10-12 10:37:24 +03:00
Oisin Coveney
7e0d994203 Merge multi-select test changes 2023-10-12 10:02:30 +03:00
Oisin Coveney
8f088a7835 Fix e2e tests 2023-10-12 09:45:51 +03:00
Oisin Coveney
6c76fd36c4 revert final check on expectSearchResultItemNameContent to ensure that order doesn't matter 2023-10-11 18:19:30 +03:00
Oisin Coveney
1a564d3520 Fix more tests 2023-10-11 18:17:16 +03:00
Oisin Coveney
31d0e03f30 Merge branch '27982-milestone-5/created-at' into 27982-milestone-5/last-edited-at 2023-10-11 18:12:00 +03:00
Oisin Coveney
b66459f644 Rename Apply filters to Apply 2023-10-11 18:10:21 +03:00
Oisin Coveney
7b7b660056 Typo 2023-10-11 18:08:30 +03:00
Oisin Coveney
5b540f4d65 Adjust spacing 2023-10-11 14:18:20 +03:00
Oisin Coveney
eaf1ececc4 Change labels in tests to reflect reality 2023-10-11 14:13:02 +03:00
Oisin Coveney
38b4595e6c Change 'Apply filters' to 'Apply' in tests 2023-10-11 13:54:52 +03:00
Oisin Coveney
36e5cc2d95 Add spacing 2023-10-11 13:48:14 +03:00
Oisin Coveney
d996a5cf9a Add spacing to components 2023-10-11 13:46:14 +03:00
Oisin Coveney
f545a4d634 Change label and group with CreatedAt filter 2023-10-11 13:36:28 +03:00
Oisin Coveney
0fb96c3b25 Merge remote-tracking branch 'origin/27982-milestone-5/created-at' into 27982-milestone-5/last-edited-at 2023-10-11 13:34:45 +03:00
Oisin Coveney
1d12a1bff2 Move width logic to component-level basis 2023-10-11 13:34:30 +03:00
Oisin Coveney
24a1958a82 Move width logic to component-level basis 2023-10-11 13:34:09 +03:00
Oisin Coveney
80bb91690c Merge branch '27982-milestone-5/created-at' into 27982-milestone-5/last-edited-at 2023-10-11 13:32:43 +03:00
Oisin Coveney
e6fca179c4 Move width logic to component-level basis 2023-10-11 13:32:12 +03:00
Oisin Coveney
8e687d1fed Fix tests 2023-10-11 13:04:11 +03:00
Oisin Coveney
146da3bd92 Change to 2023-10-11 12:51:26 +03:00
Oisin Coveney
d6dba0b9f7 Ensure that text input stays inline with chips 2023-10-11 12:44:19 +03:00
Oisin Coveney
e57dc1eca6 Merge global-search-add-more-filters 2023-10-11 11:37:43 +03:00
Oisin Coveney
ae21008363 Update frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.tsx
Co-authored-by: Maz Ameli <maz@metabase.com>
2023-10-11 10:52:58 +03:00
Oisin Coveney
7db84816dc Update frontend/src/metabase/search/components/SearchUserPicker/SearchUserPicker.tsx
Co-authored-by: Maz Ameli <maz@metabase.com>
2023-10-11 10:52:49 +03:00
Oisin Coveney
965d324912 Merge remote-tracking branch 'origin' into global-search-more-filter-options 2023-10-11 10:50:47 +03:00
Oisin Coveney
9498bcc152 Revert date-formatting to master state 2023-10-11 10:44:55 +03:00
Oisin Coveney
d5e1a1c215 Combine created_at and last_edited_at fitlers 2023-10-11 10:39:53 +03:00
Oisin Coveney
8f3ec8beab Add created_at filter to sidebar 2023-10-11 10:38:44 +03:00
Oisin Coveney
8d926c6ea9 Add last_edited_at filter to sidbear 2023-10-11 10:37:57 +03:00
Oisin Coveney
03678e4e55 Set fromUrl/toUrl 2023-10-11 10:27:20 +03:00
Oisin Coveney
1a006b2dd2 Add created_at filter with new code 2023-10-11 10:14:03 +03:00
Ngoc Khuat
e9f8b5390d Global search: option to search native queries (#34317)
Co-authored-by: Cal Herries <39073188+calherries@users.noreply.github.com>
Co-authored-by: Oisin Coveney <oisin@metabase.com>
2023-10-10 20:28:05 +07:00
Oisin Coveney
23029db26d Merge master 2023-10-10 13:34:27 +03:00
Ngoc Khuat
7d5c4daf85 test fixes 2023-10-10 13:14:04 +07:00
Ngoc Khuat
e8b93000bd Merge branch 'master' into global-search-more-filter-options 2023-10-10 11:05:06 +07:00
Oisin Coveney
85de08d12b Persist search filters across searches (#34397) 2023-10-09 15:36:46 +03:00
Oisin Coveney
cfc62492f3 Modify tests to match new behavior 2023-10-09 13:57:59 +03:00
Oisin Coveney
3592d02bac Remove user from general list when selected 2023-10-09 13:34:18 +03:00
Oisin Coveney
ef9329f15f Modify SearchResult to fit new search payload (#34465) 2023-10-09 09:31:40 +03:00
Oisin Coveney
94e8471d08 Simplify ternary expression 2023-10-07 14:19:45 +03:00
Ngoc Khuat
7b18a67c09 Global search: adding creator name and last editor name to search results (#34312) 2023-10-06 09:40:49 +07:00
Oisin Coveney
00e7e73878 Discard changes to frontend/src/metabase/search/containers/SearchApp.styled.tsx 2023-10-05 13:24:22 +03:00
Oisin Coveney
cbd4a9f90b Prettier on SearchUserPicker.unit.spec.tsx 2023-10-05 12:21:37 +03:00
Oisin Coveney
89d4004c1e Solved overflow a different way 2023-10-05 11:56:55 +03:00
Oisin Coveney
0b2d499bf7 Remove inline style 2023-10-05 10:34:02 +03:00
Oisin Coveney
fd978928f6 Merge global-search-more-filters 2023-10-05 10:33:35 +03:00
Oisin Coveney
276348f2f2 Search Milestone 2: Add last_edited_by search filter (#34214) 2023-10-04 18:20:00 +03:00
Oisin Coveney
be759bd91f Fix merge problem and fix unit test 2023-10-04 17:12:26 +03:00
Oisin Coveney
ec3db0d4b8 Discard changes to e2e/support/cypress_sample_instance_data.js 2023-10-04 17:05:54 +03:00
Oisin Coveney
5c0e2a1a93 Merge last-edited-by 2023-10-04 16:21:41 +03:00
Oisin Coveney
6929159f04 Handle text overflow for Polish 2023-10-04 15:58:47 +03:00
Oisin Coveney
803b7a0b8e Attempt to solve overflow issue 2023-10-04 15:31:18 +03:00
Oisin Coveney
61984359ed Fix overflow on icon and search test typo 2023-10-04 11:41:50 +03:00
Oisin Coveney
87985bc2da Add tests and attempt to solve overflow error 2023-10-04 11:17:40 +03:00
Oisin Coveney
3e385fbad9 Remove some unneeded styled components 2023-10-03 18:03:46 +03:00
Oisin Coveney
3eb14ce157 Some adjustments based on feedback 2023-10-03 17:56:00 +03:00
Oisin Coveney
817b3d0e17 Fix bug with clearing filters 2023-10-03 17:23:55 +03:00
Oisin Coveney
dff6ccb99e Fix stringifyUserIdArray test 2023-10-03 16:42:02 +03:00
Oisin Coveney
a287b1896b Merge with last-edited-by 2023-10-03 16:38:38 +03:00
Oisin Coveney
d5af52606a Feedback fixes 2023-10-03 16:29:10 +03:00
Oisin Coveney
5fe25bf0d7 Discard changes to frontend/src/metabase/search/components/UserListElement/UserListElement.tsx 2023-10-03 15:35:15 +03:00
Oisin Coveney
8c21e30e9a Small fixes 2023-10-03 15:32:19 +03:00
Oisin Coveney
fd503617b3 Small fixes 2023-10-03 15:32:09 +03:00
Ngoc Khuat
dfd7164791 Merge branch 'master' into global-search-more-filter-options 2023-10-03 19:21:59 +07:00
Oisin Coveney
747506db0c Discard changes to frontend/src/metabase/ui/Playground.tsx 2023-10-03 15:19:43 +03:00
Oisin Coveney
612afb80fb Discard changes to frontend/src/metabase/routes.jsx 2023-10-03 15:19:29 +03:00
Oisin Coveney
b9d9585294 Merge branch '27982-milestone-3/fe-last-edited-by-filter' into readd-multi-select-to-search-user-picker 2023-10-03 15:19:08 +03:00
Oisin Coveney
7a5451fb47 Merge branch 'global-search-more-filter-options' into 27982-milestone-3/fe-last-edited-by-filter 2023-10-03 15:18:32 +03:00
Oisin Coveney
7fc021b9cf Fix weird undefined bug and refactor to parse string arrays 2023-10-03 15:13:11 +03:00
Oisin Coveney
36060882b7 Merge branch 'global-search-more-filter-options' into 27982-milestone-3/fe-last-edited-by-filter 2023-10-03 14:01:36 +03:00
Ngoc Khuat
517c54d948 Global search: created by and edited by support multiple ids (#34302) 2023-10-03 16:03:59 +07:00
Ngoc Khuat
5a9d8e0bae Merge branch 'master' into global-search-more-filter-options 2023-10-03 10:17:33 +07:00
Oisin Coveney
2682493059 modify comments to reflect current state of tests 2023-10-02 18:03:13 +03:00
Oisin Coveney
7bd8b0d077 Add last_edited_by filter 2023-10-02 17:54:27 +03:00
Ngoc Khuat
fa8e3ac9d3 Global search: last edited by, last edited at (#33525) 2023-10-02 19:07:33 +07:00
Oisin Coveney
37b2cc851a Search Milestone 2: Organize created_by components for future use (#34244) 2023-10-02 12:39:33 +03:00
Oisin Coveney
0d7f5a6578 Merge branch 'master' into global-search-more-filter-options 2023-09-29 12:33:36 +03:00
Oisin Coveney
ceca1b0dd8 Resolve more merge conflict issues 2023-09-29 12:31:02 +03:00
Oisin Coveney
a04ffc2ee8 Remove orphaned merge code 2023-09-28 17:40:52 +03:00
Oisin Coveney
93b3051cb3 Fix merge issues 2023-09-28 17:37:16 +03:00
Oisin Coveney
e6a9fa8aa2 Organize component files 2023-09-28 17:18:29 +03:00
Oisin Coveney
b55a0ff492 Merge master 2023-09-28 14:31:08 +03:00
Oisin Coveney
f022e61bc3 Search Milestone 2: Add verified search filter (#33956) 2023-09-28 14:22:35 +03:00
Oisin Coveney
fac62c76e3 Search Milestone 2: Add created_by search filter (#33808)
Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
2023-09-25 14:19:51 +03:00
Ngoc Khuat
8781ffed9c Merge branch '27982-milestone-1.5-integration' into global-search-more-filter-options 2023-09-24 15:58:11 +01:00
Oisin Coveney
59b7fb84ca Fix types + imports 2023-09-23 10:46:47 +03:00
Oisin Coveney
19808cb2e9 Merge branch 'master' into 27982-milestone-1.5-integration 2023-09-23 10:36:18 +03:00
Oisin Coveney
99069f692a Merge master 2023-09-22 18:25:02 +03:00
Oisin Coveney
8dfa2e4826 Merge branch 'master' into 27982-milestone-1.5-integration 2023-09-22 16:32:49 +03:00
Oisin Coveney
83a9c73193 Remove '1 search results' from realm of possibility 2023-09-21 13:28:57 +03:00
Ngoc Khuat
f3991f50e0 remove duplicated test 2023-09-20 11:41:48 +01:00
Oisin Coveney
2b77f67dbe Merge branch '27982-milestone-1.5-integration' into global-search-more-filter-options 2023-09-20 13:30:41 +03:00
Oisin Coveney
31ec2bda32 Merge branch 'master' into 27982-milestone-1.5-integration 2023-09-20 13:10:23 +03:00
Ngoc Khuat
1efcb2870f split up long test 2023-09-20 09:51:45 +01:00
Ngoc Khuat
978deef162 Merge branch 'master' into global-search-more-filter-options 2023-09-19 22:16:26 +01:00
Ngoc Khuat
c678d91b7c fix failed to run test 2023-09-19 22:16:22 +01:00
Oisin Coveney
4298cb93f0 Add CTA to search dropdown (#33675) 2023-09-19 17:08:32 +03:00
Oisin Coveney
52148308f9 Merge branch 'master' into 27982-milestone-1.5-integration 2023-09-19 17:06:36 +03:00
Oisin Coveney
dd794348b7 Merge branch '27982-milestone-1.5-integration' into global-search-more-filter-options 2023-09-19 10:14:30 +03:00
Oisin Coveney
506d37d889 Merge branch 'master' into 27982-milestone-1.5-integration 2023-09-19 09:25:56 +03:00
Oisin Coveney
c08fc16e22 Move enabled types to the search API types to resolve type issues and keep things in one place 2023-09-08 12:27:29 +03:00
Oisin Coveney
79a4a1f5bd Try to fix type check 2023-09-08 12:18:46 +03:00
Oisin Coveney
848f0ab6be Merge master and resolve conflicts 2023-09-08 11:49:51 +03:00
Oisin Coveney
6d3d4c602c Merge branch '27982-milestone-1.5-integration' into global-search-more-filter-options 2023-09-07 14:53:49 +03:00
Oisin Coveney
351a5516dd Add Type Filter to Search (#33639) 2023-09-07 14:38:43 +03:00
Oisin Coveney
058059ad80 Merge branch 'master' into 27982-milestone-1.5-integration 2023-09-07 11:23:26 +03:00
Ngoc Khuat
0a37e91a8e Merge branch 'master' into global-search-more-filter-options 2023-09-07 14:51:54 +07:00
Oisin Coveney
cc173c1084 Merge branch 'master' into 27982-milestone-1.5-integration 2023-09-06 17:59:24 +03:00
Oisin Coveney
2bdea851ad Merge branch 'master' into 27982-milestone-1.5-integration 2023-09-05 18:35:24 +03:00
Oisin Coveney
cf46ea89f3 Merge commit '1209f7e' into 27982-milestone-1.5-integration 2023-08-31 15:05:01 +03:00
Ngoc Khuat
3908ca2fb9 Merge branch 'master' into global-search-more-filter-options 2023-08-29 16:28:48 +07:00
Oisin Coveney
1209f7e6da Remove and rename SearchFilter files for Milestone 1.5 (#33532) 2023-08-28 17:19:46 +03:00
Oisin Coveney
91cdac4138 Add change to create PR 2023-08-28 17:10:35 +03:00
Ngoc Khuat
a174f295ff Merge branch 'master' into global-search-more-filter-options 2023-08-28 17:00:36 +07:00
Ngoc Khuat
bb9707a3c8 Revert "Global search: last edited by, last edited at (#33405)" (#33523)
This reverts commit 66325f89c0.
2023-08-28 16:58:51 +07:00
Ngoc Khuat
66325f89c0 Global search: last edited by, last edited at (#33405) 2023-08-28 14:21:44 +07:00
Ngoc Khuat
d9703e868c Merge branch 'master' into global-search-more-filter-options 2023-08-23 22:24:42 +07:00
Ngoc Khuat
81136d88ae Merge branch 'master' into global-search-more-filter-options 2023-08-23 10:19:18 +07:00
Ngoc Khuat
4bda5f2f0a Merge branch 'master' into global-search-more-filter-options 2023-08-22 16:06:58 +07:00
Ngoc Khuat
656261531b Merge branch 'master' into global-search-more-filter-options 2023-08-21 09:07:59 +07:00
Ngoc Khuat
eaca06eaad Global search: verified remove collection, add models (#33146) 2023-08-17 10:05:14 +07:00
Ngoc Khuat
001bfc2f6a Global search: created at filter (#32836) 2023-08-17 08:30:29 +07:00
Oisin Coveney
5ab143660c Merge branch 'master' into global-search-more-filter-options 2023-08-15 12:22:40 +03:00
Ngoc Khuat
069d427081 Merge branch 'master' into global-search-more-filter-options 2023-08-14 20:47:27 +07:00
Ngoc Khuat
f6f0bc9420 Merge branch 'master' into global-search-more-filter-options 2023-08-12 12:21:25 +07:00
Ngoc Khuat
83a3395981 available_models should not take models into account (#33038) 2023-08-12 12:21:07 +07:00
Oisin Coveney
f815f990b8 Merge branch 'master' into global-search-more-filter-options 2023-08-11 12:23:01 +03:00
Oisin Coveney
11fdaa55bd Merge remote-tracking branch 'origin/master' into global-search-more-filter-options 2023-08-10 12:44:54 +03:00
Ngoc Khuat
f44cef6eea Merge branch 'master' into global-search-more-filter-options 2023-08-04 20:01:46 +07:00
Ngoc Khuat
3a9a06d848 Merge branch 'master' into global-search-more-filter-options 2023-08-02 16:21:59 +07:00
Ngoc Khuat
9885a61d09 Filter by Verified (#32662) 2023-08-02 16:05:40 +07:00
Ngoc Khuat
539892c85a Merge branch 'master' into global-search-more-filter-options 2023-08-01 17:22:33 +07:00
Ngoc Khuat
5e083d8529 Global search refactor (#32658) 2023-08-01 16:56:57 +07:00
Ngoc Khuat
d23a485341 Merge branch 'master' into global-search-more-filter-options 2023-08-01 12:47:23 +07:00
Ngoc Khuat
af476e8a73 Merge branch 'master' into global-search-more-filter-options 2023-07-27 16:47:44 +07:00
Ngoc Khuat
85b981e7f8 Global search: filter by creator (#32469) 2023-07-25 17:04:32 +07:00
113 changed files with 6218 additions and 1602 deletions

View File

@@ -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

View File

@@ -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);
});
}

View File

@@ -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."))))))

View File

@@ -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),
};

View File

@@ -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();
});
});

View File

@@ -0,0 +1 @@
export * from "./VerifiedFilter";

View File

@@ -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;
}

View File

@@ -16,6 +16,7 @@ import "./sandboxes";
import "./auth";
import "./caching";
import "./collections";
import "./content_verification";
import "./whitelabel";
import "./embedding";
import "./snippets";

View File

@@ -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,
};
};

View File

@@ -23,7 +23,7 @@ export const createMockUser = (opts?: Partial<User>): User => ({
...opts,
});
export const createMockerUserListResult = (
export const createMockUserListResult = (
opts?: Partial<UserListResult>,
): UserListResult => ({
id: 1,

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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();
},
);
});
});

View File

@@ -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}

View File

@@ -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 => {

View File

@@ -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");
});
});
});

View File

@@ -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

View File

@@ -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">,
};

View File

@@ -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";

View File

@@ -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 => {

View File

@@ -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;
`;

View File

@@ -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>
);
};

View File

@@ -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();

View File

@@ -0,0 +1 @@
export { DropdownSidebarFilter } from "./DropdownSidebarFilter";

View File

@@ -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>
);
};

View File

@@ -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();
},
);
});

View File

@@ -0,0 +1 @@
export * from "./SearchFilterDateDisplay";

View File

@@ -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;
`;

View File

@@ -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}
/>
);

View File

@@ -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",
);
});
});

View File

@@ -0,0 +1 @@
export * from "./SearchFilterDatePicker";

View File

@@ -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};
`;
}}
`;

View File

@@ -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>
);
};

View File

@@ -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);
});
});

View File

@@ -0,0 +1 @@
export { SearchFilterPopoverWrapper } from "./SearchFilterPopoverWrapper";

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { SearchSidebar } from "./SearchSidebar";

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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,
});
};

View File

@@ -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};
}
`;
}}
`;

View File

@@ -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>
);
};

View File

@@ -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([]);
});
});

View File

@@ -0,0 +1 @@
export { SearchUserPicker } from "./SearchUserPicker";

View File

@@ -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]};
`;
}}
`;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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");
});
});

View File

@@ -0,0 +1,2 @@
export { ToggleSidebarFilter } from "./ToggleSidebarFilter";
export type { ToggleSidebarFilterProps } from "./ToggleSidebarFilter";

View File

@@ -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;
}
`;

View File

@@ -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>
);

View File

@@ -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",
);
});
});

View File

@@ -0,0 +1 @@
export { UserListElement } from "./UserListElement";

View File

@@ -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>
);
};

View File

@@ -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();
});
});

View File

@@ -0,0 +1,2 @@
export { UserNameDisplay } from "./UserNameDisplay";
export type { UserNameDisplayProps } from "./UserNameDisplay";

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -0,0 +1 @@
export * from "./CreatedByFilter";

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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),
};

View File

@@ -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`;
};

View File

@@ -0,0 +1 @@
export { NativeQueryFilter } from "./NativeQueryFilter";

View File

@@ -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;
},
};

View File

@@ -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"]);
});
});

View File

@@ -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>
);
};

View File

@@ -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([]);
});
});

View File

@@ -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>
);

View File

@@ -0,0 +1 @@
export * from "./TypeFilter";

View File

@@ -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[] = [

View File

@@ -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 ? (

View File

@@ -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};
}

View File

@@ -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],
);
},
);
});

View File

@@ -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;

View File

@@ -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);
};

View File

@@ -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([]);
});
});

View File

@@ -0,0 +1,4 @@
export {
isEnabledSearchModelType,
filterEnabledSearchTypes,
} from "./enabled-search-type";

View File

@@ -1 +1,3 @@
export * from "./search-location";
export * from "./user-search-params";
export * from "./enabled-search-type";

View File

@@ -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));
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1 @@
export { parseUserIdArray, stringifyUserIdArray } from "./user-search-params";

View File

@@ -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)) : [];

View File

@@ -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([]);
});
});

View File

@@ -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

View File

@@ -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)]

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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]

View File

@@ -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!

View File

@@ -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]

View File

@@ -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"

View File

@@ -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?

View File

@@ -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)
"")))

View 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)))))

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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