merge with master

This commit is contained in:
Martin McKeaveney 2023-10-16 14:42:50 +01:00
commit 2ef9871fda
33 changed files with 325 additions and 362 deletions

View File

@ -10,7 +10,6 @@ on:
push: push:
branches: branches:
- master - master
- develop
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@ -262,11 +261,7 @@ jobs:
branch="${{ github.base_ref || github.ref_name }}" branch="${{ github.base_ref || github.ref_name }}"
echo "Running on branch '$branch' (base_ref=${{ github.base_ref }}, ref_name=${{ github.head_ref }})" echo "Running on branch '$branch' (base_ref=${{ github.base_ref }}, ref_name=${{ github.head_ref }})"
if [[ $branch == "master" ]]; then base_commit=$(git rev-parse origin/master)
base_commit=$(git rev-parse origin/master)
elif [[ $branch == "develop" ]]; then
base_commit=$(git rev-parse origin/develop)
fi
if [[ ! -z $base_commit ]]; then if [[ ! -z $base_commit ]]; then
echo "target_branch=$branch" echo "target_branch=$branch"

View File

@ -4,7 +4,13 @@ on:
pull_request: pull_request:
types: [closed] types: [closed]
branches: branches:
- develop - master
workflow_dispatch:
inputs:
BRANCH:
type: string
description: Which featurebranch branch to destroy?
required: true
jobs: jobs:
release: release:
@ -13,7 +19,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: passeidireto/trigger-external-workflow-action@main - uses: passeidireto/trigger-external-workflow-action@main
env: env:
PAYLOAD_BRANCH: ${{ github.head_ref }} PAYLOAD_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.BRANCH || github.head_ref }}
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }} PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
with: with:
repository: budibase/budibase-deploys repository: budibase/budibase-deploys

View File

@ -3,7 +3,6 @@ name: deploy-featurebranch
on: on:
pull_request: pull_request:
branches: branches:
- develop
- master - master
jobs: jobs:

View File

@ -1,41 +0,0 @@
name: "deploy-preprod"
on:
workflow_dispatch:
workflow_call:
jobs:
deploy-to-legacy-preprod-env:
runs-on: ubuntu-latest
steps:
- name: Fail if not a tag
run: |
if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Get the latest budibase release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- uses: passeidireto/trigger-external-workflow-action@main
env:
PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }}
with:
repository: budibase/budibase-deploys
event: budicloud-preprod-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View File

