Frontend component of updating the API and accounting for default value not being in the paginated results of the relationship picker.

This commit is contained in:
mike12345567 2023-10-12 19:00:53 +01:00
parent 6bbce23910
commit 16d551542e
9 changed files with 153 additions and 74 deletions

View File

@ -14,13 +14,14 @@ import {
} from "../db" } from "../db"
import { import {
BulkDocsResponse, BulkDocsResponse,
ContextUser,
SearchQuery,
SearchQueryOperators,
SearchUsersRequest, SearchUsersRequest,
User, User,
ContextUser,
} from "@budibase/types" } from "@budibase/types"
import { getGlobalDB } from "../context"
import * as context from "../context" import * as context from "../context"
import { user as userCache } from "../cache" import { getGlobalDB } from "../context"
type GetOpts = { cleanup?: boolean } type GetOpts = { cleanup?: boolean }
@ -39,6 +40,27 @@ function removeUserPassword(users: User | User[]) {
return users 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 ( export const bulkGetGlobalUsersById = async (
userIds: string[], userIds: string[],
opts?: GetOpts opts?: GetOpts
@ -211,8 +233,8 @@ export const searchGlobalUsersByEmail = async (
const PAGE_LIMIT = 8 const PAGE_LIMIT = 8
export const paginatedUsers = async ({ export const paginatedUsers = async ({
page, bookmark,
email, query,
appId, appId,
}: SearchUsersRequest = {}) => { }: SearchUsersRequest = {}) => {
const db = getGlobalDB() const db = getGlobalDB()
@ -222,18 +244,20 @@ export const paginatedUsers = async ({
limit: PAGE_LIMIT + 1, limit: PAGE_LIMIT + 1,
} }
// add a startkey if the page was specified (anchor) // add a startkey if the page was specified (anchor)
if (page) { if (bookmark) {
opts.startkey = page opts.startkey = bookmark
} }
// property specifies what to use for the page/anchor // property specifies what to use for the page/anchor
let userList: User[], let userList: User[],
property = "_id", property = "_id",
getKey getKey
if (appId) { if (query?.equal?._id) {
userList = [await getById(query.equal._id)]
} else if (appId) {
userList = await searchGlobalUsersByApp(appId, opts) userList = await searchGlobalUsersByApp(appId, opts)
getKey = (doc: any) => getGlobalUserByAppPage(appId, doc) getKey = (doc: any) => getGlobalUserByAppPage(appId, doc)
} else if (email) { } else if (query?.string?.email) {
userList = await searchGlobalUsersByEmail(email, opts) userList = await searchGlobalUsersByEmail(query?.string?.email, opts)
property = "email" property = "email"
} else { } else {
// no search, query allDocs // no search, query allDocs

View File

@ -4,6 +4,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
import { FieldTypes } from "../../../constants" import { FieldTypes } from "../../../constants"
import { onMount } from "svelte"
const { API } = getContext("sdk") const { API } = getContext("sdk")
@ -25,6 +26,7 @@
let tableDefinition let tableDefinition
let searchTerm let searchTerm
let open let open
let hasFetchedDefault, fetchedDefault
$: type = $: type =
datasourceType === "table" ? FieldTypes.LINK : FieldTypes.BB_REFERENCE datasourceType === "table" ? FieldTypes.LINK : FieldTypes.BB_REFERENCE
@ -75,8 +77,8 @@
} }
} }
$: enrichedOptions = enrichOptions(optionsObj, $fetch.rows) $: enrichedOptions = enrichOptions(optionsObj, $fetch.rows, fetchedDefault)
const enrichOptions = (optionsObj, fetchResults) => { const enrichOptions = (optionsObj, fetchResults, fetchedDefault) => {
const result = (fetchResults || [])?.reduce((accumulator, row) => { const result = (fetchResults || [])?.reduce((accumulator, row) => {
if (!accumulator[row._id]) { if (!accumulator[row._id]) {
accumulator[row._id] = row accumulator[row._id] = row
@ -84,7 +86,11 @@
return accumulator return accumulator
}, optionsObj) }, 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 // 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 = const allRowsFetched =
$fetch.loaded && $fetch.loaded &&
!Object.keys($fetch.query?.string || {}).length && !Object.keys($fetch.query?.string || {}).length &&
!$fetch.hasNextPage !$fetch.hasNextPage
// Don't request until we have the primary display const shouldFetch = !defaultValue ? !allRowsFetched : gotDefault
if (!allRowsFetched && primaryDisplay) { // Don't request until we have the primary display or default value has been fetched
fetch.update({ if (shouldFetch && primaryDisplay) {
await fetch.update({
query: { string: { [primaryDisplay]: searchTerm } }, query: { string: { [primaryDisplay]: searchTerm } },
}) })
} }
@ -171,6 +178,20 @@
fetch.nextPage() 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
})
</script> </script>
<Field <Field

View File

@ -33,7 +33,6 @@ export const buildUserEndpoints = API => ({
if (limit) { if (limit) {
opts.limit = limit opts.limit = limit
} }
console.log(opts)
return await API.post({ return await API.post({
url: `/api/global/users/search`, url: `/api/global/users/search`,
body: opts, body: opts,

View File

@ -32,9 +32,9 @@ export default class UserFetch extends DataFetch {
const { cursor, query } = get(this.store) const { cursor, query } = get(this.store)
let finalQuery let finalQuery
// convert old format to new one - we now allow use of the lucene format // convert old format to new one - we now allow use of the lucene format
const { appId, paginated, ...rest} = query const { appId, paginated, ...rest } = query
if (!LuceneUtils.isLuceneFilter(query) && rest.email) { if (!LuceneUtils.hasFilters(query) && rest.email) {
finalQuery = { string: { email: rest.email }} finalQuery = { string: { email: rest.email } }
} else { } else {
finalQuery = rest finalQuery = rest
} }

View File

@ -15,7 +15,8 @@ import {
QuotaUsageType, QuotaUsageType,
RelationshipType, RelationshipType,
Row, Row,
SaveTableRequest, SearchQueryOperators, SaveTableRequest,
SearchQueryOperators,
SortOrder, SortOrder,
SortType, SortType,
StaticQuotaName, StaticQuotaName,
@ -1141,7 +1142,9 @@ describe.each([
) )
const createViewResponse = await config.createView({ const createViewResponse = await config.createView({
query: [{ operator: SearchQueryOperators.EQUAL, field: "age", value: 40 }], query: [
{ operator: SearchQueryOperators.EQUAL, field: "age", value: 40 },
],
schema: viewSchema, schema: viewSchema,
}) })

View File

@ -11,8 +11,8 @@ import {
UpdateViewRequest, UpdateViewRequest,
ViewV2, ViewV2,
} from "@budibase/types" } from "@budibase/types"
import {generator} from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
import {generateDatasourceID} from "../../../db/utils" import { generateDatasourceID } from "../../../db/utils"
function priceTable(): Table { function priceTable(): Table {
return { return {
@ -90,7 +90,13 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
primaryDisplay: generator.word(), primaryDisplay: generator.word(),
query: [{ operator: SearchQueryOperators.EQUAL, field: "field", value: "value" }], query: [
{
operator: SearchQueryOperators.EQUAL,
field: "field",
value: "value",
},
],
sort: { sort: {
field: "fieldToSort", field: "fieldToSort",
order: SortOrder.DESCENDING, order: SortOrder.DESCENDING,
@ -185,7 +191,13 @@ describe.each([
const tableId = table._id! const tableId = table._id!
await config.api.viewV2.update({ await config.api.viewV2.update({
...view, ...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({ expect((await config.api.table.get(tableId)).views).toEqual({
@ -280,7 +292,13 @@ describe.each([
{ {
...view, ...view,
tableId: generator.guid(), tableId: generator.guid(),
query: [{ operator: SearchQueryOperators.EQUAL, field: "newField", value: "thatValue" }], query: [
{
operator: SearchQueryOperators.EQUAL,
field: "newField",
value: "thatValue",
},
],
}, },
{ expectStatus: 404 } { expectStatus: 404 }
) )

View File

@ -9,8 +9,8 @@ import {
SortDirection, SortDirection,
SortType, SortType,
} from "@budibase/types" } from "@budibase/types"
import {OperatorOptions, SqlNumberTypeRangeMap} from "./constants" import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
import {deepGet} from "./helpers" import { deepGet } from "./helpers"
const HBS_REGEX = /{{([^{].*?)}}/g const HBS_REGEX = /{{([^{].*?)}}/g
@ -239,18 +239,6 @@ export const buildLuceneQuery = (filter: SearchFilter[]) => {
return query 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 * Performs a client-side lucene search on an array of data
* @param docs the 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) // Process a string match (fails if the value does not start with the string)
const stringMatch = match(SearchQueryOperators.STRING, (docValue: string, testValue: string) => { const stringMatch = match(
SearchQueryOperators.STRING,
(docValue: string, testValue: string) => {
return ( return (
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) !docValue ||
!docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
)
}
) )
})
// Process a fuzzy match (treat the same as starts with when running locally) // Process a fuzzy match (treat the same as starts with when running locally)
const fuzzyMatch = match(SearchQueryOperators.FUZZY, (docValue: string, testValue: string) => { const fuzzyMatch = match(
SearchQueryOperators.FUZZY,
(docValue: string, testValue: string) => {
return ( return (
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) !docValue ||
!docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
)
}
) )
})
// Process a range match // Process a range match
const rangeMatch = match( const rangeMatch = match(
@ -332,17 +328,25 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
) )
// Process an empty match (fails if the value is not empty) // Process an empty match (fails if the value is not empty)
const emptyMatch = match(SearchQueryOperators.EMPTY, (docValue: string | null) => { const emptyMatch = match(
SearchQueryOperators.EMPTY,
(docValue: string | null) => {
return docValue != null && docValue !== "" return docValue != null && docValue !== ""
}) }
)
// Process a not-empty match (fails is the value is empty) // Process a not-empty match (fails is the value is empty)
const notEmptyMatch = match(SearchQueryOperators.NOT_EMPTY, (docValue: string | null) => { const notEmptyMatch = match(
SearchQueryOperators.NOT_EMPTY,
(docValue: string | null) => {
return docValue == null || docValue === "" return docValue == null || docValue === ""
}) }
)
// Process an includes match (fails if the value is not included) // Process an includes match (fails if the value is not included)
const oneOf = match(SearchQueryOperators.ONE_OF, (docValue: any, testValue: any) => { const oneOf = match(
SearchQueryOperators.ONE_OF,
(docValue: any, testValue: any) => {
if (typeof testValue === "string") { if (typeof testValue === "string") {
testValue = testValue.split(",") testValue = testValue.split(",")
if (typeof docValue === "number") { if (typeof docValue === "number") {
@ -350,11 +354,15 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
} }
} }
return !testValue?.includes(docValue) return !testValue?.includes(docValue)
}) }
)
const containsAny = match(SearchQueryOperators.CONTAINS_ANY, (docValue: any, testValue: any) => { const containsAny = match(
SearchQueryOperators.CONTAINS_ANY,
(docValue: any, testValue: any) => {
return !docValue?.includes(...testValue) return !docValue?.includes(...testValue)
}) }
)
const contains = match( const contains = match(
SearchQueryOperators.CONTAINS, SearchQueryOperators.CONTAINS,
@ -446,7 +454,7 @@ export const hasFilters = (query?: SearchQuery) => {
if (skipped.includes(key) || typeof value !== "object") { if (skipped.includes(key) || typeof value !== "object") {
continue continue
} }
if (Object.keys(value).length !== 0) { if (Object.keys(value || {}).length !== 0) {
return true return true
} }
} }

View File

@ -1,4 +1,5 @@
import { User } from "../../documents" import { User } from "../../documents"
import { SearchQuery } from "./searchFilter"
export interface SaveUserResponse { export interface SaveUserResponse {
_id: string _id: string
@ -51,10 +52,10 @@ export interface InviteUsersResponse {
} }
export interface SearchUsersRequest { export interface SearchUsersRequest {
page?: string bookmark?: string
email?: string query?: SearchQuery
appId?: string appId?: string
paginated?: boolean paginate?: boolean
} }
export interface CreateAdminUserRequest { export interface CreateAdminUserRequest {

View File

@ -197,7 +197,12 @@ export const getAppUsers = async (ctx: Ctx<SearchUsersRequest>) => {
export const search = async (ctx: Ctx<SearchUsersRequest>) => { export const search = async (ctx: Ctx<SearchUsersRequest>) => {
const body = ctx.request.body 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) await getAppUsers(ctx)
} else { } else {
const paginated = await userSdk.core.paginatedUsers(body) const paginated = await userSdk.core.paginatedUsers(body)