diff --git a/packages/backend-core/src/users/users.ts b/packages/backend-core/src/users/users.ts index a7e1389920..71ac460112 100644 --- a/packages/backend-core/src/users/users.ts +++ b/packages/backend-core/src/users/users.ts @@ -14,13 +14,14 @@ import { } from "../db" import { BulkDocsResponse, + ContextUser, + SearchQuery, + SearchQueryOperators, SearchUsersRequest, User, - ContextUser, } from "@budibase/types" -import { getGlobalDB } from "../context" import * as context from "../context" -import { user as userCache } from "../cache" +import { getGlobalDB } from "../context" type GetOpts = { cleanup?: boolean } @@ -39,6 +40,27 @@ function removeUserPassword(users: User | User[]) { return users } +export const isSupportedUserSearch = (query: SearchQuery) => { + const allowed = [ + { op: SearchQueryOperators.STRING, key: "email" }, + { op: SearchQueryOperators.EQUAL, key: "_id" }, + ] + for (let [key, operation] of Object.entries(query)) { + if (typeof operation !== "object") { + return false + } + const fields = Object.keys(operation || {}) + const allowedOperation = allowed.find( + allow => + allow.op === key && fields.length === 1 && fields[0] === allow.key + ) + if (!allowedOperation && fields.length > 0) { + return false + } + } + return true +} + export const bulkGetGlobalUsersById = async ( userIds: string[], opts?: GetOpts @@ -211,8 +233,8 @@ export const searchGlobalUsersByEmail = async ( const PAGE_LIMIT = 8 export const paginatedUsers = async ({ - page, - email, + bookmark, + query, appId, }: SearchUsersRequest = {}) => { const db = getGlobalDB() @@ -222,18 +244,20 @@ export const paginatedUsers = async ({ limit: PAGE_LIMIT + 1, } // add a startkey if the page was specified (anchor) - if (page) { - opts.startkey = page + if (bookmark) { + opts.startkey = bookmark } // property specifies what to use for the page/anchor let userList: User[], property = "_id", getKey - if (appId) { + if (query?.equal?._id) { + userList = [await getById(query.equal._id)] + } else if (appId) { userList = await searchGlobalUsersByApp(appId, opts) getKey = (doc: any) => getGlobalUserByAppPage(appId, doc) - } else if (email) { - userList = await searchGlobalUsersByEmail(email, opts) + } else if (query?.string?.email) { + userList = await searchGlobalUsersByEmail(query?.string?.email, opts) property = "email" } else { // no search, query allDocs diff --git a/packages/client/src/components/app/forms/RelationshipField.svelte b/packages/client/src/components/app/forms/RelationshipField.svelte index 52faf46615..5dbf51e169 100644 --- a/packages/client/src/components/app/forms/RelationshipField.svelte +++ b/packages/client/src/components/app/forms/RelationshipField.svelte @@ -4,6 +4,7 @@ import { getContext } from "svelte" import Field from "./Field.svelte" import { FieldTypes } from "../../../constants" + import { onMount } from "svelte" const { API } = getContext("sdk") @@ -25,6 +26,7 @@ let tableDefinition let searchTerm let open + let hasFetchedDefault, fetchedDefault $: type = datasourceType === "table" ? FieldTypes.LINK : FieldTypes.BB_REFERENCE @@ -75,8 +77,8 @@ } } - $: enrichedOptions = enrichOptions(optionsObj, $fetch.rows) - const enrichOptions = (optionsObj, fetchResults) => { + $: enrichedOptions = enrichOptions(optionsObj, $fetch.rows, fetchedDefault) + const enrichOptions = (optionsObj, fetchResults, fetchedDefault) => { const result = (fetchResults || [])?.reduce((accumulator, row) => { if (!accumulator[row._id]) { accumulator[row._id] = row @@ -84,7 +86,11 @@ return accumulator }, optionsObj) - return Object.values(result) + const final = Object.values(result) + if (fetchedDefault && !final.find(row => row._id === fetchedDefault._id)) { + final.push(fetchedDefault) + } + return final } $: { // We don't want to reorder while the dropdown is open, to avoid UX jumps @@ -105,16 +111,17 @@ } } - $: fetchRows(searchTerm, primaryDisplay) + $: fetchRows(searchTerm, primaryDisplay, hasFetchedDefault) - const fetchRows = (searchTerm, primaryDisplay) => { + const fetchRows = async (searchTerm, primaryDisplay, gotDefault) => { const allRowsFetched = $fetch.loaded && !Object.keys($fetch.query?.string || {}).length && !$fetch.hasNextPage - // Don't request until we have the primary display - if (!allRowsFetched && primaryDisplay) { - fetch.update({ + const shouldFetch = !defaultValue ? !allRowsFetched : gotDefault + // Don't request until we have the primary display or default value has been fetched + if (shouldFetch && primaryDisplay) { + await fetch.update({ query: { string: { [primaryDisplay]: searchTerm } }, }) } @@ -171,6 +178,20 @@ fetch.nextPage() } } + + onMount(async () => { + // the pagination might not include the default row + if (defaultValue) { + await fetch.update({ + query: { equal: { _id: defaultValue }} + }) + const fetched = $fetch.rows?.[0] + if (fetched) { + fetchedDefault = { ...fetched } + } + } + hasFetchedDefault = true + }) ({ if (limit) { opts.limit = limit } - console.log(opts) return await API.post({ url: `/api/global/users/search`, body: opts, diff --git a/packages/frontend-core/src/fetch/UserFetch.js b/packages/frontend-core/src/fetch/UserFetch.js index 2158b3ca57..b1478c3a6d 100644 --- a/packages/frontend-core/src/fetch/UserFetch.js +++ b/packages/frontend-core/src/fetch/UserFetch.js @@ -32,9 +32,9 @@ export default class UserFetch extends DataFetch { const { cursor, query } = get(this.store) let finalQuery // convert old format to new one - we now allow use of the lucene format - const { appId, paginated, ...rest} = query - if (!LuceneUtils.isLuceneFilter(query) && rest.email) { - finalQuery = { string: { email: rest.email }} + const { appId, paginated, ...rest } = query + if (!LuceneUtils.hasFilters(query) && rest.email) { + finalQuery = { string: { email: rest.email } } } else { finalQuery = rest } diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 3ac8ceaf16..4c2e7a7494 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -15,7 +15,8 @@ import { QuotaUsageType, RelationshipType, Row, - SaveTableRequest, SearchQueryOperators, + SaveTableRequest, + SearchQueryOperators, SortOrder, SortType, StaticQuotaName, @@ -1141,7 +1142,9 @@ describe.each([ ) const createViewResponse = await config.createView({ - query: [{ operator: SearchQueryOperators.EQUAL, field: "age", value: 40 }], + query: [ + { operator: SearchQueryOperators.EQUAL, field: "age", value: 40 }, + ], schema: viewSchema, }) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 7f4cf53ff1..40060aef48 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -11,8 +11,8 @@ import { UpdateViewRequest, ViewV2, } from "@budibase/types" -import {generator} from "@budibase/backend-core/tests" -import {generateDatasourceID} from "../../../db/utils" +import { generator } from "@budibase/backend-core/tests" +import { generateDatasourceID } from "../../../db/utils" function priceTable(): Table { return { @@ -90,7 +90,13 @@ describe.each([ name: generator.name(), tableId: table._id!, primaryDisplay: generator.word(), - query: [{ operator: SearchQueryOperators.EQUAL, field: "field", value: "value" }], + query: [ + { + operator: SearchQueryOperators.EQUAL, + field: "field", + value: "value", + }, + ], sort: { field: "fieldToSort", order: SortOrder.DESCENDING, @@ -185,7 +191,13 @@ describe.each([ const tableId = table._id! await config.api.viewV2.update({ ...view, - query: [{ operator: SearchQueryOperators.EQUAL, field: "newField", value: "thatValue" }], + query: [ + { + operator: SearchQueryOperators.EQUAL, + field: "newField", + value: "thatValue", + }, + ], }) expect((await config.api.table.get(tableId)).views).toEqual({ @@ -280,7 +292,13 @@ describe.each([ { ...view, tableId: generator.guid(), - query: [{ operator: SearchQueryOperators.EQUAL, field: "newField", value: "thatValue" }], + query: [ + { + operator: SearchQueryOperators.EQUAL, + field: "newField", + value: "thatValue", + }, + ], }, { expectStatus: 404 } ) diff --git a/packages/shared-core/src/filters.ts b/packages/shared-core/src/filters.ts index 246b089af0..1839a53525 100644 --- a/packages/shared-core/src/filters.ts +++ b/packages/shared-core/src/filters.ts @@ -9,8 +9,8 @@ import { SortDirection, SortType, } from "@budibase/types" -import {OperatorOptions, SqlNumberTypeRangeMap} from "./constants" -import {deepGet} from "./helpers" +import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants" +import { deepGet } from "./helpers" const HBS_REGEX = /{{([^{].*?)}}/g @@ -239,18 +239,6 @@ export const buildLuceneQuery = (filter: SearchFilter[]) => { return query } -// type unknown -export const isLuceneFilter = (search: any) => { - if (typeof search !== "object") { - return false - } - const operators = Object.values(SearchQueryOperators) as string[] - const anySearchKey = Object.keys(search).find(key => { - return operators.includes(key) && typeof search[key] === "object" - }) - return !!anySearchKey -} - /** * Performs a client-side lucene search on an array of data * @param docs the data @@ -286,18 +274,26 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => { } // Process a string match (fails if the value does not start with the string) - const stringMatch = match(SearchQueryOperators.STRING, (docValue: string, testValue: string) => { - return ( - !docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) - ) - }) + const stringMatch = match( + SearchQueryOperators.STRING, + (docValue: string, testValue: string) => { + return ( + !docValue || + !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) + ) + } + ) // Process a fuzzy match (treat the same as starts with when running locally) - const fuzzyMatch = match(SearchQueryOperators.FUZZY, (docValue: string, testValue: string) => { - return ( - !docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) - ) - }) + const fuzzyMatch = match( + SearchQueryOperators.FUZZY, + (docValue: string, testValue: string) => { + return ( + !docValue || + !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) + ) + } + ) // Process a range match const rangeMatch = match( @@ -332,29 +328,41 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => { ) // Process an empty match (fails if the value is not empty) - const emptyMatch = match(SearchQueryOperators.EMPTY, (docValue: string | null) => { - return docValue != null && docValue !== "" - }) + const emptyMatch = match( + SearchQueryOperators.EMPTY, + (docValue: string | null) => { + return docValue != null && docValue !== "" + } + ) // Process a not-empty match (fails is the value is empty) - const notEmptyMatch = match(SearchQueryOperators.NOT_EMPTY, (docValue: string | null) => { - return docValue == null || docValue === "" - }) + const notEmptyMatch = match( + SearchQueryOperators.NOT_EMPTY, + (docValue: string | null) => { + return docValue == null || docValue === "" + } + ) // Process an includes match (fails if the value is not included) - const oneOf = match(SearchQueryOperators.ONE_OF, (docValue: any, testValue: any) => { - if (typeof testValue === "string") { - testValue = testValue.split(",") - if (typeof docValue === "number") { - testValue = testValue.map((item: string) => parseFloat(item)) + const oneOf = match( + SearchQueryOperators.ONE_OF, + (docValue: any, testValue: any) => { + if (typeof testValue === "string") { + testValue = testValue.split(",") + if (typeof docValue === "number") { + testValue = testValue.map((item: string) => parseFloat(item)) + } } + return !testValue?.includes(docValue) } - return !testValue?.includes(docValue) - }) + ) - const containsAny = match(SearchQueryOperators.CONTAINS_ANY, (docValue: any, testValue: any) => { - return !docValue?.includes(...testValue) - }) + const containsAny = match( + SearchQueryOperators.CONTAINS_ANY, + (docValue: any, testValue: any) => { + return !docValue?.includes(...testValue) + } + ) const contains = match( SearchQueryOperators.CONTAINS, @@ -446,7 +454,7 @@ export const hasFilters = (query?: SearchQuery) => { if (skipped.includes(key) || typeof value !== "object") { continue } - if (Object.keys(value).length !== 0) { + if (Object.keys(value || {}).length !== 0) { return true } } diff --git a/packages/types/src/api/web/user.ts b/packages/types/src/api/web/user.ts index 85e2d89ad1..a1e039cfd7 100644 --- a/packages/types/src/api/web/user.ts +++ b/packages/types/src/api/web/user.ts @@ -1,4 +1,5 @@ import { User } from "../../documents" +import { SearchQuery } from "./searchFilter" export interface SaveUserResponse { _id: string @@ -51,10 +52,10 @@ export interface InviteUsersResponse { } export interface SearchUsersRequest { - page?: string - email?: string + bookmark?: string + query?: SearchQuery appId?: string - paginated?: boolean + paginate?: boolean } export interface CreateAdminUserRequest { diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 822a16d33e..8de3a1444e 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -197,7 +197,12 @@ export const getAppUsers = async (ctx: Ctx) => { export const search = async (ctx: Ctx) => { const body = ctx.request.body - if (body.paginated === false) { + // TODO: for now only one supported search key, string.email + if (body?.query && !userSdk.core.isSupportedUserSearch(body.query)) { + ctx.throw(501, "Can only search by string.email or equal._id") + } + + if (body.paginate === false) { await getAppUsers(ctx) } else { const paginated = await userSdk.core.paginatedUsers(body)