@ -1,124 +0,0 @@
name: Budibase Prerelease
concurrency:
group: release-prerelease
cancel-in-progress: false
on:
push:
tags:
- "*-alpha.*"
workflow_dispatch:
env:
# Posthog token used by ui at build time
# disable unless needed for testing
# POSTHOG_TOKEN: phc_uDYOfnFt6wAbBAXkC6STjcrTpAFiWIhqgFcsC1UVO5F
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
FEATURE_PREVIEW_URL: https://budirelease.live
jobs:
release-images:
runs-on: ubuntu-latest
steps:
- name: Fail if not a tag
run: |
if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if tag is not develop
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/develop; then
echo "Tag is not in develop"
exit 1
fi
- uses: actions/setup-node@v1
with:
node-version: 18.x
- run: yarn install --frozen-lockfile
- name: Update versions
run: ./scripts/updateVersions.sh
- run: yarn build
- run: yarn build:sdk
- name: Publish budibase packages to NPM
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# setup the username and email.
git config --global user.name "Budibase Staging Release Bot"
git config --global user.email "<>"
git submodule foreach git commit -a -m 'Release process'
git commit -a -m 'Release process'
echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc
yarn release:develop
- name: Build/release Docker images
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build:docker:develop
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
release-helm-chart:
needs: [release-images]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Helm
uses: azure/setup-helm@v1
id: helm-install
# due to helm repo index issue: https://github.com/helm/helm/issues/7363
# we need to create new package in a different dir, merge the index and move the package back
- name: Build and release helm chart
run: |
git config user.name "Budibase Helm Bot"
git config user.email "<>"
git reset --hard
git fetch
mkdir sync
echo "Packaging chart to sync dir"
helm package charts/budibase --version 0.0.0-develop --app-version develop --destination sync
echo "Packaging successful"
git checkout gh-pages
echo "Indexing helm repo"
helm repo index --merge docs/index.yaml sync
mv -f sync/* docs
rm -rf sync
echo "Pushing new helm release"
git add -A
git commit -m "Helm Release: develop"
git push
trigger-deploy-to-qa-env:
needs: [release-helm-chart]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Get the current budibase release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- uses: passeidireto/trigger-external-workflow-action@main
env:
PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }}
with:
repository: budibase/budibase-deploys
event: budicloud-qa-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View File

@ -110,19 +110,13 @@ jobs:
git commit -m "Helm Release: ${{ env.RELEASE_VERSION }}" git commit -m "Helm Release: ${{ env.RELEASE_VERSION }}"
git push git push
deploy-to-legacy-preprod-env:
needs: [release-images]
uses: ./.github/workflows/deploy-preprod.yml
secrets: inherit
# Trigger deploy to new EKS preprod environment trigger-deploy-to-qa-env:
trigger-deploy-to-preprod-env:
needs: [release-helm-chart] needs: [release-helm-chart]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Get the current budibase release version
- name: Get the latest budibase release version
id: version id: version
run: | run: |
release_version=$(cat lerna.json | jq -r '.version') release_version=$(cat lerna.json | jq -r '.version')
@ -133,5 +127,5 @@ jobs:
PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }} PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }}
with: with:
repository: budibase/budibase-deploys repository: budibase/budibase-deploys
event: budicloud-preprod-deploy event: budicloud-qa-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }} github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View File

@ -18,7 +18,7 @@ jobs:
- name: Maximize build space - name: Maximize build space
uses: easimon/maximize-build-space@master uses: easimon/maximize-build-space@master
with: with:
root-reserve-mb: 35000 root-reserve-mb: 30000
swap-size-mb: 1024 swap-size-mb: 1024
remove-android: 'true' remove-android: 'true'
remove-dotnet: 'true' remove-dotnet: 'true'
@ -33,14 +33,6 @@ jobs:
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1 uses: actions/setup-node@v1
@ -55,10 +47,6 @@ jobs:
run: yarn run: yarn
- name: Update versions - name: Update versions
run: ./scripts/updateVersions.sh run: ./scripts/updateVersions.sh
- name: Runt Yarn Lint
run: yarn lint
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Run Yarn Build - name: Run Yarn Build
run: yarn build:docker:pre run: yarn build:docker:pre
- name: Login to Docker Hub - name: Login to Docker Hub

View File

@ -2,7 +2,7 @@ name: Close stale issues and PRs # https://github.com/actions/stale
on: on:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: '*/30 * * * *' # Every 30 mins - cron: "*/30 * * * *" # Every 30 mins
jobs: jobs:
stale: stale:
@ -10,20 +10,37 @@ jobs:
steps: steps:
- uses: actions/stale@v8 - uses: actions/stale@v8
with: with:
# stale rules operations-per-run: 1
days-before-stale: 60 # stale rules for PRs
days-before-pr-stale: 7 days-before-pr-stale: 7
stale-issue-label: stale stale-issue-label: stale
stale-issue-message: "This issue has been automatically marked as stale because it has not had any activity for 60 days."
# close rules
# days after being marked as stale to close
days-before-close: 30
close-issue-label: closed-stale
close-issue-message: This issue has been automatically closed it has not had any activity in 90 days."
days-before-pr-close: 7
# exemptions
exempt-pr-labels: pinned,security,roadmap exempt-pr-labels: pinned,security,roadmap
days-before-pr-close: 7
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for high priority bugs
days-before-stale: 30
only-issue-labels: bug,High priority
stale-issue-label: warn
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for medium priority bugs
days-before-stale: 90
only-issue-labels: bug,Medium priority
stale-issue-label: warn
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for all bugs
days-before-stale: 180
stale-issue-label: stale
only-issue-labels: bug
stale-issue-message: "This issue has been automatically marked as stale because it has not had any activity for six months."
days-before-close: 30

View File

@ -1,42 +0,0 @@
name: Tag prerelease
concurrency:
group: tag-prerelease
cancel-in-progress: false
on:
push:
branches:
- develop
paths:
- ".aws/**"
- ".github/**"
- "charts/**"
- "packages/**"
- "scripts/**"
- "package.json"
- "yarn.lock"
workflow_dispatch:
jobs:
tag-prerelease:
runs-on: ubuntu-latest
steps:
- name: Fail if branch is not develop
if: github.ref != 'refs/heads/develop'
run: |
echo "Ref is not develop, you must run this job from develop."
exit 1
- uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- run: cd scripts && yarn
- name: Tag prerelease
run: |
cd scripts
# setup the username and email.
git config --global user.name "Budibase Staging Release Bot"
git config --global user.email "<>"
./versionCommit.sh prerelease

View File

@ -4,17 +4,6 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
on: on:
push:
branches:
- master
paths:
- ".aws/**"
- ".github/**"
- "charts/**"
- "packages/**"
- "scripts/**"
- "package.json"
- "yarn.lock"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
versioning: versioning:

View File

@ -12,14 +12,14 @@ RUN chmod +x /cleanup.sh
WORKDIR /app WORKDIR /app
ADD packages/server . ADD packages/server .
COPY yarn.lock . COPY yarn.lock .
RUN yarn install --production=true RUN yarn install --production=true --network-timeout 100000
RUN /cleanup.sh RUN /cleanup.sh
# build worker # build worker
WORKDIR /worker WORKDIR /worker
ADD packages/worker . ADD packages/worker .
COPY yarn.lock . COPY yarn.lock .
RUN yarn install --production=true RUN yarn install --production=true --network-timeout 100000
RUN /cleanup.sh RUN /cleanup.sh
FROM budibase/couchdb FROM budibase/couchdb

View File

@ -1,5 +1,5 @@
{ {
"version": "2.11.27-alpha.1", "version": "2.11.34",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

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,31 @@ 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 || {})
// this filter doesn't contain options - ignore
if (fields.length === 0) {
continue
}
const allowedOperation = allowed.find(
allow =>
allow.op === key && fields.length === 1 && fields[0] === allow.key
)
if (!allowedOperation) {
return false
}
}
return true
}
export const bulkGetGlobalUsersById = async ( export const bulkGetGlobalUsersById = async (
userIds: string[], userIds: string[],
opts?: GetOpts opts?: GetOpts
@ -211,8 +237,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 +248,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

@ -102,7 +102,7 @@
</div> </div>
{/if} {/if}
<div class="text" title={showTooltip ? text : null}> <div class="text" title={showTooltip ? text : null}>
{text} <span title={text}>{text}</span>
{#if selectedBy} {#if selectedBy}
<UserAvatars size="XS" users={selectedBy} /> <UserAvatars size="XS" users={selectedBy} />
{/if} {/if}
@ -227,9 +227,6 @@
.text { .text {
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto; flex: 1 1 auto;
color: var(--spectrum-global-color-gray-900); color: var(--spectrum-global-color-gray-900);
order: 2; order: 2;
@ -238,6 +235,11 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.text span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scrollable .text { .scrollable .text {
flex: 0 0 auto; flex: 0 0 auto;
max-width: 160px; max-width: 160px;

View File

@ -123,7 +123,10 @@
prevUserSearch = search prevUserSearch = search
try { try {
userPageInfo.loading() userPageInfo.loading()
await users.search({ userPage, email: search }) await users.search({
bookmark: userPage,
query: { string: { email: search } },
})
userPageInfo.fetched($users.hasNextPage, $users.nextPage) userPageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) { } catch (error) {
notifications.error("Error getting user list") notifications.error("Error getting user list")

View File

@ -31,7 +31,10 @@
prevSearch = search prevSearch = search
try { try {
pageInfo.loading() pageInfo.loading()
await users.search({ page, email: search }) await users.search({
bookmark: page,
query: { string: { email: search } },
})
pageInfo.fetched($users.hasNextPage, $users.nextPage) pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) { } catch (error) {
notifications.error("Error getting user list") notifications.error("Error getting user list")

View File

@ -3419,6 +3419,17 @@
"value": "custom" "value": "custom"
} }
}, },
{
"type": "event",
"label": "On change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "validation/string", "type": "validation/string",
"label": "Validation", "label": "Validation",

View File

@ -128,6 +128,7 @@
<div class="manual-input"> <div class="manual-input">
<Input <Input
bind:value bind:value
updateOnChange={false}
on:change={() => { on:change={() => {
dispatch("change", value) dispatch("change", value)
}} }}

View File

@ -105,19 +105,25 @@
} }
} }
$: fetchRows(searchTerm, primaryDisplay) $: fetchRows(searchTerm, primaryDisplay, defaultValue)
const fetchRows = (searchTerm, primaryDisplay) => { const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
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 // Don't request until we have the primary display or default value has been fetched
if (!allRowsFetched && primaryDisplay) { if (allRowsFetched || !primaryDisplay) {
fetch.update({ return
query: { string: { [primaryDisplay]: searchTerm } }, }
if (defaultVal && !optionsObj[defaultVal]) {
await fetch.update({
query: { equal: { _id: defaultVal } },
}) })
} }
await fetch.update({
query: { string: { [primaryDisplay]: searchTerm } },
})
} }
const flatten = values => { const flatten = values => {

View File

@ -10,24 +10,28 @@ export const buildUserEndpoints = API => ({
/** /**
* Gets a list of users in the current tenant. * Gets a list of users in the current tenant.
* @param {string} page The page to retrieve * @param {string} bookmark The page to retrieve
* @param {string} search The starts with string to search username/email by. * @param {object} query search filters for lookup by user (all operators not supported).
* @param {string} appId Facilitate app/role based user searching * @param {string} appId Facilitate app/role based user searching
* @param {boolean} paginated Allow the disabling of pagination * @param {boolean} paginate Allow the disabling of pagination
* @param {number} limit How many users to retrieve in a single search
*/ */
searchUsers: async ({ paginated, page, email, appId } = {}) => { searchUsers: async ({ paginate, bookmark, query, appId, limit } = {}) => {
const opts = {} const opts = {}
if (page) { if (bookmark) {
opts.page = page opts.bookmark = bookmark
} }
if (email) { if (query) {
opts.email = email opts.query = query
} }
if (appId) { if (appId) {
opts.appId = appId opts.appId = appId
} }
if (typeof paginated === "boolean") { if (typeof paginate === "boolean") {
opts.paginated = paginated opts.paginate = paginate
}
if (limit) {
opts.limit = limit
} }
return await API.post({ return await API.post({
url: `/api/global/users/search`, url: `/api/global/users/search`,

View File

@ -27,7 +27,7 @@
const email = Object.values(searchParams.query.string)[0] const email = Object.values(searchParams.query.string)[0]
const results = await API.searchUsers({ const results = await API.searchUsers({
email, query: { string: { email } },
}) })
// Mapping to the expected data within RelationshipCell // Mapping to the expected data within RelationshipCell

View File

@ -1,6 +1,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import DataFetch from "./DataFetch.js" import DataFetch from "./DataFetch.js"
import { TableNames } from "../constants" import { TableNames } from "../constants"
import { LuceneUtils } from "../utils"
export default class UserFetch extends DataFetch { export default class UserFetch extends DataFetch {
constructor(opts) { constructor(opts) {
@ -27,16 +28,25 @@ export default class UserFetch extends DataFetch {
} }
async getData() { async getData() {
const { limit, paginate } = this.options
const { cursor, query } = get(this.store) 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.hasFilters(query) && rest.email) {
finalQuery = { string: { email: rest.email } }
} else {
finalQuery = rest
}
try { try {
// "query" normally contains a lucene query, but users uses a non-standard const opts = {
// search endpoint so we use query uniquely here bookmark: cursor,
const res = await this.API.searchUsers({ query: finalQuery,
page: cursor, appId: appId,
email: query.email, paginate: paginated || paginate,
appId: query.appId, limit,
paginated: query.paginated, }
}) const res = await this.API.searchUsers(opts)
return { return {
rows: res?.data || [], rows: res?.data || [],
hasNextPage: res?.hasNextPage || false, hasNextPage: res?.hasNextPage || false,

View File

@ -16,6 +16,7 @@ import {
RelationshipType, RelationshipType,
Row, Row,
SaveTableRequest, 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: "equal", field: "age", value: 40 }], query: [
{ operator: SearchQueryOperators.EQUAL, field: "age", value: 40 },
],
schema: viewSchema, schema: viewSchema,
}) })

View File

@ -3,6 +3,7 @@ import {
CreateViewRequest, CreateViewRequest,
FieldSchema, FieldSchema,
FieldType, FieldType,
SearchQueryOperators,
SortOrder, SortOrder,
SortType, SortType,
Table, Table,
@ -89,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: "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,
@ -184,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: "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({
@ -207,7 +220,7 @@ describe.each([
primaryDisplay: generator.word(), primaryDisplay: generator.word(),
query: [ query: [
{ {
operator: "equal", operator: SearchQueryOperators.EQUAL,
field: generator.word(), field: generator.word(),
value: generator.word(), value: generator.word(),
}, },
@ -279,7 +292,13 @@ describe.each([
{ {
...view, ...view,
tableId: generator.guid(), tableId: generator.guid(),
query: [{ operator: "equal", field: "newField", value: "thatValue" }], query: [
{
operator: SearchQueryOperators.EQUAL,
field: "newField",
value: "thatValue",
},
],
}, },
{ expectStatus: 404 } { expectStatus: 404 }
) )

View File

@ -43,6 +43,10 @@ describe("postgres integrations", () => {
) )
}) })
afterAll(async () => {
await databaseTestProviders.postgres.stopContainer()
})
beforeEach(async () => { beforeEach(async () => {
async function createAuxTable(prefix: string) { async function createAuxTable(prefix: string) {
return await config.createTable({ return await config.createTable({

View File

@ -36,3 +36,10 @@ export async function getDsConfig(): Promise<Datasource> {
}, },
} }
} }
export async function stopContainer() {
if (container) {
await container.stop()
container = undefined
}
}

View File

@ -1,12 +1,13 @@
import { import {
Datasource, Datasource,
FieldSubtype,
FieldType, FieldType,
SortDirection,
SortType,
SearchFilter, SearchFilter,
SearchQuery, SearchQuery,
SearchQueryFields, SearchQueryFields,
FieldSubtype, SearchQueryOperators,
SortDirection,
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"
@ -273,22 +274,30 @@ 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("string", (docValue: string, testValue: string) => { const stringMatch = match(
return ( SearchQueryOperators.STRING,
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) (docValue: string, testValue: string) => {
) return (
}) !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("fuzzy", (docValue: string, testValue: string) => { const fuzzyMatch = match(
return ( SearchQueryOperators.FUZZY,
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) (docValue: string, testValue: string) => {
) return (
}) !docValue ||
!docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
)
}
)
// Process a range match // Process a range match
const rangeMatch = match( const rangeMatch = match(
"range", SearchQueryOperators.RANGE,
( (
docValue: string | number | null, docValue: string | number | null,
testValue: { low: number; high: number } testValue: { low: number; high: number }
@ -304,7 +313,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process an equal match (fails if the value is different) // Process an equal match (fails if the value is different)
const equalMatch = match( const equalMatch = match(
"equal", SearchQueryOperators.EQUAL,
(docValue: any, testValue: string | null) => { (docValue: any, testValue: string | null) => {
return testValue != null && testValue !== "" && docValue !== testValue return testValue != null && testValue !== "" && docValue !== testValue
} }
@ -312,46 +321,58 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process a not-equal match (fails if the value is the same) // Process a not-equal match (fails if the value is the same)
const notEqualMatch = match( const notEqualMatch = match(
"notEqual", SearchQueryOperators.NOT_EQUAL,
(docValue: any, testValue: string | null) => { (docValue: any, testValue: string | null) => {
return testValue != null && testValue !== "" && docValue === testValue return testValue != null && testValue !== "" && docValue === testValue
} }
) )
// 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("empty", (docValue: string | null) => { const emptyMatch = match(
return docValue != null && docValue !== "" SearchQueryOperators.EMPTY,
}) (docValue: string | null) => {
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("notEmpty", (docValue: string | null) => { const notEmptyMatch = match(
return docValue == null || docValue === "" SearchQueryOperators.NOT_EMPTY,
}) (docValue: string | null) => {
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("oneOf", (docValue: any, testValue: any) => { const oneOf = match(
if (typeof testValue === "string") { SearchQueryOperators.ONE_OF,
testValue = testValue.split(",") (docValue: any, testValue: any) => {
if (typeof docValue === "number") { if (typeof testValue === "string") {
testValue = testValue.map((item: string) => parseFloat(item)) 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("containsAny", (docValue: any, testValue: any) => { const containsAny = match(
return !docValue?.includes(...testValue) SearchQueryOperators.CONTAINS_ANY,
}) (docValue: any, testValue: any) => {
return !docValue?.includes(...testValue)
}
)
const contains = match( const contains = match(
"contains", SearchQueryOperators.CONTAINS,
(docValue: string | any[], testValue: any[]) => { (docValue: string | any[], testValue: any[]) => {
return !testValue?.every((item: any) => docValue?.includes(item)) return !testValue?.every((item: any) => docValue?.includes(item))
} }
) )
const notContains = match( const notContains = match(
"notContains", SearchQueryOperators.NOT_CONTAINS,
(docValue: string | any[], testValue: any[]) => { (docValue: string | any[], testValue: any[]) => {
return testValue?.every((item: any) => docValue?.includes(item)) return testValue?.every((item: any) => docValue?.includes(item))
} }
@ -433,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

@ -10,43 +10,57 @@ export type SearchFilter = {
externalType?: string externalType?: string
} }
export enum SearchQueryOperators {
STRING = "string",
FUZZY = "fuzzy",
RANGE = "range",
EQUAL = "equal",
NOT_EQUAL = "notEqual",
EMPTY = "empty",
NOT_EMPTY = "notEmpty",
ONE_OF = "oneOf",
CONTAINS = "contains",
NOT_CONTAINS = "notContains",
CONTAINS_ANY = "containsAny",
}
export type SearchQuery = { export type SearchQuery = {
allOr?: boolean allOr?: boolean
onEmptyFilter?: EmptyFilterOption onEmptyFilter?: EmptyFilterOption
string?: { [SearchQueryOperators.STRING]?: {
[key: string]: string [key: string]: string
} }
fuzzy?: { [SearchQueryOperators.FUZZY]?: {
[key: string]: string [key: string]: string
} }
range?: { [SearchQueryOperators.RANGE]?: {
[key: string]: { [key: string]: {
high: number | string high: number | string
low: number | string low: number | string
} }
} }
equal?: { [SearchQueryOperators.EQUAL]?: {
[key: string]: any [key: string]: any
} }
notEqual?: { [SearchQueryOperators.NOT_EQUAL]?: {
[key: string]: any [key: string]: any
} }
empty?: { [SearchQueryOperators.EMPTY]?: {
[key: string]: any [key: string]: any
} }
notEmpty?: { [SearchQueryOperators.NOT_EMPTY]?: {
[key: string]: any [key: string]: any
} }
oneOf?: { [SearchQueryOperators.ONE_OF]?: {
[key: string]: any[] [key: string]: any[]
} }
contains?: { [SearchQueryOperators.CONTAINS]?: {
[key: string]: any[] [key: string]: any[]
} }
notContains?: { [SearchQueryOperators.NOT_CONTAINS]?: {
[key: string]: any[] [key: string]: any[]
} }
containsAny?: { [SearchQueryOperators.CONTAINS_ANY]?: {
[key: string]: any[] [key: string]: any[]
} }
} }

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)

View File

@ -544,6 +544,36 @@ describe("/api/global/users", () => {
}) })
}) })
describe("POST /api/global/users/search", () => {
it("should be able to search by email", async () => {
const user = await config.createUser()
const response = await config.api.users.searchUsers({
query: { string: { email: user.email } },
})
expect(response.body.data.length).toBe(1)
expect(response.body.data[0].email).toBe(user.email)
})
it("should be able to search by _id", async () => {
const user = await config.createUser()
const response = await config.api.users.searchUsers({
query: { equal: { _id: user._id } },
})
expect(response.body.data.length).toBe(1)
expect(response.body.data[0]._id).toBe(user._id)
})
it("should throw an error when unimplemented options used", async () => {
const user = await config.createUser()
await config.api.users.searchUsers(
{
query: { equal: { firstName: user.firstName } },
},
501
)
})
})
describe("DELETE /api/global/users/:userId", () => { describe("DELETE /api/global/users/:userId", () => {
it("should be able to destroy a basic user", async () => { it("should be able to destroy a basic user", async () => {
const user = await config.createUser() const user = await config.createUser()

View File

@ -4,6 +4,7 @@ import {
InviteUsersRequest, InviteUsersRequest,
User, User,
CreateAdminUserRequest, CreateAdminUserRequest,
SearchQuery,
} from "@budibase/types" } from "@budibase/types"
import structures from "../structures" import structures from "../structures"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
@ -133,6 +134,15 @@ export class UserAPI extends TestAPI {
.expect(status ? status : 200) .expect(status ? status : 200)
} }
searchUsers = ({ query }: { query?: SearchQuery }, status = 200) => {
return this.request
.post("/api/global/users/search")
.set(this.config.defaultHeaders())
.send({ query })
.expect("Content-Type", /json/)
.expect(status ? status : 200)
}
getUser = (userId: string, opts?: TestAPIOpts) => { getUser = (userId: string, opts?: TestAPIOpts) => {
return this.request return this.request
.get(`/api/global/users/${userId}`) .get(`/api/global/users/${userId}`)

View File

@ -21750,7 +21750,7 @@ vlq@^0.2.2:
resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow== integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==
vm2@^3.9.19: vm2@^3.9.19, vm2@^3.9.8:
version "3.9.19" version "3.9.19"
resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.19.tgz#be1e1d7a106122c6c492b4d51c2e8b93d3ed6a4a" resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.19.tgz#be1e1d7a106122c6c492b4d51c2e8b93d3ed6a4a"
integrity sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg== integrity sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==