Merge branch 'master' into feature/onboarding
This commit is contained in:
commit
1da10c9a5f
|
@ -6,6 +6,7 @@ packages/server/coverage
|
||||||
packages/worker/coverage
|
packages/worker/coverage
|
||||||
packages/backend-core/coverage
|
packages/backend-core/coverage
|
||||||
packages/server/client
|
packages/server/client
|
||||||
|
packages/server/coverage
|
||||||
packages/builder/.routify
|
packages/builder/.routify
|
||||||
packages/sdk/sdk
|
packages/sdk/sdk
|
||||||
packages/account-portal/packages/server/build
|
packages/account-portal/packages/server/build
|
||||||
|
|
|
@ -34,17 +34,43 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["**/*.ts"],
|
"files": ["**/*.ts"],
|
||||||
|
"excludedFiles": ["qa-core/**"],
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
"extends": ["eslint:recommended"],
|
"extends": ["eslint:recommended"],
|
||||||
|
"globals": {
|
||||||
|
"NodeJS": true
|
||||||
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"no-inner-declarations": "off",
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
"no-case-declarations": "off",
|
"local-rules/no-budibase-imports": "error"
|
||||||
"no-useless-escape": "off",
|
}
|
||||||
"no-undef": "off",
|
},
|
||||||
"no-prototype-builtins": "off",
|
{
|
||||||
"local-rules/no-budibase-imports": "error",
|
"files": ["**/*.spec.ts"],
|
||||||
"local-rules/no-test-com": "error"
|
"excludedFiles": ["qa-core/**"],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["jest", "@typescript-eslint"],
|
||||||
|
"extends": ["eslint:recommended", "plugin:jest/recommended"],
|
||||||
|
"env": {
|
||||||
|
"jest/globals": true
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"NodeJS": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
|
"local-rules/no-test-com": "error",
|
||||||
|
"local-rules/email-domain-example-com": "error",
|
||||||
|
"no-console": "warn",
|
||||||
|
// We have a lot of tests that don't have assertions, they use our test
|
||||||
|
// API client that does the assertions for them
|
||||||
|
"jest/expect-expect": "off",
|
||||||
|
// We do this in some tests where the behaviour of internal tables
|
||||||
|
// differs to external, but the API is broadly the same
|
||||||
|
"jest/no-conditional-expect": "off"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -107,9 +107,9 @@ jobs:
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
if ${{ env.USE_NX_AFFECTED }}; then
|
if ${{ env.USE_NX_AFFECTED }}; then
|
||||||
yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro --since=${{ env.NX_BASE_BRANCH }}
|
yarn test --ignore=@budibase/worker --ignore=@budibase/server --since=${{ env.NX_BASE_BRANCH }}
|
||||||
else
|
else
|
||||||
yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro
|
yarn test --ignore=@budibase/worker --ignore=@budibase/server
|
||||||
fi
|
fi
|
||||||
|
|
||||||
test-worker:
|
test-worker:
|
||||||
|
@ -160,31 +160,6 @@ jobs:
|
||||||
yarn test --scope=@budibase/server
|
yarn test --scope=@budibase/server
|
||||||
fi
|
fi
|
||||||
|
|
||||||
test-pro:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo and submodules
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Use Node.js 20.x
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
cache: yarn
|
|
||||||
- run: yarn --frozen-lockfile
|
|
||||||
- name: Test
|
|
||||||
run: |
|
|
||||||
if ${{ env.USE_NX_AFFECTED }}; then
|
|
||||||
yarn test --scope=@budibase/pro --since=${{ env.NX_BASE_BRANCH }}
|
|
||||||
else
|
|
||||||
yarn test --scope=@budibase/pro
|
|
||||||
fi
|
|
||||||
|
|
||||||
integration-test:
|
integration-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
|
@ -107,5 +107,4 @@ budibase-component
|
||||||
budibase-datasource
|
budibase-datasource
|
||||||
|
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
.nx
|
.nx
|
|
@ -140,7 +140,7 @@ $ helm install --create-namespace --namespace budibase budibase . -f values.yaml
|
||||||
| ingress.className | string | `""` | What ingress class to use. |
|
| ingress.className | string | `""` | What ingress class to use. |
|
||||||
| ingress.enabled | bool | `true` | Whether to create an Ingress resource pointing to the Budibase proxy. |
|
| ingress.enabled | bool | `true` | Whether to create an Ingress resource pointing to the Budibase proxy. |
|
||||||
| ingress.hosts | list | `[]` | Standard hosts block for the Ingress resource. Defaults to pointing to the Budibase proxy. |
|
| ingress.hosts | list | `[]` | Standard hosts block for the Ingress resource. Defaults to pointing to the Budibase proxy. |
|
||||||
| nameOverride | string | `""` | Override the name of the deploymen. Defaults to {{ .Chart.Name }}. |
|
| nameOverride | string | `""` | Override the name of the deployment. Defaults to {{ .Chart.Name }}. |
|
||||||
| service.port | int | `10000` | Port to expose on the service. |
|
| service.port | int | `10000` | Port to expose on the service. |
|
||||||
| service.type | string | `"ClusterIP"` | Service type for the service that points to the main Budibase proxy pod. |
|
| service.type | string | `"ClusterIP"` | Service type for the service that points to the main Budibase proxy pod. |
|
||||||
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account |
|
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account |
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# -- Passed to all pods created by this chart. Should not ordinarily need to be changed.
|
# -- Passed to all pods created by this chart. Should not ordinarily need to be changed.
|
||||||
imagePullSecrets: []
|
imagePullSecrets: []
|
||||||
# -- Override the name of the deploymen. Defaults to {{ .Chart.Name }}.
|
# -- Override the name of the deployment. Defaults to {{ .Chart.Name }}.
|
||||||
nameOverride: ""
|
nameOverride: ""
|
||||||
|
|
||||||
serviceAccount:
|
serviceAccount:
|
||||||
|
|
|
@ -7,11 +7,12 @@ module.exports = {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
/^@budibase\/[^/]+\/.*$/.test(importPath) &&
|
/^@budibase\/[^/]+\/.*$/.test(importPath) &&
|
||||||
importPath !== "@budibase/backend-core/tests"
|
importPath !== "@budibase/backend-core/tests" &&
|
||||||
|
importPath !== "@budibase/string-templates/test/utils"
|
||||||
) {
|
) {
|
||||||
context.report({
|
context.report({
|
||||||
node,
|
node,
|
||||||
message: `Importing from @budibase is not allowed, except for @budibase/backend-core/tests.`,
|
message: `Importing from @budibase is not allowed, except for @budibase/backend-core/tests and @budibase/string-templates/test/utils.`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -24,11 +25,9 @@ module.exports = {
|
||||||
docs: {
|
docs: {
|
||||||
description:
|
description:
|
||||||
"disallow the use of 'test.com' in strings and replace it with 'example.com'",
|
"disallow the use of 'test.com' in strings and replace it with 'example.com'",
|
||||||
category: "Possible Errors",
|
|
||||||
recommended: false,
|
|
||||||
},
|
},
|
||||||
schema: [], // no options
|
schema: [],
|
||||||
fixable: "code", // Indicates that this rule supports automatic fixing
|
fixable: "code",
|
||||||
},
|
},
|
||||||
create: function (context) {
|
create: function (context) {
|
||||||
return {
|
return {
|
||||||
|
@ -51,4 +50,39 @@ module.exports = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"email-domain-example-com": {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"enforce using the example.com domain for generator.email calls",
|
||||||
|
},
|
||||||
|
fixable: "code",
|
||||||
|
schema: [],
|
||||||
|
},
|
||||||
|
create: function (context) {
|
||||||
|
return {
|
||||||
|
CallExpression(node) {
|
||||||
|
if (
|
||||||
|
node.callee.type === "MemberExpression" &&
|
||||||
|
node.callee.object.name === "generator" &&
|
||||||
|
node.callee.property.name === "email" &&
|
||||||
|
node.arguments.length === 0
|
||||||
|
) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message:
|
||||||
|
"Prefer using generator.email with the domain \"{ domain: 'example.com' }\".",
|
||||||
|
fix: function (fixer) {
|
||||||
|
return fixer.replaceText(
|
||||||
|
node,
|
||||||
|
'generator.email({ domain: "example.com" })'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,8 +12,6 @@ COPY .yarnrc .
|
||||||
|
|
||||||
COPY packages/server/package.json packages/server/package.json
|
COPY packages/server/package.json packages/server/package.json
|
||||||
COPY packages/worker/package.json packages/worker/package.json
|
COPY packages/worker/package.json packages/worker/package.json
|
||||||
# string-templates does not get bundled during the esbuild process, so we want to use the local version
|
|
||||||
COPY packages/string-templates/package.json packages/string-templates/package.json
|
|
||||||
|
|
||||||
|
|
||||||
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
|
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
|
||||||
|
@ -26,7 +24,7 @@ RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json
|
||||||
RUN echo '' > scripts/syncProPackage.js
|
RUN echo '' > scripts/syncProPackage.js
|
||||||
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
|
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
|
||||||
RUN ./scripts/removeWorkspaceDependencies.sh package.json
|
RUN ./scripts/removeWorkspaceDependencies.sh package.json
|
||||||
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production
|
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile
|
||||||
|
|
||||||
# copy the actual code
|
# copy the actual code
|
||||||
COPY packages/server/dist packages/server/dist
|
COPY packages/server/dist packages/server/dist
|
||||||
|
@ -35,7 +33,6 @@ COPY packages/server/client packages/server/client
|
||||||
COPY packages/server/builder packages/server/builder
|
COPY packages/server/builder packages/server/builder
|
||||||
COPY packages/worker/dist packages/worker/dist
|
COPY packages/worker/dist packages/worker/dist
|
||||||
COPY packages/worker/pm2.config.js packages/worker/pm2.config.js
|
COPY packages/worker/pm2.config.js packages/worker/pm2.config.js
|
||||||
COPY packages/string-templates packages/string-templates
|
|
||||||
|
|
||||||
|
|
||||||
FROM budibase/couchdb:v3.3.3 as runner
|
FROM budibase/couchdb:v3.3.3 as runner
|
||||||
|
@ -52,11 +49,11 @@ RUN apt-get update && \
|
||||||
|
|
||||||
# Install postgres client for pg_dump utils
|
# Install postgres client for pg_dump utils
|
||||||
RUN apt install -y software-properties-common apt-transport-https ca-certificates gnupg \
|
RUN apt install -y software-properties-common apt-transport-https ca-certificates gnupg \
|
||||||
&& curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \
|
&& curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \
|
||||||
&& echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \
|
&& echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \
|
||||||
&& apt update -y \
|
&& apt update -y \
|
||||||
&& apt install postgresql-client-15 -y \
|
&& apt install postgresql-client-15 -y \
|
||||||
&& apt remove software-properties-common apt-transport-https gpg -y
|
&& apt remove software-properties-common apt-transport-https gpg -y
|
||||||
|
|
||||||
# We use pm2 in order to run multiple node processes in a single container
|
# We use pm2 in order to run multiple node processes in a single container
|
||||||
RUN npm install --global pm2
|
RUN npm install --global pm2
|
||||||
|
@ -100,9 +97,6 @@ COPY --from=build /app/node_modules /node_modules
|
||||||
COPY --from=build /app/package.json /package.json
|
COPY --from=build /app/package.json /package.json
|
||||||
COPY --from=build /app/packages/server /app
|
COPY --from=build /app/packages/server /app
|
||||||
COPY --from=build /app/packages/worker /worker
|
COPY --from=build /app/packages/worker /worker
|
||||||
COPY --from=build /app/packages/string-templates /string-templates
|
|
||||||
|
|
||||||
RUN cd /string-templates && yarn link && cd ../app && yarn link @budibase/string-templates && cd ../worker && yarn link @budibase/string-templates
|
|
||||||
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.21.3",
|
"version": "2.22.12",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
"esbuild-node-externals": "^1.8.0",
|
"esbuild-node-externals": "^1.8.0",
|
||||||
"eslint": "^8.52.0",
|
"eslint": "^8.52.0",
|
||||||
"eslint-plugin-import": "^2.29.0",
|
"eslint-plugin-import": "^2.29.0",
|
||||||
|
"eslint-plugin-jest": "^27.9.0",
|
||||||
"eslint-plugin-local-rules": "^2.0.0",
|
"eslint-plugin-local-rules": "^2.0.0",
|
||||||
"eslint-plugin-svelte": "^2.34.0",
|
"eslint-plugin-svelte": "^2.34.0",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
|
@ -25,6 +26,7 @@
|
||||||
"svelte": "^4.2.10",
|
"svelte": "^4.2.10",
|
||||||
"svelte-eslint-parser": "^0.33.1",
|
"svelte-eslint-parser": "^0.33.1",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"typescript-eslint": "^7.3.1",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -67,7 +67,7 @@
|
||||||
"@types/lodash": "4.14.200",
|
"@types/lodash": "4.14.200",
|
||||||
"@types/node-fetch": "2.6.4",
|
"@types/node-fetch": "2.6.4",
|
||||||
"@types/pouchdb": "6.4.0",
|
"@types/pouchdb": "6.4.0",
|
||||||
"@types/redlock": "4.0.3",
|
"@types/redlock": "4.0.7",
|
||||||
"@types/semver": "7.3.7",
|
"@types/semver": "7.3.7",
|
||||||
"@types/tar-fs": "2.0.1",
|
"@types/tar-fs": "2.0.1",
|
||||||
"@types/uuid": "8.3.4",
|
"@types/uuid": "8.3.4",
|
||||||
|
@ -78,6 +78,7 @@
|
||||||
"jest-serial-runner": "1.2.1",
|
"jest-serial-runner": "1.2.1",
|
||||||
"pino-pretty": "10.0.0",
|
"pino-pretty": "10.0.0",
|
||||||
"pouchdb-adapter-memory": "7.2.2",
|
"pouchdb-adapter-memory": "7.2.2",
|
||||||
|
"testcontainers": "^10.7.2",
|
||||||
"timekeeper": "2.2.0",
|
"timekeeper": "2.2.0",
|
||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2"
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,10 +4,10 @@ set -e
|
||||||
if [[ -n $CI ]]
|
if [[ -n $CI ]]
|
||||||
then
|
then
|
||||||
# --runInBand performs better in ci where resources are limited
|
# --runInBand performs better in ci where resources are limited
|
||||||
echo "jest --coverage --runInBand --forceExit"
|
echo "jest --coverage --runInBand --forceExit $@"
|
||||||
jest --coverage --runInBand --forceExit
|
jest --coverage --runInBand --forceExit $@
|
||||||
else
|
else
|
||||||
# --maxWorkers performs better in development
|
# --maxWorkers performs better in development
|
||||||
echo "jest --coverage --detectOpenHandles"
|
echo "jest --coverage --forceExit --detectOpenHandles $@"
|
||||||
jest --coverage --detectOpenHandles
|
jest --coverage --forceExit --detectOpenHandles $@
|
||||||
fi
|
fi
|
|
@ -133,7 +133,7 @@ export async function refreshOAuthToken(
|
||||||
configId?: string
|
configId?: string
|
||||||
): Promise<RefreshResponse> {
|
): Promise<RefreshResponse> {
|
||||||
switch (providerType) {
|
switch (providerType) {
|
||||||
case SSOProviderType.OIDC:
|
case SSOProviderType.OIDC: {
|
||||||
if (!configId) {
|
if (!configId) {
|
||||||
return { err: { data: "OIDC config id not provided" } }
|
return { err: { data: "OIDC config id not provided" } }
|
||||||
}
|
}
|
||||||
|
@ -142,12 +142,14 @@ export async function refreshOAuthToken(
|
||||||
return { err: { data: "OIDC configuration not found" } }
|
return { err: { data: "OIDC configuration not found" } }
|
||||||
}
|
}
|
||||||
return refreshOIDCAccessToken(oidcConfig, refreshToken)
|
return refreshOIDCAccessToken(oidcConfig, refreshToken)
|
||||||
case SSOProviderType.GOOGLE:
|
}
|
||||||
|
case SSOProviderType.GOOGLE: {
|
||||||
let googleConfig = await configs.getGoogleConfig()
|
let googleConfig = await configs.getGoogleConfig()
|
||||||
if (!googleConfig) {
|
if (!googleConfig) {
|
||||||
return { err: { data: "Google configuration not found" } }
|
return { err: { data: "Google configuration not found" } }
|
||||||
}
|
}
|
||||||
return refreshGoogleAccessToken(googleConfig, refreshToken)
|
return refreshGoogleAccessToken(googleConfig, refreshToken)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ describe("platformLogout", () => {
|
||||||
await testEnv.withTenant(async () => {
|
await testEnv.withTenant(async () => {
|
||||||
const ctx = structures.koa.newContext()
|
const ctx = structures.koa.newContext()
|
||||||
await auth.platformLogout({ ctx, userId: "test" })
|
await auth.platformLogout({ ctx, userId: "test" })
|
||||||
expect(events.auth.logout).toBeCalledTimes(1)
|
expect(events.auth.logout).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -23,6 +23,18 @@ export default class BaseCache {
|
||||||
return client.keys(pattern)
|
return client.keys(pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exists(key: string, opts = { useTenancy: true }) {
|
||||||
|
key = opts.useTenancy ? generateTenantKey(key) : key
|
||||||
|
const client = await this.getClient()
|
||||||
|
return client.exists(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan(key: string, opts = { useTenancy: true }) {
|
||||||
|
key = opts.useTenancy ? generateTenantKey(key) : key
|
||||||
|
const client = await this.getClient()
|
||||||
|
return client.scan(key)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read only from the cache.
|
* Read only from the cache.
|
||||||
*/
|
*/
|
||||||
|
@ -32,6 +44,15 @@ export default class BaseCache {
|
||||||
return client.get(key)
|
return client.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read only from the cache.
|
||||||
|
*/
|
||||||
|
async bulkGet<T>(keys: string[], opts = { useTenancy: true }) {
|
||||||
|
keys = opts.useTenancy ? keys.map(key => generateTenantKey(key)) : keys
|
||||||
|
const client = await this.getClient()
|
||||||
|
return client.bulkGet<T>(keys)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write to the cache.
|
* Write to the cache.
|
||||||
*/
|
*/
|
||||||
|
@ -46,6 +67,25 @@ export default class BaseCache {
|
||||||
await client.store(key, value, ttl)
|
await client.store(key, value, ttl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk write to the cache.
|
||||||
|
*/
|
||||||
|
async bulkStore(
|
||||||
|
data: Record<string, any>,
|
||||||
|
ttl: number | null = null,
|
||||||
|
opts = { useTenancy: true }
|
||||||
|
) {
|
||||||
|
if (opts.useTenancy) {
|
||||||
|
data = Object.entries(data).reduce((acc, [key, value]) => {
|
||||||
|
acc[generateTenantKey(key)] = value
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, any>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await this.getClient()
|
||||||
|
await client.bulkStore(data, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove from cache.
|
* Remove from cache.
|
||||||
*/
|
*/
|
||||||
|
@ -55,15 +95,24 @@ export default class BaseCache {
|
||||||
return client.delete(key)
|
return client.delete(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove from cache.
|
||||||
|
*/
|
||||||
|
async bulkDelete(keys: string[], opts = { useTenancy: true }) {
|
||||||
|
keys = opts.useTenancy ? keys.map(key => generateTenantKey(key)) : keys
|
||||||
|
const client = await this.getClient()
|
||||||
|
return client.bulkDelete(keys)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read from the cache. Write to the cache if not exists.
|
* Read from the cache. Write to the cache if not exists.
|
||||||
*/
|
*/
|
||||||
async withCache(
|
async withCache<T>(
|
||||||
key: string,
|
key: string,
|
||||||
ttl: number,
|
ttl: number | null = null,
|
||||||
fetchFn: any,
|
fetchFn: () => Promise<T> | T,
|
||||||
opts = { useTenancy: true }
|
opts = { useTenancy: true }
|
||||||
) {
|
): Promise<T> {
|
||||||
const cachedValue = await this.get(key, opts)
|
const cachedValue = await this.get(key, opts)
|
||||||
if (cachedValue) {
|
if (cachedValue) {
|
||||||
return cachedValue
|
return cachedValue
|
||||||
|
@ -80,7 +129,7 @@ export default class BaseCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async bustCache(key: string, opts = { client: null }) {
|
async bustCache(key: string) {
|
||||||
const client = await this.getClient()
|
const client = await this.getClient()
|
||||||
try {
|
try {
|
||||||
await client.delete(generateTenantKey(key))
|
await client.delete(generateTenantKey(key))
|
||||||
|
@ -89,4 +138,13 @@ export default class BaseCache {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the entry if the provided value matches the stored one.
|
||||||
|
*/
|
||||||
|
async deleteIfValue(key: string, value: any, opts = { useTenancy: true }) {
|
||||||
|
key = opts.useTenancy ? generateTenantKey(key) : key
|
||||||
|
const client = await this.getClient()
|
||||||
|
await client.deleteIfValue(key, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { AnyDocument, Database } from "@budibase/types"
|
||||||
|
|
||||||
|
import { JobQueue, Queue, createQueue } from "../queue"
|
||||||
|
import * as dbUtils from "../db"
|
||||||
|
|
||||||
|
interface ProcessDocMessage {
|
||||||
|
dbName: string
|
||||||
|
docId: string
|
||||||
|
data: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSIST_MAX_ATTEMPTS = 100
|
||||||
|
let processor: DocWritethroughProcessor | undefined
|
||||||
|
|
||||||
|
export class DocWritethroughProcessor {
|
||||||
|
private static _queue: Queue
|
||||||
|
|
||||||
|
public static get queue() {
|
||||||
|
if (!DocWritethroughProcessor._queue) {
|
||||||
|
DocWritethroughProcessor._queue = createQueue<ProcessDocMessage>(
|
||||||
|
JobQueue.DOC_WRITETHROUGH_QUEUE,
|
||||||
|
{
|
||||||
|
jobOptions: {
|
||||||
|
attempts: PERSIST_MAX_ATTEMPTS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DocWritethroughProcessor._queue
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
DocWritethroughProcessor.queue.process(async message => {
|
||||||
|
try {
|
||||||
|
await this.persistToDb(message.data)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.status === 409) {
|
||||||
|
// If we get a 409, it means that another job updated it meanwhile. We want to retry it to persist it again.
|
||||||
|
throw new Error(
|
||||||
|
`Conflict persisting message ${message.id}. Attempt ${message.attemptsMade}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistToDb({
|
||||||
|
dbName,
|
||||||
|
docId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
dbName: string
|
||||||
|
docId: string
|
||||||
|
data: Record<string, any>
|
||||||
|
}) {
|
||||||
|
const db = dbUtils.getDB(dbName)
|
||||||
|
let doc: AnyDocument | undefined
|
||||||
|
try {
|
||||||
|
doc = await db.get(docId)
|
||||||
|
} catch {
|
||||||
|
doc = { _id: docId }
|
||||||
|
}
|
||||||
|
|
||||||
|
doc = { ...doc, ...data }
|
||||||
|
await db.put(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocWritethrough {
|
||||||
|
private db: Database
|
||||||
|
private _docId: string
|
||||||
|
|
||||||
|
constructor(db: Database, docId: string) {
|
||||||
|
this.db = db
|
||||||
|
this._docId = docId
|
||||||
|
}
|
||||||
|
|
||||||
|
get docId() {
|
||||||
|
return this._docId
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch(data: Record<string, any>) {
|
||||||
|
await DocWritethroughProcessor.queue.add({
|
||||||
|
dbName: this.db.name,
|
||||||
|
docId: this.docId,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init(): DocWritethroughProcessor {
|
||||||
|
processor = new DocWritethroughProcessor().init()
|
||||||
|
return processor
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProcessor(): DocWritethroughProcessor {
|
||||||
|
if (!processor) {
|
||||||
|
return init()
|
||||||
|
}
|
||||||
|
return processor
|
||||||
|
}
|
|
@ -26,7 +26,8 @@ export const store = (...args: Parameters<typeof GENERIC.store>) =>
|
||||||
GENERIC.store(...args)
|
GENERIC.store(...args)
|
||||||
export const destroy = (...args: Parameters<typeof GENERIC.delete>) =>
|
export const destroy = (...args: Parameters<typeof GENERIC.delete>) =>
|
||||||
GENERIC.delete(...args)
|
GENERIC.delete(...args)
|
||||||
export const withCache = (...args: Parameters<typeof GENERIC.withCache>) =>
|
export const withCache = <T>(
|
||||||
GENERIC.withCache(...args)
|
...args: Parameters<typeof GENERIC.withCache<T>>
|
||||||
|
) => GENERIC.withCache(...args)
|
||||||
export const bustCache = (...args: Parameters<typeof GENERIC.bustCache>) =>
|
export const bustCache = (...args: Parameters<typeof GENERIC.bustCache>) =>
|
||||||
GENERIC.bustCache(...args)
|
GENERIC.bustCache(...args)
|
||||||
|
|
|
@ -5,3 +5,4 @@ export * as writethrough from "./writethrough"
|
||||||
export * as invite from "./invite"
|
export * as invite from "./invite"
|
||||||
export * as passwordReset from "./passwordReset"
|
export * as passwordReset from "./passwordReset"
|
||||||
export * from "./generic"
|
export * from "./generic"
|
||||||
|
export * as docWritethrough from "./docWritethrough"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import * as utils from "../utils"
|
import * as utils from "../utils"
|
||||||
import { Duration, DurationType } from "../utils"
|
import { Duration } from "../utils"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { getTenantId } from "../context"
|
import { getTenantId } from "../context"
|
||||||
import * as redis from "../redis/init"
|
import * as redis from "../redis/init"
|
||||||
|
|
|
@ -0,0 +1,294 @@
|
||||||
|
import tk from "timekeeper"
|
||||||
|
|
||||||
|
import _ from "lodash"
|
||||||
|
import { DBTestConfiguration, generator, structures } from "../../../tests"
|
||||||
|
import { getDB } from "../../db"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DocWritethrough,
|
||||||
|
DocWritethroughProcessor,
|
||||||
|
init,
|
||||||
|
} from "../docWritethrough"
|
||||||
|
|
||||||
|
import InMemoryQueue from "../../queue/inMemoryQueue"
|
||||||
|
|
||||||
|
const initialTime = Date.now()
|
||||||
|
|
||||||
|
async function waitForQueueCompletion() {
|
||||||
|
const queue: InMemoryQueue = DocWritethroughProcessor.queue as never
|
||||||
|
await queue.waitForCompletion()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("docWritethrough", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
init()
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = new DBTestConfiguration()
|
||||||
|
|
||||||
|
const db = getDB(structures.db.id())
|
||||||
|
let documentId: string
|
||||||
|
let docWritethrough: DocWritethrough
|
||||||
|
|
||||||
|
describe("patch", () => {
|
||||||
|
function generatePatchObject(fieldCount: number) {
|
||||||
|
const keys = generator.unique(() => generator.guid(), fieldCount)
|
||||||
|
return keys.reduce((acc, c) => {
|
||||||
|
acc[c] = generator.word()
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, any>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
documentId = structures.uuid()
|
||||||
|
docWritethrough = new DocWritethrough(db, documentId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("patching will not persist until the messages are persisted", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
await docWritethrough.patch(generatePatchObject(2))
|
||||||
|
await docWritethrough.patch(generatePatchObject(2))
|
||||||
|
|
||||||
|
expect(await db.exists(documentId)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("patching will persist when the messages are persisted", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const patch1 = generatePatchObject(2)
|
||||||
|
const patch2 = generatePatchObject(2)
|
||||||
|
await docWritethrough.patch(patch1)
|
||||||
|
await docWritethrough.patch(patch2)
|
||||||
|
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
|
||||||
|
// This will not be persisted
|
||||||
|
const patch3 = generatePatchObject(3)
|
||||||
|
await docWritethrough.patch(patch3)
|
||||||
|
|
||||||
|
expect(await db.get(documentId)).toEqual({
|
||||||
|
_id: documentId,
|
||||||
|
...patch1,
|
||||||
|
...patch2,
|
||||||
|
_rev: expect.stringMatching(/2-.+/),
|
||||||
|
createdAt: new Date(initialTime).toISOString(),
|
||||||
|
updatedAt: new Date(initialTime).toISOString(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("patching will persist keeping the previous data", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const patch1 = generatePatchObject(2)
|
||||||
|
const patch2 = generatePatchObject(2)
|
||||||
|
await docWritethrough.patch(patch1)
|
||||||
|
await docWritethrough.patch(patch2)
|
||||||
|
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
|
||||||
|
const patch3 = generatePatchObject(3)
|
||||||
|
await docWritethrough.patch(patch3)
|
||||||
|
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
_id: documentId,
|
||||||
|
...patch1,
|
||||||
|
...patch2,
|
||||||
|
...patch3,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("date audit fields are set correctly when persisting", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const patch1 = generatePatchObject(2)
|
||||||
|
const patch2 = generatePatchObject(2)
|
||||||
|
await docWritethrough.patch(patch1)
|
||||||
|
const date1 = new Date()
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
await docWritethrough.patch(patch2)
|
||||||
|
|
||||||
|
tk.travel(Date.now() + 100)
|
||||||
|
const date2 = new Date()
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
|
||||||
|
expect(date1).not.toEqual(date2)
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
createdAt: date1.toISOString(),
|
||||||
|
updatedAt: date2.toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("concurrent patches will override keys", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const patch1 = generatePatchObject(2)
|
||||||
|
await docWritethrough.patch(patch1)
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
const patch2 = generatePatchObject(1)
|
||||||
|
await docWritethrough.patch(patch2)
|
||||||
|
|
||||||
|
const keyToOverride = _.sample(Object.keys(patch1))!
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
[keyToOverride]: patch1[keyToOverride],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
|
||||||
|
const patch3 = {
|
||||||
|
...generatePatchObject(3),
|
||||||
|
[keyToOverride]: generator.word(),
|
||||||
|
}
|
||||||
|
await docWritethrough.patch(patch3)
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
...patch1,
|
||||||
|
...patch2,
|
||||||
|
...patch3,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("concurrent patches to different docWritethrough will not pollute each other", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const secondDocWritethrough = new DocWritethrough(
|
||||||
|
db,
|
||||||
|
structures.db.id()
|
||||||
|
)
|
||||||
|
|
||||||
|
const doc1Patch = generatePatchObject(2)
|
||||||
|
await docWritethrough.patch(doc1Patch)
|
||||||
|
const doc2Patch = generatePatchObject(1)
|
||||||
|
await secondDocWritethrough.patch(doc2Patch)
|
||||||
|
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
|
||||||
|
const doc1Patch2 = generatePatchObject(3)
|
||||||
|
await docWritethrough.patch(doc1Patch2)
|
||||||
|
const doc2Patch2 = generatePatchObject(3)
|
||||||
|
await secondDocWritethrough.patch(doc2Patch2)
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
|
||||||
|
expect(await db.get(docWritethrough.docId)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
...doc1Patch,
|
||||||
|
...doc1Patch2,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await db.get(secondDocWritethrough.docId)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
...doc2Patch,
|
||||||
|
...doc2Patch2,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cached values are persisted only once", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const initialPatch = generatePatchObject(5)
|
||||||
|
|
||||||
|
await docWritethrough.patch(initialPatch)
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining(initialPatch)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.remove(await db.get(documentId))
|
||||||
|
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
const extraPatch = generatePatchObject(5)
|
||||||
|
await docWritethrough.patch(extraPatch)
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining(extraPatch)
|
||||||
|
)
|
||||||
|
expect(await db.get(documentId)).not.toEqual(
|
||||||
|
expect.objectContaining(initialPatch)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("concurrent calls will not cause conflicts", async () => {
|
||||||
|
async function parallelPatch(count: number) {
|
||||||
|
const patches = Array.from({ length: count }).map(() =>
|
||||||
|
generatePatchObject(1)
|
||||||
|
)
|
||||||
|
await Promise.all(patches.map(p => docWritethrough.patch(p)))
|
||||||
|
|
||||||
|
return patches.reduce((acc, c) => {
|
||||||
|
acc = { ...acc, ...c }
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
const queueMessageSpy = jest.spyOn(DocWritethroughProcessor.queue, "add")
|
||||||
|
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
let patches = await parallelPatch(5)
|
||||||
|
expect(queueMessageSpy).toHaveBeenCalledTimes(5)
|
||||||
|
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining(patches)
|
||||||
|
)
|
||||||
|
|
||||||
|
patches = { ...patches, ...(await parallelPatch(40)) }
|
||||||
|
expect(queueMessageSpy).toHaveBeenCalledTimes(45)
|
||||||
|
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining(patches)
|
||||||
|
)
|
||||||
|
|
||||||
|
patches = { ...patches, ...(await parallelPatch(10)) }
|
||||||
|
expect(queueMessageSpy).toHaveBeenCalledTimes(55)
|
||||||
|
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining(patches)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// This is not yet supported
|
||||||
|
// eslint-disable-next-line jest/no-disabled-tests
|
||||||
|
it.skip("patches will execute in order", async () => {
|
||||||
|
let incrementalValue = 0
|
||||||
|
const keyToOverride = generator.word()
|
||||||
|
async function incrementalPatches(count: number) {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await docWritethrough.patch({ [keyToOverride]: incrementalValue++ })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
await incrementalPatches(5)
|
||||||
|
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining({ [keyToOverride]: 5 })
|
||||||
|
)
|
||||||
|
|
||||||
|
await incrementalPatches(40)
|
||||||
|
await waitForQueueCompletion()
|
||||||
|
expect(await db.get(documentId)).toEqual(
|
||||||
|
expect.objectContaining({ [keyToOverride]: 45 })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -55,8 +55,8 @@ describe("user cache", () => {
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(UserDB.bulkGet).toBeCalledTimes(1)
|
expect(UserDB.bulkGet).toHaveBeenCalledTimes(1)
|
||||||
expect(UserDB.bulkGet).toBeCalledWith(userIdsToRequest)
|
expect(UserDB.bulkGet).toHaveBeenCalledWith(userIdsToRequest)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("on a second all, all of them are retrieved from cache", async () => {
|
it("on a second all, all of them are retrieved from cache", async () => {
|
||||||
|
@ -82,7 +82,7 @@ describe("user cache", () => {
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(UserDB.bulkGet).toBeCalledTimes(1)
|
expect(UserDB.bulkGet).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("when some users are cached, only the missing ones are retrieved from db", async () => {
|
it("when some users are cached, only the missing ones are retrieved from db", async () => {
|
||||||
|
@ -110,8 +110,8 @@ describe("user cache", () => {
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(UserDB.bulkGet).toBeCalledTimes(1)
|
expect(UserDB.bulkGet).toHaveBeenCalledTimes(1)
|
||||||
expect(UserDB.bulkGet).toBeCalledWith([
|
expect(UserDB.bulkGet).toHaveBeenCalledWith([
|
||||||
userIdsToRequest[1],
|
userIdsToRequest[1],
|
||||||
userIdsToRequest[2],
|
userIdsToRequest[2],
|
||||||
userIdsToRequest[4],
|
userIdsToRequest[4],
|
||||||
|
|
|
@ -6,7 +6,7 @@ import env from "../environment"
|
||||||
import * as accounts from "../accounts"
|
import * as accounts from "../accounts"
|
||||||
import { UserDB } from "../users"
|
import { UserDB } from "../users"
|
||||||
import { sdk } from "@budibase/shared-core"
|
import { sdk } from "@budibase/shared-core"
|
||||||
import { User } from "@budibase/types"
|
import { User, UserMetadata } from "@budibase/types"
|
||||||
|
|
||||||
const EXPIRY_SECONDS = 3600
|
const EXPIRY_SECONDS = 3600
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ const EXPIRY_SECONDS = 3600
|
||||||
*/
|
*/
|
||||||
async function populateFromDB(userId: string, tenantId: string) {
|
async function populateFromDB(userId: string, tenantId: string) {
|
||||||
const db = tenancy.getTenantDB(tenantId)
|
const db = tenancy.getTenantDB(tenantId)
|
||||||
const user = await db.get<any>(userId)
|
const user = await db.get<UserMetadata>(userId)
|
||||||
user.budibaseAccess = true
|
user.budibaseAccess = true
|
||||||
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
||||||
const account = await accounts.getAccount(user.email)
|
const account = await accounts.getAccount(user.email)
|
||||||
|
|
|
@ -8,7 +8,7 @@ const DEFAULT_WRITE_RATE_MS = 10000
|
||||||
let CACHE: BaseCache | null = null
|
let CACHE: BaseCache | null = null
|
||||||
|
|
||||||
interface CacheItem<T extends Document> {
|
interface CacheItem<T extends Document> {
|
||||||
doc: any
|
doc: T
|
||||||
lastWrite: number
|
lastWrite: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -57,6 +57,9 @@ export const StaticDatabases = {
|
||||||
AUDIT_LOGS: {
|
AUDIT_LOGS: {
|
||||||
name: "audit-logs",
|
name: "audit-logs",
|
||||||
},
|
},
|
||||||
|
SCIM_LOGS: {
|
||||||
|
name: "scim-logs",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_PREFIX = prefixed(DocumentType.APP)
|
export const APP_PREFIX = prefixed(DocumentType.APP)
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
StaticDatabases,
|
StaticDatabases,
|
||||||
DEFAULT_TENANT_ID,
|
DEFAULT_TENANT_ID,
|
||||||
} from "../constants"
|
} from "../constants"
|
||||||
import { Database, IdentityContext } from "@budibase/types"
|
import { Database, IdentityContext, Snippet, App } from "@budibase/types"
|
||||||
import { ContextMap } from "./types"
|
import { ContextMap } from "./types"
|
||||||
|
|
||||||
let TEST_APP_ID: string | null = null
|
let TEST_APP_ID: string | null = null
|
||||||
|
@ -35,6 +35,17 @@ export function getAuditLogDBName(tenantId?: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getScimDBName(tenantId?: string) {
|
||||||
|
if (!tenantId) {
|
||||||
|
tenantId = getTenantId()
|
||||||
|
}
|
||||||
|
if (tenantId === DEFAULT_TENANT_ID) {
|
||||||
|
return StaticDatabases.SCIM_LOGS.name
|
||||||
|
} else {
|
||||||
|
return `${tenantId}${SEPARATOR}${StaticDatabases.SCIM_LOGS.name}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function baseGlobalDBName(tenantId: string | undefined | null) {
|
export function baseGlobalDBName(tenantId: string | undefined | null) {
|
||||||
if (!tenantId || tenantId === DEFAULT_TENANT_ID) {
|
if (!tenantId || tenantId === DEFAULT_TENANT_ID) {
|
||||||
return StaticDatabases.GLOBAL.name
|
return StaticDatabases.GLOBAL.name
|
||||||
|
@ -111,10 +122,10 @@ export async function doInAutomationContext<T>(params: {
|
||||||
automationId: string
|
automationId: string
|
||||||
task: () => T
|
task: () => T
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
const tenantId = getTenantIDFromAppID(params.appId)
|
await ensureSnippetContext()
|
||||||
return newContext(
|
return newContext(
|
||||||
{
|
{
|
||||||
tenantId,
|
tenantId: getTenantIDFromAppID(params.appId),
|
||||||
appId: params.appId,
|
appId: params.appId,
|
||||||
automationId: params.automationId,
|
automationId: params.automationId,
|
||||||
},
|
},
|
||||||
|
@ -270,6 +281,27 @@ export function doInScimContext(task: any) {
|
||||||
return newContext(updates, task)
|
return newContext(updates, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ensureSnippetContext() {
|
||||||
|
const ctx = getCurrentContext()
|
||||||
|
|
||||||
|
// If we've already added snippets to context, continue
|
||||||
|
if (!ctx || ctx.snippets) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise get snippets for this app and update context
|
||||||
|
let snippets: Snippet[] | undefined
|
||||||
|
const db = getAppDB()
|
||||||
|
if (db && !env.isTest()) {
|
||||||
|
const app = await db.get<App>(DocumentType.APP_METADATA)
|
||||||
|
snippets = app.snippets
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always set snippets to a non-null value so that we can tell we've attempted
|
||||||
|
// to load snippets
|
||||||
|
ctx.snippets = snippets || []
|
||||||
|
}
|
||||||
|
|
||||||
export function getEnvironmentVariables() {
|
export function getEnvironmentVariables() {
|
||||||
const context = Context.get()
|
const context = Context.get()
|
||||||
if (!context.environmentVariables) {
|
if (!context.environmentVariables) {
|
||||||
|
|
|
@ -246,7 +246,7 @@ describe("context", () => {
|
||||||
context.doInAppMigrationContext(db.generateAppID(), async () => {
|
context.doInAppMigrationContext(db.generateAppID(), async () => {
|
||||||
await otherContextCall()
|
await otherContextCall()
|
||||||
})
|
})
|
||||||
).rejects.toThrowError(
|
).rejects.toThrow(
|
||||||
"The context cannot be changed, a migration is currently running"
|
"The context cannot be changed, a migration is currently running"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { IdentityContext, VM } from "@budibase/types"
|
import { IdentityContext, Snippet, VM } from "@budibase/types"
|
||||||
import { ExecutionTimeTracker } from "../timers"
|
|
||||||
|
|
||||||
// keep this out of Budibase types, don't want to expose context info
|
// keep this out of Budibase types, don't want to expose context info
|
||||||
export type ContextMap = {
|
export type ContextMap = {
|
||||||
|
@ -10,6 +9,7 @@ export type ContextMap = {
|
||||||
isScim?: boolean
|
isScim?: boolean
|
||||||
automationId?: string
|
automationId?: string
|
||||||
isMigrating?: boolean
|
isMigrating?: boolean
|
||||||
jsExecutionTracker?: ExecutionTimeTracker
|
|
||||||
vm?: VM
|
vm?: VM
|
||||||
|
cleanup?: (() => void | Promise<void>)[]
|
||||||
|
snippets?: Snippet[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,66 +1,57 @@
|
||||||
|
import PouchDB from "pouchdb"
|
||||||
import { getPouchDB, closePouchDB } from "./couch"
|
import { getPouchDB, closePouchDB } from "./couch"
|
||||||
import { DocumentType } from "../constants"
|
import { DocumentType } from "../constants"
|
||||||
|
|
||||||
class Replication {
|
class Replication {
|
||||||
source: any
|
source: PouchDB.Database
|
||||||
target: any
|
target: PouchDB.Database
|
||||||
replication: any
|
|
||||||
|
|
||||||
/**
|
constructor({ source, target }: { source: string; target: string }) {
|
||||||
*
|
|
||||||
* @param source - the DB you want to replicate or rollback to
|
|
||||||
* @param target - the DB you want to replicate to, or rollback from
|
|
||||||
*/
|
|
||||||
constructor({ source, target }: any) {
|
|
||||||
this.source = getPouchDB(source)
|
this.source = getPouchDB(source)
|
||||||
this.target = getPouchDB(target)
|
this.target = getPouchDB(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
async close() {
|
||||||
return Promise.all([closePouchDB(this.source), closePouchDB(this.target)])
|
await Promise.all([closePouchDB(this.source), closePouchDB(this.target)])
|
||||||
}
|
}
|
||||||
|
|
||||||
promisify(operation: any, opts = {}) {
|
replicate(opts: PouchDB.Replication.ReplicateOptions = {}) {
|
||||||
return new Promise(resolve => {
|
return new Promise<PouchDB.Replication.ReplicationResult<{}>>(resolve => {
|
||||||
operation(this.target, opts)
|
this.source.replicate
|
||||||
.on("denied", function (err: any) {
|
.to(this.target, opts)
|
||||||
|
.on("denied", function (err) {
|
||||||
// a document failed to replicate (e.g. due to permissions)
|
// a document failed to replicate (e.g. due to permissions)
|
||||||
throw new Error(`Denied: Document failed to replicate ${err}`)
|
throw new Error(`Denied: Document failed to replicate ${err}`)
|
||||||
})
|
})
|
||||||
.on("complete", function (info: any) {
|
.on("complete", function (info) {
|
||||||
return resolve(info)
|
return resolve(info)
|
||||||
})
|
})
|
||||||
.on("error", function (err: any) {
|
.on("error", function (err) {
|
||||||
throw new Error(`Replication Error: ${err}`)
|
throw new Error(`Replication Error: ${err}`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
appReplicateOpts(
|
||||||
* Two way replication operation, intended to be promise based.
|
opts: PouchDB.Replication.ReplicateOptions = {}
|
||||||
* @param opts - PouchDB replication options
|
): PouchDB.Replication.ReplicateOptions {
|
||||||
*/
|
if (typeof opts.filter === "string") {
|
||||||
sync(opts = {}) {
|
return opts
|
||||||
this.replication = this.promisify(this.source.sync, opts)
|
}
|
||||||
return this.replication
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const filter = opts.filter
|
||||||
* One way replication operation, intended to be promise based.
|
delete opts.filter
|
||||||
* @param opts - PouchDB replication options
|
|
||||||
*/
|
|
||||||
replicate(opts = {}) {
|
|
||||||
this.replication = this.promisify(this.source.replicate.to, opts)
|
|
||||||
return this.replication
|
|
||||||
}
|
|
||||||
|
|
||||||
appReplicateOpts() {
|
|
||||||
return {
|
return {
|
||||||
filter: (doc: any) => {
|
...opts,
|
||||||
|
filter: (doc: any, params: any) => {
|
||||||
if (doc._id && doc._id.startsWith(DocumentType.AUTOMATION_LOG)) {
|
if (doc._id && doc._id.startsWith(DocumentType.AUTOMATION_LOG)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return doc._id !== DocumentType.APP_METADATA
|
if (doc._id === DocumentType.APP_METADATA) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return filter ? filter(doc, params) : true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -75,10 +66,6 @@ class Replication {
|
||||||
// take the opportunity to remove deleted tombstones
|
// take the opportunity to remove deleted tombstones
|
||||||
await this.replicate()
|
await this.replicate()
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
|
||||||
this.replication.cancel()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Replication
|
export default Replication
|
||||||
|
|
|
@ -70,7 +70,15 @@ export class DatabaseImpl implements Database {
|
||||||
DatabaseImpl.nano = buildNano(couchInfo)
|
DatabaseImpl.nano = buildNano(couchInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists() {
|
exists(docId?: string) {
|
||||||
|
if (docId === undefined) {
|
||||||
|
return this.dbExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.docExists(docId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dbExists() {
|
||||||
const response = await directCouchUrlCall({
|
const response = await directCouchUrlCall({
|
||||||
url: `${this.couchInfo.url}/${this.name}`,
|
url: `${this.couchInfo.url}/${this.name}`,
|
||||||
method: "HEAD",
|
method: "HEAD",
|
||||||
|
@ -79,6 +87,15 @@ export class DatabaseImpl implements Database {
|
||||||
return response.status === 200
|
return response.status === 200
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async docExists(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.performCall(db => () => db.head(id))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private nano() {
|
private nano() {
|
||||||
return this.instanceNano || DatabaseImpl.nano
|
return this.instanceNano || DatabaseImpl.nano
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,9 +24,12 @@ export class DDInstrumentedDatabase implements Database {
|
||||||
return this.db.name
|
return this.db.name
|
||||||
}
|
}
|
||||||
|
|
||||||
exists(): Promise<boolean> {
|
exists(docId?: string): Promise<boolean> {
|
||||||
return tracer.trace("db.exists", span => {
|
return tracer.trace("db.exists", span => {
|
||||||
span?.addTags({ db_name: this.name })
|
span?.addTags({ db_name: this.name, doc_id: docId })
|
||||||
|
if (docId) {
|
||||||
|
return this.db.exists(docId)
|
||||||
|
}
|
||||||
return this.db.exists()
|
return this.db.exists()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,10 +10,6 @@ interface SearchResponse<T> {
|
||||||
totalRows: number
|
totalRows: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginatedSearchResponse<T> extends SearchResponse<T> {
|
|
||||||
hasNextPage: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SearchParams<T> = {
|
export type SearchParams<T> = {
|
||||||
tableId?: string
|
tableId?: string
|
||||||
sort?: string
|
sort?: string
|
||||||
|
@ -247,7 +243,7 @@ export class QueryBuilder<T> {
|
||||||
}
|
}
|
||||||
// Escape characters
|
// Escape characters
|
||||||
if (!this.#noEscaping && escape && originalType === "string") {
|
if (!this.#noEscaping && escape && originalType === "string") {
|
||||||
value = `${value}`.replace(/[ \/#+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
|
value = `${value}`.replace(/[ /#+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap in quotes
|
// Wrap in quotes
|
||||||
|
|
|
@ -34,12 +34,12 @@ export async function createUserIndex() {
|
||||||
}
|
}
|
||||||
let idxKey = prev != null ? `${prev}.${key}` : key
|
let idxKey = prev != null ? `${prev}.${key}` : key
|
||||||
if (typeof input[key] === "string") {
|
if (typeof input[key] === "string") {
|
||||||
|
// @ts-expect-error index is available in a CouchDB map function
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
// @ts-ignore
|
|
||||||
index(idxKey, input[key].toLowerCase(), { facet: true })
|
index(idxKey, input[key].toLowerCase(), { facet: true })
|
||||||
} else if (typeof input[key] !== "object") {
|
} else if (typeof input[key] !== "object") {
|
||||||
|
// @ts-expect-error index is available in a CouchDB map function
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
// @ts-ignore
|
|
||||||
index(idxKey, input[key], { facet: true })
|
index(idxKey, input[key], { facet: true })
|
||||||
} else {
|
} else {
|
||||||
idx(input[key], idxKey)
|
idx(input[key], idxKey)
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
import _ from "lodash"
|
||||||
|
import { AnyDocument } from "@budibase/types"
|
||||||
|
import { generator } from "../../../tests"
|
||||||
|
import { DatabaseImpl } from "../couch"
|
||||||
|
import { newid } from "../../utils"
|
||||||
|
|
||||||
|
describe("DatabaseImpl", () => {
|
||||||
|
const database = new DatabaseImpl(generator.word())
|
||||||
|
const documents: AnyDocument[] = []
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const docsToCreate = Array.from({ length: 10 }).map(() => ({
|
||||||
|
_id: newid(),
|
||||||
|
}))
|
||||||
|
const createdDocs = await database.bulkDocs(docsToCreate)
|
||||||
|
|
||||||
|
documents.push(...createdDocs.map((x: any) => ({ _id: x.id, _rev: x.rev })))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("document exists", () => {
|
||||||
|
it("can check existing docs by id", async () => {
|
||||||
|
const existingDoc = _.sample(documents)
|
||||||
|
const result = await database.exists(existingDoc!._id!)
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can check non existing docs by id", async () => {
|
||||||
|
const result = await database.exists(newid())
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can check an existing doc by id multiple times", async () => {
|
||||||
|
const existingDoc = _.sample(documents)
|
||||||
|
const id = existingDoc!._id!
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
results.push(await database.exists(id))
|
||||||
|
results.push(await database.exists(id))
|
||||||
|
results.push(await database.exists(id))
|
||||||
|
|
||||||
|
expect(results).toEqual([true, true, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false after the doc is deleted", async () => {
|
||||||
|
const existingDoc = _.sample(documents)
|
||||||
|
const id = existingDoc!._id!
|
||||||
|
expect(await database.exists(id)).toBe(true)
|
||||||
|
|
||||||
|
await database.remove(existingDoc!)
|
||||||
|
expect(await database.exists(id)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -17,13 +17,8 @@ export function init(processors: ProcessorMap) {
|
||||||
// if not processing in this instance, kick it off
|
// if not processing in this instance, kick it off
|
||||||
if (!processingPromise) {
|
if (!processingPromise) {
|
||||||
processingPromise = asyncEventQueue.process(async job => {
|
processingPromise = asyncEventQueue.process(async job => {
|
||||||
const { event, identity, properties, timestamp } = job.data
|
const { event, identity, properties } = job.data
|
||||||
await documentProcessor.processEvent(
|
await documentProcessor.processEvent(event, identity, properties)
|
||||||
event,
|
|
||||||
identity,
|
|
||||||
properties,
|
|
||||||
timestamp
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -186,6 +186,7 @@ const environment = {
|
||||||
environment[key] = value
|
environment[key] = value
|
||||||
},
|
},
|
||||||
ROLLING_LOG_MAX_SIZE: process.env.ROLLING_LOG_MAX_SIZE || "10M",
|
ROLLING_LOG_MAX_SIZE: process.env.ROLLING_LOG_MAX_SIZE || "10M",
|
||||||
|
DISABLE_SCIM_CALLS: process.env.DISABLE_SCIM_CALLS,
|
||||||
}
|
}
|
||||||
|
|
||||||
// clean up any environment variable edge cases
|
// clean up any environment variable edge cases
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import {
|
import {
|
||||||
Event,
|
Event,
|
||||||
Identity,
|
Identity,
|
||||||
Group,
|
|
||||||
IdentityType,
|
IdentityType,
|
||||||
AuditLogQueueEvent,
|
AuditLogQueueEvent,
|
||||||
AuditLogFn,
|
AuditLogFn,
|
||||||
|
@ -79,11 +78,11 @@ export default class AuditLogsProcessor implements EventProcessor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async identify(identity: Identity, timestamp?: string | number) {
|
async identify() {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
async identifyGroup(group: Group, timestamp?: string | number) {
|
async identifyGroup() {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,7 @@ export default class LoggingProcessor implements EventProcessor {
|
||||||
async processEvent(
|
async processEvent(
|
||||||
event: Event,
|
event: Event,
|
||||||
identity: Identity,
|
identity: Identity,
|
||||||
properties: any,
|
properties: any
|
||||||
timestamp?: string
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (skipLogging) {
|
if (skipLogging) {
|
||||||
return
|
return
|
||||||
|
@ -17,14 +16,14 @@ export default class LoggingProcessor implements EventProcessor {
|
||||||
console.log(`[audit] [identityType=${identity.type}] ${event}`, properties)
|
console.log(`[audit] [identityType=${identity.type}] ${event}`, properties)
|
||||||
}
|
}
|
||||||
|
|
||||||
async identify(identity: Identity, timestamp?: string | number) {
|
async identify(identity: Identity) {
|
||||||
if (skipLogging) {
|
if (skipLogging) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log(`[audit] identified`, identity)
|
console.log(`[audit] identified`, identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
async identifyGroup(group: Group, timestamp?: string | number) {
|
async identifyGroup(group: Group) {
|
||||||
if (skipLogging) {
|
if (skipLogging) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,12 +14,7 @@ export default class DocumentUpdateProcessor implements EventProcessor {
|
||||||
this.processors = processors
|
this.processors = processors
|
||||||
}
|
}
|
||||||
|
|
||||||
async processEvent(
|
async processEvent(event: Event, identity: Identity, properties: any) {
|
||||||
event: Event,
|
|
||||||
identity: Identity,
|
|
||||||
properties: any,
|
|
||||||
timestamp?: string | number
|
|
||||||
) {
|
|
||||||
const tenantId = identity.realTenantId
|
const tenantId = identity.realTenantId
|
||||||
const docId = getDocumentId(event, properties)
|
const docId = getDocumentId(event, properties)
|
||||||
if (!tenantId || !docId) {
|
if (!tenantId || !docId) {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
AppVersionRevertedEvent,
|
AppVersionRevertedEvent,
|
||||||
AppRevertedEvent,
|
AppRevertedEvent,
|
||||||
AppExportedEvent,
|
AppExportedEvent,
|
||||||
|
AppDuplicatedEvent,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
|
||||||
const created = async (app: App, timestamp?: string | number) => {
|
const created = async (app: App, timestamp?: string | number) => {
|
||||||
|
@ -77,6 +78,17 @@ async function fileImported(app: App) {
|
||||||
await publishEvent(Event.APP_FILE_IMPORTED, properties)
|
await publishEvent(Event.APP_FILE_IMPORTED, properties)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function duplicated(app: App, duplicateAppId: string) {
|
||||||
|
const properties: AppDuplicatedEvent = {
|
||||||
|
duplicateAppId,
|
||||||
|
appId: app.appId,
|
||||||
|
audited: {
|
||||||
|
name: app.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await publishEvent(Event.APP_DUPLICATED, properties)
|
||||||
|
}
|
||||||
|
|
||||||
async function templateImported(app: App, templateKey: string) {
|
async function templateImported(app: App, templateKey: string) {
|
||||||
const properties: AppTemplateImportedEvent = {
|
const properties: AppTemplateImportedEvent = {
|
||||||
appId: app.appId,
|
appId: app.appId,
|
||||||
|
@ -147,6 +159,7 @@ export default {
|
||||||
published,
|
published,
|
||||||
unpublished,
|
unpublished,
|
||||||
fileImported,
|
fileImported,
|
||||||
|
duplicated,
|
||||||
templateImported,
|
templateImported,
|
||||||
versionUpdated,
|
versionUpdated,
|
||||||
versionReverted,
|
versionReverted,
|
||||||
|
|
|
@ -10,6 +10,18 @@ import { formats } from "dd-trace/ext"
|
||||||
|
|
||||||
import { localFileDestination } from "../system"
|
import { localFileDestination } from "../system"
|
||||||
|
|
||||||
|
function isPlainObject(obj: any) {
|
||||||
|
return typeof obj === "object" && obj !== null && !(obj instanceof Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isError(obj: any) {
|
||||||
|
return obj instanceof Error
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessage(obj: any) {
|
||||||
|
return typeof obj === "string"
|
||||||
|
}
|
||||||
|
|
||||||
// LOGGER
|
// LOGGER
|
||||||
|
|
||||||
let pinoInstance: pino.Logger | undefined
|
let pinoInstance: pino.Logger | undefined
|
||||||
|
@ -71,23 +83,11 @@ if (!env.DISABLE_PINO_LOGGER) {
|
||||||
err?: Error
|
err?: Error
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPlainObject(obj: any) {
|
|
||||||
return typeof obj === "object" && obj !== null && !(obj instanceof Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isError(obj: any) {
|
|
||||||
return obj instanceof Error
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMessage(obj: any) {
|
|
||||||
return typeof obj === "string"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backwards compatibility between console logging statements
|
* Backwards compatibility between console logging statements
|
||||||
* and pino logging requirements.
|
* and pino logging requirements.
|
||||||
*/
|
*/
|
||||||
function getLogParams(args: any[]): [MergingObject, string] {
|
const getLogParams = (args: any[]): [MergingObject, string] => {
|
||||||
let error = undefined
|
let error = undefined
|
||||||
let objects: any[] = []
|
let objects: any[] = []
|
||||||
let message = ""
|
let message = ""
|
||||||
|
|
|
@ -11,7 +11,6 @@ export const buildMatcherRegex = (
|
||||||
return patterns.map(pattern => {
|
return patterns.map(pattern => {
|
||||||
let route = pattern.route
|
let route = pattern.route
|
||||||
const method = pattern.method
|
const method = pattern.method
|
||||||
const strict = pattern.strict ? pattern.strict : false
|
|
||||||
|
|
||||||
// if there is a param in the route
|
// if there is a param in the route
|
||||||
// use a wildcard pattern
|
// use a wildcard pattern
|
||||||
|
@ -24,24 +23,17 @@ export const buildMatcherRegex = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { regex: new RegExp(route), method, strict, route }
|
return { regex: new RegExp(route), method, route }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const matches = (ctx: BBContext, options: RegexMatcher[]) => {
|
export const matches = (ctx: BBContext, options: RegexMatcher[]) => {
|
||||||
return options.find(({ regex, method, strict, route }) => {
|
return options.find(({ regex, method }) => {
|
||||||
let urlMatch
|
const urlMatch = regex.test(ctx.request.url)
|
||||||
if (strict) {
|
|
||||||
urlMatch = ctx.request.url === route
|
|
||||||
} else {
|
|
||||||
urlMatch = regex.test(ctx.request.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const methodMatch =
|
const methodMatch =
|
||||||
method === "ALL"
|
method === "ALL"
|
||||||
? true
|
? true
|
||||||
: ctx.request.method.toLowerCase() === method.toLowerCase()
|
: ctx.request.method.toLowerCase() === method.toLowerCase()
|
||||||
|
|
||||||
return urlMatch && methodMatch
|
return urlMatch && methodMatch
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Cookie } from "../../../constants"
|
||||||
import * as configs from "../../../configs"
|
import * as configs from "../../../configs"
|
||||||
import * as cache from "../../../cache"
|
import * as cache from "../../../cache"
|
||||||
import * as utils from "../../../utils"
|
import * as utils from "../../../utils"
|
||||||
import { UserCtx, SSOProfile, DatasourceAuthCookie } from "@budibase/types"
|
import { UserCtx, SSOProfile } from "@budibase/types"
|
||||||
import { ssoSaveUserNoOp } from "../sso/sso"
|
import { ssoSaveUserNoOp } from "../sso/sso"
|
||||||
|
|
||||||
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
||||||
|
|
|
@ -5,7 +5,6 @@ import * as context from "../../../context"
|
||||||
import fetch from "node-fetch"
|
import fetch from "node-fetch"
|
||||||
import {
|
import {
|
||||||
SaveSSOUserFunction,
|
SaveSSOUserFunction,
|
||||||
SaveUserOpts,
|
|
||||||
SSOAuthDetails,
|
SSOAuthDetails,
|
||||||
SSOUser,
|
SSOUser,
|
||||||
User,
|
User,
|
||||||
|
@ -14,10 +13,8 @@ import {
|
||||||
// no-op function for user save
|
// no-op function for user save
|
||||||
// - this allows datasource auth and access token refresh to work correctly
|
// - this allows datasource auth and access token refresh to work correctly
|
||||||
// - prefer no-op over an optional argument to ensure function is provided to login flows
|
// - prefer no-op over an optional argument to ensure function is provided to login flows
|
||||||
export const ssoSaveUserNoOp: SaveSSOUserFunction = (
|
export const ssoSaveUserNoOp: SaveSSOUserFunction = (user: SSOUser) =>
|
||||||
user: SSOUser,
|
Promise.resolve(user)
|
||||||
opts: SaveUserOpts
|
|
||||||
) => Promise.resolve(user)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common authentication logic for third parties. e.g. OAuth, OIDC.
|
* Common authentication logic for third parties. e.g. OAuth, OIDC.
|
||||||
|
|
|
@ -114,11 +114,11 @@ describe("sso", () => {
|
||||||
// tenant id added
|
// tenant id added
|
||||||
ssoUser.tenantId = context.getTenantId()
|
ssoUser.tenantId = context.getTenantId()
|
||||||
|
|
||||||
expect(mockSaveUser).toBeCalledWith(ssoUser, {
|
expect(mockSaveUser).toHaveBeenCalledWith(ssoUser, {
|
||||||
hashPassword: false,
|
hashPassword: false,
|
||||||
requirePassword: false,
|
requirePassword: false,
|
||||||
})
|
})
|
||||||
expect(mockDone).toBeCalledWith(null, ssoUser)
|
expect(mockDone).toHaveBeenCalledWith(null, ssoUser)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -159,11 +159,11 @@ describe("sso", () => {
|
||||||
// existing id preserved
|
// existing id preserved
|
||||||
ssoUser._id = existingUser._id
|
ssoUser._id = existingUser._id
|
||||||
|
|
||||||
expect(mockSaveUser).toBeCalledWith(ssoUser, {
|
expect(mockSaveUser).toHaveBeenCalledWith(ssoUser, {
|
||||||
hashPassword: false,
|
hashPassword: false,
|
||||||
requirePassword: false,
|
requirePassword: false,
|
||||||
})
|
})
|
||||||
expect(mockDone).toBeCalledWith(null, ssoUser)
|
expect(mockDone).toHaveBeenCalledWith(null, ssoUser)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -187,11 +187,11 @@ describe("sso", () => {
|
||||||
// existing id preserved
|
// existing id preserved
|
||||||
ssoUser._id = existingUser._id
|
ssoUser._id = existingUser._id
|
||||||
|
|
||||||
expect(mockSaveUser).toBeCalledWith(ssoUser, {
|
expect(mockSaveUser).toHaveBeenCalledWith(ssoUser, {
|
||||||
hashPassword: false,
|
hashPassword: false,
|
||||||
requirePassword: false,
|
requirePassword: false,
|
||||||
})
|
})
|
||||||
expect(mockDone).toBeCalledWith(null, ssoUser)
|
expect(mockDone).toHaveBeenCalledWith(null, ssoUser)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -24,13 +24,13 @@ function buildUserCtx(user: ContextUser) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function passed(throwFn: jest.Func, nextFn: jest.Func) {
|
function passed(throwFn: jest.Func, nextFn: jest.Func) {
|
||||||
expect(throwFn).not.toBeCalled()
|
expect(throwFn).not.toHaveBeenCalled()
|
||||||
expect(nextFn).toBeCalled()
|
expect(nextFn).toHaveBeenCalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
function threw(throwFn: jest.Func) {
|
function threw(throwFn: jest.Func) {
|
||||||
// cant check next, the throw function doesn't actually throw - so it still continues
|
// cant check next, the throw function doesn't actually throw - so it still continues
|
||||||
expect(throwFn).toBeCalled()
|
expect(throwFn).toHaveBeenCalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("adminOnly middleware", () => {
|
describe("adminOnly middleware", () => {
|
||||||
|
|
|
@ -34,23 +34,6 @@ describe("matchers", () => {
|
||||||
expect(!!matchers.matches(ctx, built)).toBe(true)
|
expect(!!matchers.matches(ctx, built)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("doesn't wildcard path with strict", () => {
|
|
||||||
const pattern = [
|
|
||||||
{
|
|
||||||
route: "/api/tests",
|
|
||||||
method: "POST",
|
|
||||||
strict: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const ctx = structures.koa.newContext()
|
|
||||||
ctx.request.url = "/api/tests/id/something/else"
|
|
||||||
ctx.request.method = "POST"
|
|
||||||
|
|
||||||
const built = matchers.buildMatcherRegex(pattern)
|
|
||||||
|
|
||||||
expect(!!matchers.matches(ctx, built)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("matches with param", () => {
|
it("matches with param", () => {
|
||||||
const pattern = [
|
const pattern = [
|
||||||
{
|
{
|
||||||
|
@ -67,23 +50,6 @@ describe("matchers", () => {
|
||||||
expect(!!matchers.matches(ctx, built)).toBe(true)
|
expect(!!matchers.matches(ctx, built)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO: Support the below behaviour
|
|
||||||
// Strict does not work when a param is present
|
|
||||||
// it("matches with param with strict", () => {
|
|
||||||
// const pattern = [{
|
|
||||||
// route: "/api/tests/:testId",
|
|
||||||
// method: "GET",
|
|
||||||
// strict: true
|
|
||||||
// }]
|
|
||||||
// const ctx = structures.koa.newContext()
|
|
||||||
// ctx.request.url = "/api/tests/id"
|
|
||||||
// ctx.request.method = "GET"
|
|
||||||
//
|
|
||||||
// const built = matchers.buildMatcherRegex(pattern)
|
|
||||||
//
|
|
||||||
// expect(!!matchers.matches(ctx, built)).toBe(true)
|
|
||||||
// })
|
|
||||||
|
|
||||||
it("doesn't match by path", () => {
|
it("doesn't match by path", () => {
|
||||||
const pattern = [
|
const pattern = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -45,10 +45,6 @@ export const runMigration = async (
|
||||||
options: MigrationOptions = {}
|
options: MigrationOptions = {}
|
||||||
) => {
|
) => {
|
||||||
const migrationType = migration.type
|
const migrationType = migration.type
|
||||||
let tenantId: string | undefined
|
|
||||||
if (migrationType !== MigrationType.INSTALLATION) {
|
|
||||||
tenantId = context.getTenantId()
|
|
||||||
}
|
|
||||||
const migrationName = migration.name
|
const migrationName = migration.name
|
||||||
const silent = migration.silent
|
const silent = migration.silent
|
||||||
|
|
||||||
|
|
|
@ -126,7 +126,7 @@ describe("app", () => {
|
||||||
|
|
||||||
it("gets url with embedded minio", async () => {
|
it("gets url with embedded minio", async () => {
|
||||||
testEnv.withMinio()
|
testEnv.withMinio()
|
||||||
await testEnv.withTenant(tenantId => {
|
await testEnv.withTenant(() => {
|
||||||
const url = getAppFileUrl()
|
const url = getAppFileUrl()
|
||||||
expect(url).toBe(
|
expect(url).toBe(
|
||||||
"/files/signed/prod-budi-app-assets/app_123/attachments/image.jpeg"
|
"/files/signed/prod-budi-app-assets/app_123/attachments/image.jpeg"
|
||||||
|
@ -136,7 +136,7 @@ describe("app", () => {
|
||||||
|
|
||||||
it("gets url with custom S3", async () => {
|
it("gets url with custom S3", async () => {
|
||||||
testEnv.withS3()
|
testEnv.withS3()
|
||||||
await testEnv.withTenant(tenantId => {
|
await testEnv.withTenant(() => {
|
||||||
const url = getAppFileUrl()
|
const url = getAppFileUrl()
|
||||||
expect(url).toBe(
|
expect(url).toBe(
|
||||||
"http://s3.example.com/prod-budi-app-assets/app_123/attachments/image.jpeg"
|
"http://s3.example.com/prod-budi-app-assets/app_123/attachments/image.jpeg"
|
||||||
|
@ -146,7 +146,7 @@ describe("app", () => {
|
||||||
|
|
||||||
it("gets url with cloudfront + s3", async () => {
|
it("gets url with cloudfront + s3", async () => {
|
||||||
testEnv.withCloudfront()
|
testEnv.withCloudfront()
|
||||||
await testEnv.withTenant(tenantId => {
|
await testEnv.withTenant(() => {
|
||||||
const url = getAppFileUrl()
|
const url = getAppFileUrl()
|
||||||
// omit rest of signed params
|
// omit rest of signed params
|
||||||
expect(
|
expect(
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { DBTestConfiguration } from "../../../tests/extra"
|
||||||
import * as tenants from "../tenants"
|
import * as tenants from "../tenants"
|
||||||
|
|
||||||
describe("tenants", () => {
|
describe("tenants", () => {
|
||||||
const config = new DBTestConfiguration()
|
new DBTestConfiguration()
|
||||||
|
|
||||||
describe("addTenant", () => {
|
describe("addTenant", () => {
|
||||||
it("concurrently adds multiple tenants safely", async () => {
|
it("concurrently adds multiple tenants safely", async () => {
|
||||||
|
|
|
@ -4,4 +4,5 @@ export enum JobQueue {
|
||||||
AUDIT_LOG = "auditLogQueue",
|
AUDIT_LOG = "auditLogQueue",
|
||||||
SYSTEM_EVENT_QUEUE = "systemEventQueue",
|
SYSTEM_EVENT_QUEUE = "systemEventQueue",
|
||||||
APP_MIGRATION = "appMigration",
|
APP_MIGRATION = "appMigration",
|
||||||
|
DOC_WRITETHROUGH_QUEUE = "docWritethroughQueue",
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,14 @@
|
||||||
import events from "events"
|
import events from "events"
|
||||||
import { timeout } from "../utils"
|
import { newid, timeout } from "../utils"
|
||||||
|
import { Queue, QueueOptions, JobOptions } from "./queue"
|
||||||
|
|
||||||
|
interface JobMessage {
|
||||||
|
id: string
|
||||||
|
timestamp: number
|
||||||
|
queue: string
|
||||||
|
data: any
|
||||||
|
opts?: JobOptions
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bull works with a Job wrapper around all messages that contains a lot more information about
|
* Bull works with a Job wrapper around all messages that contains a lot more information about
|
||||||
|
@ -10,12 +19,13 @@ import { timeout } from "../utils"
|
||||||
* @returns A new job which can now be put onto the queue, this is mostly an
|
* @returns A new job which can now be put onto the queue, this is mostly an
|
||||||
* internal structure so that an in memory queue can be easily swapped for a Bull queue.
|
* internal structure so that an in memory queue can be easily swapped for a Bull queue.
|
||||||
*/
|
*/
|
||||||
function newJob(queue: string, message: any) {
|
function newJob(queue: string, message: any, opts?: JobOptions): JobMessage {
|
||||||
return {
|
return {
|
||||||
|
id: newid(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
queue: queue,
|
queue: queue,
|
||||||
data: message,
|
data: message,
|
||||||
opts: {},
|
opts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,26 +34,29 @@ function newJob(queue: string, message: any) {
|
||||||
* It is relatively simple, using an event emitter internally to register when messages are available
|
* It is relatively simple, using an event emitter internally to register when messages are available
|
||||||
* to the consumers - in can support many inputs and many consumers.
|
* to the consumers - in can support many inputs and many consumers.
|
||||||
*/
|
*/
|
||||||
class InMemoryQueue {
|
class InMemoryQueue implements Partial<Queue> {
|
||||||
_name: string
|
_name: string
|
||||||
_opts?: any
|
_opts?: QueueOptions
|
||||||
_messages: any[]
|
_messages: JobMessage[]
|
||||||
_emitter: EventEmitter
|
_queuedJobIds: Set<string>
|
||||||
|
_emitter: NodeJS.EventEmitter
|
||||||
_runCount: number
|
_runCount: number
|
||||||
_addCount: number
|
_addCount: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The constructor the queue, exactly the same as that of Bulls.
|
* The constructor the queue, exactly the same as that of Bulls.
|
||||||
* @param name The name of the queue which is being configured.
|
* @param name The name of the queue which is being configured.
|
||||||
* @param opts This is not used by the in memory queue as there is no real use
|
* @param opts This is not used by the in memory queue as there is no real use
|
||||||
* case when in memory, but is the same API as Bull
|
* case when in memory, but is the same API as Bull
|
||||||
*/
|
*/
|
||||||
constructor(name: string, opts?: any) {
|
constructor(name: string, opts?: QueueOptions) {
|
||||||
this._name = name
|
this._name = name
|
||||||
this._opts = opts
|
this._opts = opts
|
||||||
this._messages = []
|
this._messages = []
|
||||||
this._emitter = new events.EventEmitter()
|
this._emitter = new events.EventEmitter()
|
||||||
this._runCount = 0
|
this._runCount = 0
|
||||||
this._addCount = 0
|
this._addCount = 0
|
||||||
|
this._queuedJobIds = new Set<string>()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -55,22 +68,42 @@ class InMemoryQueue {
|
||||||
* note this is incredibly limited compared to Bull as in reality the Job would contain
|
* note this is incredibly limited compared to Bull as in reality the Job would contain
|
||||||
* a lot more information about the queue and current status of Bull cluster.
|
* a lot more information about the queue and current status of Bull cluster.
|
||||||
*/
|
*/
|
||||||
process(func: any) {
|
async process(func: any) {
|
||||||
this._emitter.on("message", async () => {
|
this._emitter.on("message", async () => {
|
||||||
if (this._messages.length <= 0) {
|
if (this._messages.length <= 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let msg = this._messages.shift()
|
let msg = this._messages.shift()
|
||||||
|
|
||||||
let resp = func(msg)
|
let resp = func(msg)
|
||||||
|
|
||||||
|
async function retryFunc(fnc: any) {
|
||||||
|
try {
|
||||||
|
await fnc
|
||||||
|
} catch (e: any) {
|
||||||
|
await new Promise<void>(r => setTimeout(() => r(), 50))
|
||||||
|
|
||||||
|
await retryFunc(func(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (resp.then != null) {
|
if (resp.then != null) {
|
||||||
await resp
|
try {
|
||||||
|
await retryFunc(resp)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this._runCount++
|
this._runCount++
|
||||||
|
const jobId = msg?.opts?.jobId?.toString()
|
||||||
|
if (jobId && msg?.opts?.removeOnComplete) {
|
||||||
|
this._queuedJobIds.delete(jobId)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async isReady() {
|
async isReady() {
|
||||||
return true
|
return this as any
|
||||||
}
|
}
|
||||||
|
|
||||||
// simply puts a message to the queue and emits to the queue for processing
|
// simply puts a message to the queue and emits to the queue for processing
|
||||||
|
@ -83,27 +116,45 @@ class InMemoryQueue {
|
||||||
* @param repeat serves no purpose for the import queue.
|
* @param repeat serves no purpose for the import queue.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
add(msg: any, repeat: boolean) {
|
async add(data: any, opts?: JobOptions) {
|
||||||
if (typeof msg !== "object") {
|
const jobId = opts?.jobId?.toString()
|
||||||
|
if (jobId && this._queuedJobIds.has(jobId)) {
|
||||||
|
console.log(`Ignoring already queued job ${jobId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data !== "object") {
|
||||||
throw "Queue only supports carrying JSON."
|
throw "Queue only supports carrying JSON."
|
||||||
}
|
}
|
||||||
this._messages.push(newJob(this._name, msg))
|
if (jobId) {
|
||||||
this._addCount++
|
this._queuedJobIds.add(jobId)
|
||||||
this._emitter.emit("message")
|
}
|
||||||
|
|
||||||
|
const pushMessage = () => {
|
||||||
|
this._messages.push(newJob(this._name, data, opts))
|
||||||
|
this._addCount++
|
||||||
|
this._emitter.emit("message")
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = opts?.delay
|
||||||
|
if (delay) {
|
||||||
|
setTimeout(pushMessage, delay)
|
||||||
|
} else {
|
||||||
|
pushMessage()
|
||||||
|
}
|
||||||
|
return {} as any
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* replicating the close function from bull, which waits for jobs to finish.
|
* replicating the close function from bull, which waits for jobs to finish.
|
||||||
*/
|
*/
|
||||||
async close() {
|
async close() {}
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This removes a cron which has been implemented, this is part of Bull API.
|
* This removes a cron which has been implemented, this is part of Bull API.
|
||||||
* @param cronJobId The cron which is to be removed.
|
* @param cronJobId The cron which is to be removed.
|
||||||
*/
|
*/
|
||||||
removeRepeatableByKey(cronJobId: string) {
|
async removeRepeatableByKey(cronJobId: string) {
|
||||||
// TODO: implement for testing
|
// TODO: implement for testing
|
||||||
console.log(cronJobId)
|
console.log(cronJobId)
|
||||||
}
|
}
|
||||||
|
@ -111,12 +162,12 @@ class InMemoryQueue {
|
||||||
/**
|
/**
|
||||||
* Implemented for tests
|
* Implemented for tests
|
||||||
*/
|
*/
|
||||||
getRepeatableJobs() {
|
async getRepeatableJobs() {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
removeJobs(pattern: string) {
|
async removeJobs(pattern: string) {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,18 +179,22 @@ class InMemoryQueue {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJob() {
|
async getJob() {
|
||||||
return {}
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
on() {
|
on() {
|
||||||
// do nothing
|
// do nothing
|
||||||
return this
|
return this as any
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForCompletion() {
|
async waitForCompletion() {
|
||||||
do {
|
do {
|
||||||
await timeout(50)
|
await timeout(50)
|
||||||
} while (this._addCount < this._runCount)
|
} while (this.hasRunningJobs())
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRunningJobs() {
|
||||||
|
return this._addCount > this._runCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -88,6 +88,7 @@ enum QueueEventType {
|
||||||
AUDIT_LOG_EVENT = "audit-log-event",
|
AUDIT_LOG_EVENT = "audit-log-event",
|
||||||
SYSTEM_EVENT = "system-event",
|
SYSTEM_EVENT = "system-event",
|
||||||
APP_MIGRATION = "app-migration",
|
APP_MIGRATION = "app-migration",
|
||||||
|
DOC_WRITETHROUGH = "doc-writethrough",
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventTypeMap: { [key in JobQueue]: QueueEventType } = {
|
const EventTypeMap: { [key in JobQueue]: QueueEventType } = {
|
||||||
|
@ -96,6 +97,7 @@ const EventTypeMap: { [key in JobQueue]: QueueEventType } = {
|
||||||
[JobQueue.AUDIT_LOG]: QueueEventType.AUDIT_LOG_EVENT,
|
[JobQueue.AUDIT_LOG]: QueueEventType.AUDIT_LOG_EVENT,
|
||||||
[JobQueue.SYSTEM_EVENT_QUEUE]: QueueEventType.SYSTEM_EVENT,
|
[JobQueue.SYSTEM_EVENT_QUEUE]: QueueEventType.SYSTEM_EVENT,
|
||||||
[JobQueue.APP_MIGRATION]: QueueEventType.APP_MIGRATION,
|
[JobQueue.APP_MIGRATION]: QueueEventType.APP_MIGRATION,
|
||||||
|
[JobQueue.DOC_WRITETHROUGH_QUEUE]: QueueEventType.DOC_WRITETHROUGH,
|
||||||
}
|
}
|
||||||
|
|
||||||
function logging(queue: Queue, jobQueue: JobQueue) {
|
function logging(queue: Queue, jobQueue: JobQueue) {
|
||||||
|
@ -130,7 +132,7 @@ function logging(queue: Queue, jobQueue: JobQueue) {
|
||||||
// A Job is waiting to be processed as soon as a worker is idling.
|
// A Job is waiting to be processed as soon as a worker is idling.
|
||||||
console.info(...getLogParams(eventType, BullEvent.WAITING, { jobId }))
|
console.info(...getLogParams(eventType, BullEvent.WAITING, { jobId }))
|
||||||
})
|
})
|
||||||
.on(BullEvent.ACTIVE, async (job: Job, jobPromise: any) => {
|
.on(BullEvent.ACTIVE, async (job: Job) => {
|
||||||
// A job has started. You can use `jobPromise.cancel()`` to abort it.
|
// A job has started. You can use `jobPromise.cancel()`` to abort it.
|
||||||
await doInJobContext(job, () => {
|
await doInJobContext(job, () => {
|
||||||
console.info(...getLogParams(eventType, BullEvent.ACTIVE, { job }))
|
console.info(...getLogParams(eventType, BullEvent.ACTIVE, { job }))
|
||||||
|
|
|
@ -7,6 +7,8 @@ import { addListeners, StalledFn } from "./listeners"
|
||||||
import { Duration } from "../utils"
|
import { Duration } from "../utils"
|
||||||
import * as timers from "../timers"
|
import * as timers from "../timers"
|
||||||
|
|
||||||
|
export { QueueOptions, Queue, JobOptions } from "bull"
|
||||||
|
|
||||||
// the queue lock is held for 5 minutes
|
// the queue lock is held for 5 minutes
|
||||||
const QUEUE_LOCK_MS = Duration.fromMinutes(5).toMs()
|
const QUEUE_LOCK_MS = Duration.fromMinutes(5).toMs()
|
||||||
// queue lock is refreshed every 30 seconds
|
// queue lock is refreshed every 30 seconds
|
||||||
|
|
|
@ -9,7 +9,8 @@ let userClient: Client,
|
||||||
lockClient: Client,
|
lockClient: Client,
|
||||||
socketClient: Client,
|
socketClient: Client,
|
||||||
inviteClient: Client,
|
inviteClient: Client,
|
||||||
passwordResetClient: Client
|
passwordResetClient: Client,
|
||||||
|
docWritethroughClient: Client
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
userClient = await new Client(utils.Databases.USER_CACHE).init()
|
userClient = await new Client(utils.Databases.USER_CACHE).init()
|
||||||
|
@ -24,6 +25,9 @@ export async function init() {
|
||||||
utils.Databases.SOCKET_IO,
|
utils.Databases.SOCKET_IO,
|
||||||
utils.SelectableDatabase.SOCKET_IO
|
utils.SelectableDatabase.SOCKET_IO
|
||||||
).init()
|
).init()
|
||||||
|
docWritethroughClient = await new Client(
|
||||||
|
utils.Databases.DOC_WRITE_THROUGH
|
||||||
|
).init()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shutdown() {
|
export async function shutdown() {
|
||||||
|
@ -36,6 +40,7 @@ export async function shutdown() {
|
||||||
if (inviteClient) await inviteClient.finish()
|
if (inviteClient) await inviteClient.finish()
|
||||||
if (passwordResetClient) await passwordResetClient.finish()
|
if (passwordResetClient) await passwordResetClient.finish()
|
||||||
if (socketClient) await socketClient.finish()
|
if (socketClient) await socketClient.finish()
|
||||||
|
if (docWritethroughClient) await docWritethroughClient.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("exit", async () => {
|
process.on("exit", async () => {
|
||||||
|
@ -104,3 +109,10 @@ export async function getPasswordResetClient() {
|
||||||
}
|
}
|
||||||
return passwordResetClient
|
return passwordResetClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDocWritethroughClient() {
|
||||||
|
if (!writethroughClient) {
|
||||||
|
await init()
|
||||||
|
}
|
||||||
|
return writethroughClient
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import Redis from "ioredis"
|
import Redis, { Cluster } from "ioredis"
|
||||||
// mock-redis doesn't have any typing
|
// mock-redis doesn't have any typing
|
||||||
let MockRedis: any | undefined
|
let MockRedis: any | undefined
|
||||||
if (env.MOCK_REDIS) {
|
if (env.MOCK_REDIS) {
|
||||||
|
@ -28,7 +28,7 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
|
||||||
|
|
||||||
// for testing just generate the client once
|
// for testing just generate the client once
|
||||||
let CLOSED = false
|
let CLOSED = false
|
||||||
let CLIENTS: { [key: number]: any } = {}
|
const CLIENTS: Record<number, Redis> = {}
|
||||||
let CONNECTED = false
|
let CONNECTED = false
|
||||||
|
|
||||||
// mock redis always connected
|
// mock redis always connected
|
||||||
|
@ -36,7 +36,7 @@ if (env.MOCK_REDIS) {
|
||||||
CONNECTED = true
|
CONNECTED = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickClient(selectDb: number): any {
|
function pickClient(selectDb: number) {
|
||||||
return CLIENTS[selectDb]
|
return CLIENTS[selectDb]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -201,12 +201,15 @@ class RedisWrapper {
|
||||||
key = `${db}${SEPARATOR}${key}`
|
key = `${db}${SEPARATOR}${key}`
|
||||||
let stream
|
let stream
|
||||||
if (CLUSTERED) {
|
if (CLUSTERED) {
|
||||||
let node = this.getClient().nodes("master")
|
let node = (this.getClient() as never as Cluster).nodes("master")
|
||||||
stream = node[0].scanStream({ match: key + "*", count: 100 })
|
stream = node[0].scanStream({ match: key + "*", count: 100 })
|
||||||
} else {
|
} else {
|
||||||
stream = this.getClient().scanStream({ match: key + "*", count: 100 })
|
stream = (this.getClient() as Redis).scanStream({
|
||||||
|
match: key + "*",
|
||||||
|
count: 100,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return promisifyStream(stream, this.getClient())
|
return promisifyStream(stream, this.getClient() as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
async keys(pattern: string) {
|
async keys(pattern: string) {
|
||||||
|
@ -221,14 +224,16 @@ class RedisWrapper {
|
||||||
|
|
||||||
async get(key: string) {
|
async get(key: string) {
|
||||||
const db = this._db
|
const db = this._db
|
||||||
let response = await this.getClient().get(addDbPrefix(db, key))
|
const response = await this.getClient().get(addDbPrefix(db, key))
|
||||||
// overwrite the prefixed key
|
// overwrite the prefixed key
|
||||||
|
// @ts-ignore
|
||||||
if (response != null && response.key) {
|
if (response != null && response.key) {
|
||||||
|
// @ts-ignore
|
||||||
response.key = key
|
response.key = key
|
||||||
}
|
}
|
||||||
// if its not an object just return the response
|
// if its not an object just return the response
|
||||||
try {
|
try {
|
||||||
return JSON.parse(response)
|
return JSON.parse(response!)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
@ -274,13 +279,37 @@ class RedisWrapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkStore(
|
||||||
|
data: Record<string, any>,
|
||||||
|
expirySeconds: number | null = null
|
||||||
|
) {
|
||||||
|
const client = this.getClient()
|
||||||
|
|
||||||
|
const dataToStore = Object.entries(data).reduce((acc, [key, value]) => {
|
||||||
|
acc[addDbPrefix(this._db, key)] =
|
||||||
|
typeof value === "object" ? JSON.stringify(value) : value
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, any>)
|
||||||
|
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
pipeline.mset(dataToStore)
|
||||||
|
|
||||||
|
if (expirySeconds !== null) {
|
||||||
|
for (const key of Object.keys(dataToStore)) {
|
||||||
|
pipeline.expire(key, expirySeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline.exec()
|
||||||
|
}
|
||||||
|
|
||||||
async getTTL(key: string) {
|
async getTTL(key: string) {
|
||||||
const db = this._db
|
const db = this._db
|
||||||
const prefixedKey = addDbPrefix(db, key)
|
const prefixedKey = addDbPrefix(db, key)
|
||||||
return this.getClient().ttl(prefixedKey)
|
return this.getClient().ttl(prefixedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
async setExpiry(key: string, expirySeconds: number | null) {
|
async setExpiry(key: string, expirySeconds: number) {
|
||||||
const db = this._db
|
const db = this._db
|
||||||
const prefixedKey = addDbPrefix(db, key)
|
const prefixedKey = addDbPrefix(db, key)
|
||||||
await this.getClient().expire(prefixedKey, expirySeconds)
|
await this.getClient().expire(prefixedKey, expirySeconds)
|
||||||
|
@ -291,10 +320,35 @@ class RedisWrapper {
|
||||||
await this.getClient().del(addDbPrefix(db, key))
|
await this.getClient().del(addDbPrefix(db, key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkDelete(keys: string[]) {
|
||||||
|
const db = this._db
|
||||||
|
await this.getClient().del(keys.map(key => addDbPrefix(db, key)))
|
||||||
|
}
|
||||||
|
|
||||||
async clear() {
|
async clear() {
|
||||||
let items = await this.scan()
|
let items = await this.scan()
|
||||||
await Promise.all(items.map((obj: any) => this.delete(obj.key)))
|
await Promise.all(items.map((obj: any) => this.delete(obj.key)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async increment(key: string) {
|
||||||
|
const result = await this.getClient().incr(addDbPrefix(this._db, key))
|
||||||
|
if (isNaN(result)) {
|
||||||
|
throw new Error(`Redis ${key} does not contain a number`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIfValue(key: string, value: any) {
|
||||||
|
const client = this.getClient()
|
||||||
|
|
||||||
|
const luaScript = `
|
||||||
|
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||||
|
redis.call('DEL', KEYS[1])
|
||||||
|
end
|
||||||
|
`
|
||||||
|
|
||||||
|
await client.eval(luaScript, 1, addDbPrefix(this._db, key), value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RedisWrapper
|
export default RedisWrapper
|
||||||
|
|
|
@ -72,7 +72,7 @@ const OPTIONS: Record<keyof typeof LockType, Redlock.Options> = {
|
||||||
export async function newRedlock(opts: Redlock.Options = {}) {
|
export async function newRedlock(opts: Redlock.Options = {}) {
|
||||||
const options = { ...OPTIONS.DEFAULT, ...opts }
|
const options = { ...OPTIONS.DEFAULT, ...opts }
|
||||||
const redisWrapper = await getLockClient()
|
const redisWrapper = await getLockClient()
|
||||||
const client = redisWrapper.getClient()
|
const client = redisWrapper.getClient() as any
|
||||||
return new Redlock([client], options)
|
return new Redlock([client], options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { GenericContainer, StartedTestContainer } from "testcontainers"
|
||||||
|
import { generator, structures } from "../../../tests"
|
||||||
|
import RedisWrapper from "../redis"
|
||||||
|
import { env } from "../.."
|
||||||
|
|
||||||
|
jest.setTimeout(30000)
|
||||||
|
|
||||||
|
describe("redis", () => {
|
||||||
|
let redis: RedisWrapper
|
||||||
|
let container: StartedTestContainer
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const container = await new GenericContainer("redis")
|
||||||
|
.withExposedPorts(6379)
|
||||||
|
.start()
|
||||||
|
|
||||||
|
env._set(
|
||||||
|
"REDIS_URL",
|
||||||
|
`${container.getHost()}:${container.getMappedPort(6379)}`
|
||||||
|
)
|
||||||
|
env._set("MOCK_REDIS", 0)
|
||||||
|
env._set("REDIS_PASSWORD", 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => container?.stop())
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
redis = new RedisWrapper(structures.db.id())
|
||||||
|
await redis.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("store", () => {
|
||||||
|
it("a basic value can be persisted", async () => {
|
||||||
|
const key = structures.uuid()
|
||||||
|
const value = generator.word()
|
||||||
|
|
||||||
|
await redis.store(key, value)
|
||||||
|
|
||||||
|
expect(await redis.get(key)).toEqual(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("objects can be persisted", async () => {
|
||||||
|
const key = structures.uuid()
|
||||||
|
const value = { [generator.word()]: generator.word() }
|
||||||
|
|
||||||
|
await redis.store(key, value)
|
||||||
|
|
||||||
|
expect(await redis.get(key)).toEqual(value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("bulkStore", () => {
|
||||||
|
function createRandomObject(
|
||||||
|
keyLength: number,
|
||||||
|
valueGenerator: () => any = () => generator.word()
|
||||||
|
) {
|
||||||
|
return generator
|
||||||
|
.unique(() => generator.word(), keyLength)
|
||||||
|
.reduce((acc, key) => {
|
||||||
|
acc[key] = valueGenerator()
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, string>)
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a basic object can be persisted", async () => {
|
||||||
|
const data = createRandomObject(10)
|
||||||
|
|
||||||
|
await redis.bulkStore(data)
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
expect(await redis.get(key)).toEqual(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await redis.keys("*")).toHaveLength(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("a complex object can be persisted", async () => {
|
||||||
|
const data = {
|
||||||
|
...createRandomObject(10, () => createRandomObject(5)),
|
||||||
|
...createRandomObject(5),
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.bulkStore(data)
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
expect(await redis.get(key)).toEqual(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await redis.keys("*")).toHaveLength(15)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("no TTL is set by default", async () => {
|
||||||
|
const data = createRandomObject(10)
|
||||||
|
|
||||||
|
await redis.bulkStore(data)
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
expect(await redis.get(key)).toEqual(value)
|
||||||
|
expect(await redis.getTTL(key)).toEqual(-1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("a bulk store can be persisted with TTL", async () => {
|
||||||
|
const ttl = 500
|
||||||
|
const data = createRandomObject(8)
|
||||||
|
|
||||||
|
await redis.bulkStore(data, ttl)
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
expect(await redis.get(key)).toEqual(value)
|
||||||
|
expect(await redis.getTTL(key)).toEqual(ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await redis.keys("*")).toHaveLength(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("setting a TTL of -1 will not persist the key", async () => {
|
||||||
|
const ttl = -1
|
||||||
|
const data = createRandomObject(5)
|
||||||
|
|
||||||
|
await redis.bulkStore(data, ttl)
|
||||||
|
|
||||||
|
for (const key of Object.keys(data)) {
|
||||||
|
expect(await redis.get(key)).toBe(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await redis.keys("*")).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("increment", () => {
|
||||||
|
it("can increment on a new key", async () => {
|
||||||
|
const key = structures.uuid()
|
||||||
|
const result = await redis.increment(key)
|
||||||
|
expect(result).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can increment multiple times", async () => {
|
||||||
|
const key = structures.uuid()
|
||||||
|
const results = [
|
||||||
|
await redis.increment(key),
|
||||||
|
await redis.increment(key),
|
||||||
|
await redis.increment(key),
|
||||||
|
await redis.increment(key),
|
||||||
|
await redis.increment(key),
|
||||||
|
]
|
||||||
|
expect(results).toEqual([1, 2, 3, 4, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can increment multiple times in parallel", async () => {
|
||||||
|
const key = structures.uuid()
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 100 }).map(() => redis.increment(key))
|
||||||
|
)
|
||||||
|
expect(results).toHaveLength(100)
|
||||||
|
expect(results).toEqual(Array.from({ length: 100 }).map((_, i) => i + 1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can increment existing set keys", async () => {
|
||||||
|
const key = structures.uuid()
|
||||||
|
await redis.store(key, 70)
|
||||||
|
await redis.increment(key)
|
||||||
|
|
||||||
|
const result = await redis.increment(key)
|
||||||
|
expect(result).toBe(72)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
generator.word(),
|
||||||
|
generator.bool(),
|
||||||
|
{ [generator.word()]: generator.word() },
|
||||||
|
])("cannot increment if the store value is not a number", async value => {
|
||||||
|
const key = structures.uuid()
|
||||||
|
await redis.store(key, value)
|
||||||
|
|
||||||
|
await expect(redis.increment(key)).rejects.toThrow(
|
||||||
|
"ERR value is not an integer or out of range"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("deleteIfValue", () => {
|
||||||
|
it("can delete if the value matches", async () => {
|
||||||
|
const key = structures.uuid()
|
||||||
|
const value = generator.word()
|
||||||
|
await redis.store(key, value)
|
||||||
|
|
||||||
|
await redis.deleteIfValue(key, value)
|
||||||
|
|
||||||
|
expect(await redis.get(key)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("will not delete if the value does not matches", async () => {
|
||||||
|
const key = structures.uuid()
|
||||||
|
const value = generator.word()
|
||||||
|
await redis.store(key, value)
|
||||||
|
|
||||||
|
await redis.deleteIfValue(key, generator.word())
|
||||||
|
|
||||||
|
expect(await redis.get(key)).toEqual(value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -96,8 +96,8 @@ describe("redlockImpl", () => {
|
||||||
task: mockTask,
|
task: mockTask,
|
||||||
executionTimeMs: lockTtl * 2,
|
executionTimeMs: lockTtl * 2,
|
||||||
})
|
})
|
||||||
).rejects.toThrowError(
|
).rejects.toThrow(
|
||||||
`Unable to fully release the lock on resource \"lock:${config.tenantId}_persist_writethrough\".`
|
`Unable to fully release the lock on resource "lock:${config.tenantId}_persist_writethrough".`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -30,6 +30,7 @@ export enum Databases {
|
||||||
LOCKS = "locks",
|
LOCKS = "locks",
|
||||||
SOCKET_IO = "socket_io",
|
SOCKET_IO = "socket_io",
|
||||||
BPM_EVENTS = "bpmEvents",
|
BPM_EVENTS = "bpmEvents",
|
||||||
|
DOC_WRITE_THROUGH = "docWriteThrough",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -101,10 +101,7 @@ export function getBuiltinRole(roleId: string): Role | undefined {
|
||||||
/**
|
/**
|
||||||
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
|
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
|
||||||
*/
|
*/
|
||||||
export function builtinRoleToNumber(id?: string) {
|
export function builtinRoleToNumber(id: string) {
|
||||||
if (!id) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
const builtins = getBuiltinRoles()
|
const builtins = getBuiltinRoles()
|
||||||
const MAX = Object.values(builtins).length + 1
|
const MAX = Object.values(builtins).length + 1
|
||||||
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
|
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
|
||||||
|
|
|
@ -158,8 +158,8 @@ describe("getTenantIDFromCtx", () => {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
expect(getTenantIDFromCtx(ctx, mockOpts)).toBeUndefined()
|
expect(getTenantIDFromCtx(ctx, mockOpts)).toBeUndefined()
|
||||||
expect(ctx.throw).toBeCalledTimes(1)
|
expect(ctx.throw).toHaveBeenCalledTimes(1)
|
||||||
expect(ctx.throw).toBeCalledWith(403, "Tenant id not set")
|
expect(ctx.throw).toHaveBeenCalledWith(403, "Tenant id not set")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns undefined if allowNoTenant is true", () => {
|
it("returns undefined if allowNoTenant is true", () => {
|
||||||
|
|
|
@ -20,41 +20,3 @@ export function cleanup() {
|
||||||
}
|
}
|
||||||
intervals = []
|
intervals = []
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ExecutionTimeoutError extends Error {
|
|
||||||
public readonly name = "ExecutionTimeoutError"
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ExecutionTimeTracker {
|
|
||||||
static withLimit(limitMs: number) {
|
|
||||||
return new ExecutionTimeTracker(limitMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(readonly limitMs: number) {}
|
|
||||||
|
|
||||||
private totalTimeMs = 0
|
|
||||||
|
|
||||||
track<T>(f: () => T): T {
|
|
||||||
this.checkLimit()
|
|
||||||
const start = process.hrtime.bigint()
|
|
||||||
try {
|
|
||||||
return f()
|
|
||||||
} finally {
|
|
||||||
const end = process.hrtime.bigint()
|
|
||||||
this.totalTimeMs += Number(end - start) / 1e6
|
|
||||||
this.checkLimit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get elapsedMS() {
|
|
||||||
return this.totalTimeMs
|
|
||||||
}
|
|
||||||
|
|
||||||
checkLimit() {
|
|
||||||
if (this.totalTimeMs > this.limitMs) {
|
|
||||||
throw new ExecutionTimeoutError(
|
|
||||||
`Execution time limit of ${this.limitMs}ms exceeded: ${this.totalTimeMs}ms`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -45,7 +45,7 @@ describe("Users", () => {
|
||||||
...{ _id: groupId, roles: { app1: "ADMIN" } },
|
...{ _id: groupId, roles: { app1: "ADMIN" } },
|
||||||
}
|
}
|
||||||
const users: User[] = []
|
const users: User[] = []
|
||||||
for (const _ of Array.from({ length: usersInGroup })) {
|
for (let i = 0; i < usersInGroup; i++) {
|
||||||
const userId = `us_${generator.guid()}`
|
const userId = `us_${generator.guid()}`
|
||||||
const user: User = structures.users.user({
|
const user: User = structures.users.user({
|
||||||
_id: userId,
|
_id: userId,
|
||||||
|
|
|
@ -15,6 +15,7 @@ beforeAll(async () => {
|
||||||
|
|
||||||
jest.spyOn(events.app, "created")
|
jest.spyOn(events.app, "created")
|
||||||
jest.spyOn(events.app, "updated")
|
jest.spyOn(events.app, "updated")
|
||||||
|
jest.spyOn(events.app, "duplicated")
|
||||||
jest.spyOn(events.app, "deleted")
|
jest.spyOn(events.app, "deleted")
|
||||||
jest.spyOn(events.app, "published")
|
jest.spyOn(events.app, "published")
|
||||||
jest.spyOn(events.app, "unpublished")
|
jest.spyOn(events.app, "unpublished")
|
||||||
|
|
|
@ -18,7 +18,7 @@ export const account = (partial: Partial<Account> = {}): Account => {
|
||||||
return {
|
return {
|
||||||
accountId: uuid(),
|
accountId: uuid(),
|
||||||
tenantId: generator.word(),
|
tenantId: generator.word(),
|
||||||
email: generator.email(),
|
email: generator.email({ domain: "example.com" }),
|
||||||
tenantName: generator.word(),
|
tenantName: generator.word(),
|
||||||
hosting: Hosting.SELF,
|
hosting: Hosting.SELF,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|
|
@ -13,7 +13,7 @@ interface CreateUserRequestFields {
|
||||||
export function createUserRequest(userData?: Partial<CreateUserRequestFields>) {
|
export function createUserRequest(userData?: Partial<CreateUserRequestFields>) {
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
externalId: uuid(),
|
externalId: uuid(),
|
||||||
email: generator.email(),
|
email: `${uuid()}@example.com`,
|
||||||
firstName: generator.first(),
|
firstName: generator.first(),
|
||||||
lastName: generator.last(),
|
lastName: generator.last(),
|
||||||
username: generator.name(),
|
username: generator.name(),
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { generator } from "./generator"
|
||||||
|
|
||||||
export function userGroup(): UserGroup {
|
export function userGroup(): UserGroup {
|
||||||
return {
|
return {
|
||||||
name: generator.word(),
|
name: generator.guid(),
|
||||||
icon: generator.word(),
|
icon: generator.word(),
|
||||||
color: generator.word(),
|
color: generator.word(),
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
<div use:getAnchor on:click={openMenu}>
|
<div use:getAnchor on:click={openMenu}>
|
||||||
<slot name="control" />
|
<slot name="control" />
|
||||||
</div>
|
</div>
|
||||||
<Popover bind:this={dropdown} {anchor} {align} {portalTarget}>
|
<Popover bind:this={dropdown} {anchor} {align} {portalTarget} on:open on:close>
|
||||||
<Menu>
|
<Menu>
|
||||||
<slot />
|
<slot />
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
|
@ -32,19 +32,30 @@ const handleClick = event => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ignore clicks for drawers, unless the handler is registered from a drawer
|
||||||
|
const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null
|
||||||
|
const clickInDrawer = event.target.closest(".drawer-wrapper") != null
|
||||||
|
if (clickInDrawer && !sourceInDrawer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handler.allowedType && event.type !== handler.allowedType) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handler.callback?.(event)
|
handler.callback?.(event)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
document.documentElement.addEventListener("click", handleClick, true)
|
document.documentElement.addEventListener("click", handleClick, true)
|
||||||
document.documentElement.addEventListener("contextmenu", handleClick, true)
|
document.documentElement.addEventListener("mousedown", handleClick, true)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds or updates a click handler
|
* Adds or updates a click handler
|
||||||
*/
|
*/
|
||||||
const updateHandler = (id, element, anchor, callback) => {
|
const updateHandler = (id, element, anchor, callback, allowedType) => {
|
||||||
let existingHandler = clickHandlers.find(x => x.id === id)
|
let existingHandler = clickHandlers.find(x => x.id === id)
|
||||||
if (!existingHandler) {
|
if (!existingHandler) {
|
||||||
clickHandlers.push({ id, element, anchor, callback })
|
clickHandlers.push({ id, element, anchor, callback, allowedType })
|
||||||
} else {
|
} else {
|
||||||
existingHandler.callback = callback
|
existingHandler.callback = callback
|
||||||
}
|
}
|
||||||
|
@ -68,9 +79,11 @@ const removeHandler = id => {
|
||||||
export default (element, opts) => {
|
export default (element, opts) => {
|
||||||
const id = Math.random()
|
const id = Math.random()
|
||||||
const update = newOpts => {
|
const update = newOpts => {
|
||||||
const callback = newOpts?.callback || newOpts
|
const callback =
|
||||||
|
newOpts?.callback || (typeof newOpts === "function" ? newOpts : null)
|
||||||
const anchor = newOpts?.anchor || element
|
const anchor = newOpts?.anchor || element
|
||||||
updateHandler(id, element, anchor, callback)
|
const allowedType = newOpts?.allowedType || "click"
|
||||||
|
updateHandler(id, element, anchor, callback, allowedType)
|
||||||
}
|
}
|
||||||
update(opts)
|
update(opts)
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -15,6 +15,7 @@ export default function positionDropdown(element, opts) {
|
||||||
align,
|
align,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
|
minWidth,
|
||||||
useAnchorWidth,
|
useAnchorWidth,
|
||||||
offset = 5,
|
offset = 5,
|
||||||
customUpdate,
|
customUpdate,
|
||||||
|
@ -28,18 +29,26 @@ export default function positionDropdown(element, opts) {
|
||||||
const elementBounds = element.getBoundingClientRect()
|
const elementBounds = element.getBoundingClientRect()
|
||||||
let styles = {
|
let styles = {
|
||||||
maxHeight: null,
|
maxHeight: null,
|
||||||
minWidth: null,
|
minWidth,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
left: null,
|
left: null,
|
||||||
top: null,
|
top: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof customUpdate === "function") {
|
if (typeof customUpdate === "function") {
|
||||||
styles = customUpdate(anchorBounds, elementBounds, styles)
|
styles = customUpdate(anchorBounds, elementBounds, {
|
||||||
|
...styles,
|
||||||
|
offset: opts.offset,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// Determine vertical styles
|
// Determine vertical styles
|
||||||
if (align === "right-outside") {
|
if (align === "right-outside" || align === "left-outside") {
|
||||||
styles.top = anchorBounds.top
|
styles.top =
|
||||||
|
anchorBounds.top + anchorBounds.height / 2 - elementBounds.height / 2
|
||||||
|
styles.maxHeight = maxHeight
|
||||||
|
if (styles.top + elementBounds.height > window.innerHeight) {
|
||||||
|
styles.top = window.innerHeight - elementBounds.height
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
window.innerHeight - anchorBounds.bottom <
|
window.innerHeight - anchorBounds.bottom <
|
||||||
(maxHeight || 100)
|
(maxHeight || 100)
|
||||||
|
|
|
@ -1,28 +1,111 @@
|
||||||
|
<script context="module">
|
||||||
|
import { writable, get } from "svelte/store"
|
||||||
|
|
||||||
|
// Observe this class name if possible in order to know how to size the
|
||||||
|
// drawer. If this doesn't exist we'll use a fixed size.
|
||||||
|
const drawerContainer = "drawer-container"
|
||||||
|
|
||||||
|
// Context level stores to keep drawers in sync
|
||||||
|
const openDrawers = writable([])
|
||||||
|
const modal = writable(false)
|
||||||
|
const resizable = writable(true)
|
||||||
|
const drawerLeft = writable(null)
|
||||||
|
const drawerWidth = writable(null)
|
||||||
|
|
||||||
|
// Resize observer to keep track of size changes
|
||||||
|
let observer
|
||||||
|
|
||||||
|
// Starts observing the target node to watching to size changes.
|
||||||
|
// Invoked when the first drawer of a chain is rendered.
|
||||||
|
const observe = () => {
|
||||||
|
const target = document.getElementsByClassName(drawerContainer)[0]
|
||||||
|
if (observer || !target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
observer = new ResizeObserver(entries => {
|
||||||
|
if (!entries?.[0]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const bounds = entries[0].target.getBoundingClientRect()
|
||||||
|
drawerLeft.set(bounds.left)
|
||||||
|
drawerWidth.set(bounds.width)
|
||||||
|
})
|
||||||
|
observer.observe(target)
|
||||||
|
|
||||||
|
// Manually measure once to ensure that we have dimensions for the initial
|
||||||
|
// paint
|
||||||
|
const bounds = target.getBoundingClientRect()
|
||||||
|
drawerLeft.set(bounds.left)
|
||||||
|
drawerWidth.set(bounds.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stops observing the target node.
|
||||||
|
// Invoked when the last drawer of a chain is removed.
|
||||||
|
const unobserve = () => {
|
||||||
|
if (get(openDrawers).length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
observer?.disconnect()
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
observer = null
|
||||||
|
modal.set(false)
|
||||||
|
resizable.set(true)
|
||||||
|
drawerLeft.set(null)
|
||||||
|
drawerWidth.set(null)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Portal from "svelte-portal"
|
|
||||||
import Button from "../Button/Button.svelte"
|
import Button from "../Button/Button.svelte"
|
||||||
import Body from "../Typography/Body.svelte"
|
import Icon from "../Icon/Icon.svelte"
|
||||||
import Heading from "../Typography/Heading.svelte"
|
import ActionButton from "../ActionButton/ActionButton.svelte"
|
||||||
import { setContext, createEventDispatcher } from "svelte"
|
import Portal from "svelte-portal"
|
||||||
|
import { setContext, createEventDispatcher, onDestroy } from "svelte"
|
||||||
import { generate } from "shortid"
|
import { generate } from "shortid"
|
||||||
|
|
||||||
export let title
|
export let title
|
||||||
export let fillWidth
|
export let forceModal = false
|
||||||
export let left = "314px"
|
|
||||||
export let width = "calc(100% - 626px)"
|
|
||||||
export let headless = false
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
const spacing = 11
|
||||||
|
|
||||||
let visible = false
|
let visible = false
|
||||||
let drawerId = generate()
|
let drawerId = generate()
|
||||||
|
|
||||||
|
$: depth = $openDrawers.length - $openDrawers.indexOf(drawerId) - 1
|
||||||
|
$: style = getStyle(depth, $drawerLeft, $drawerWidth, $modal)
|
||||||
|
|
||||||
|
const getStyle = (depth, left, width, modal) => {
|
||||||
|
let style = `
|
||||||
|
--scale-factor: ${getScaleFactor(depth)};
|
||||||
|
--spacing: ${spacing}px;
|
||||||
|
`
|
||||||
|
// Most modal styles are handled by class names
|
||||||
|
if (modal || left == null || width == null) {
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawers observing another dom node need custom position styles
|
||||||
|
return `
|
||||||
|
${style}
|
||||||
|
left: ${left + spacing}px;
|
||||||
|
width: ${width - 2 * spacing}px;
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
export function show() {
|
export function show() {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (forceModal) {
|
||||||
|
modal.set(true)
|
||||||
|
resizable.set(false)
|
||||||
|
}
|
||||||
|
observe()
|
||||||
visible = true
|
visible = true
|
||||||
dispatch("drawerShow", drawerId)
|
dispatch("drawerShow", drawerId)
|
||||||
|
openDrawers.update(state => [...state, drawerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hide() {
|
export function hide() {
|
||||||
|
@ -31,12 +114,15 @@
|
||||||
}
|
}
|
||||||
visible = false
|
visible = false
|
||||||
dispatch("drawerHide", drawerId)
|
dispatch("drawerHide", drawerId)
|
||||||
|
openDrawers.update(state => state.filter(id => id !== drawerId))
|
||||||
|
unobserve()
|
||||||
}
|
}
|
||||||
|
|
||||||
setContext("drawer-actions", {
|
setContext("drawer", {
|
||||||
hide,
|
hide,
|
||||||
show,
|
show,
|
||||||
headless,
|
modal,
|
||||||
|
resizable,
|
||||||
})
|
})
|
||||||
|
|
||||||
const easeInOutQuad = x => {
|
const easeInOutQuad = x => {
|
||||||
|
@ -45,66 +131,142 @@
|
||||||
|
|
||||||
// Use a custom svelte transition here because the built-in slide
|
// Use a custom svelte transition here because the built-in slide
|
||||||
// transition has a horrible overshoot
|
// transition has a horrible overshoot
|
||||||
const slide = () => {
|
const drawerSlide = () => {
|
||||||
return {
|
return {
|
||||||
duration: 360,
|
duration: 260,
|
||||||
css: t => {
|
css: t => {
|
||||||
const translation = 100 - Math.round(easeInOutQuad(t) * 100)
|
const f = easeInOutQuad(t)
|
||||||
return `transform: translateY(${translation}%);`
|
const yOffset = (1 - f) * 200
|
||||||
|
return `
|
||||||
|
transform: translateY(calc(${yOffset}px - 800px * (1 - var(--scale-factor))));
|
||||||
|
opacity: ${f};
|
||||||
|
`
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom fade transition because the default svelte one doesn't work any more
|
||||||
|
// with svelte 4
|
||||||
|
const drawerFade = () => {
|
||||||
|
return {
|
||||||
|
duration: 260,
|
||||||
|
css: t => {
|
||||||
|
return `opacity: ${easeInOutQuad(t)};`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getScaleFactor = depth => {
|
||||||
|
// Quadratic function approaching a limit of 1 as depth tends to infinity
|
||||||
|
const lim = 1 - 1 / (depth * depth + 1)
|
||||||
|
// Scale drawers between 1 and 0.9 as depth approaches infinity
|
||||||
|
return 1 - lim * 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (visible) {
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if visible}
|
{#if visible}
|
||||||
<Portal>
|
<Portal target=".modal-container">
|
||||||
<section
|
<!-- This class is unstyled, but needed by click_outside -->
|
||||||
class:fillWidth
|
<div class="drawer-wrapper">
|
||||||
class="drawer"
|
<div
|
||||||
class:headless
|
class="underlay"
|
||||||
transition:slide|local
|
class:hidden={!$modal}
|
||||||
style={`width: ${width}; left: ${left};`}
|
transition:drawerFade|local
|
||||||
>
|
/>
|
||||||
{#if !headless}
|
<div
|
||||||
|
class="drawer"
|
||||||
|
class:stacked={depth > 0}
|
||||||
|
class:modal={$modal}
|
||||||
|
transition:drawerSlide|local
|
||||||
|
{style}
|
||||||
|
>
|
||||||
<header>
|
<header>
|
||||||
<div class="text">
|
{#if $$slots.title}
|
||||||
<Heading size="XS">{title}</Heading>
|
<slot name="title" />
|
||||||
<Body size="S">
|
{:else}
|
||||||
<slot name="description" />
|
<div class="text">{title || "Bindings"}</div>
|
||||||
</Body>
|
{/if}
|
||||||
</div>
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<Button secondary quiet on:click={hide}>Cancel</Button>
|
<Button secondary quiet on:click={hide}>Cancel</Button>
|
||||||
<slot name="buttons" />
|
<slot name="buttons" />
|
||||||
|
{#if $resizable}
|
||||||
|
<ActionButton
|
||||||
|
size="M"
|
||||||
|
quiet
|
||||||
|
selected={$modal}
|
||||||
|
on:click={() => modal.set(!$modal)}
|
||||||
|
>
|
||||||
|
<Icon name={$modal ? "Minimize" : "Maximize"} size="S" />
|
||||||
|
</ActionButton>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{/if}
|
<slot name="body" />
|
||||||
<slot name="body" />
|
<div class="overlay" class:hidden={$modal || depth === 0} />
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.drawer.headless :global(.drawer-contents) {
|
|
||||||
height: calc(40vh + 75px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
left: 25vw;
|
||||||
|
width: 50vw;
|
||||||
|
bottom: var(--spacing);
|
||||||
|
height: 420px;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
border-top: var(--border-light);
|
border: var(--border-light);
|
||||||
z-index: 3;
|
z-index: 100;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: transform 260ms ease-out, bottom 260ms ease-out,
|
||||||
|
left 260ms ease-out, width 260ms ease-out, height 260ms ease-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.drawer.modal {
|
||||||
|
left: 15vw;
|
||||||
|
width: 70vw;
|
||||||
|
bottom: 15vh;
|
||||||
|
height: 70vh;
|
||||||
|
}
|
||||||
|
.drawer.stacked {
|
||||||
|
transform: translateY(calc(-1 * 1024px * (1 - var(--scale-factor))))
|
||||||
|
scale(var(--scale-factor));
|
||||||
}
|
}
|
||||||
|
|
||||||
.fillWidth {
|
.overlay,
|
||||||
left: 260px !important;
|
.underlay {
|
||||||
width: calc(100% - 260px) !important;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 100;
|
||||||
|
display: block;
|
||||||
|
transition: opacity 260ms ease-out;
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--background);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.underlay {
|
||||||
|
position: fixed;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.underlay.hidden,
|
||||||
|
.overlay.hidden {
|
||||||
|
opacity: 0 !important;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
|
@ -112,10 +274,9 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: var(--border-light);
|
border-bottom: var(--border-light);
|
||||||
padding: var(--spacing-l) var(--spacing-xl);
|
padding: var(--spacing-m) var(--spacing-xl);
|
||||||
gap: var(--spacing-xl);
|
gap: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -123,7 +284,6 @@
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
@ -131,4 +291,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-m);
|
gap: var(--spacing-m);
|
||||||
}
|
}
|
||||||
|
.buttons :global(.icon) {
|
||||||
|
width: 16px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
<div class="drawer-contents">
|
<script>
|
||||||
|
export let padding = true
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="drawer-contents" class:padding>
|
||||||
<div class:no-sidebar={!$$slots.sidebar} class="container">
|
<div class:no-sidebar={!$$slots.sidebar} class="container">
|
||||||
{#if $$slots.sidebar}
|
{#if $$slots.sidebar}
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
|
@ -13,8 +17,8 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.drawer-contents {
|
.drawer-contents {
|
||||||
height: 40vh;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
.container {
|
.container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -27,14 +31,21 @@
|
||||||
.sidebar {
|
.sidebar {
|
||||||
border-right: var(--border-light);
|
border-right: var(--border-light);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: var(--spacing-xl);
|
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
.padding .sidebar {
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
.sidebar::-webkit-scrollbar {
|
.sidebar::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.main {
|
.main {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.padding .main {
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
|
height: calc(100% - var(--spacing-xl) * 2);
|
||||||
}
|
}
|
||||||
.main :global(textarea) {
|
.main :global(textarea) {
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
|
|
|
@ -197,7 +197,9 @@
|
||||||
>
|
>
|
||||||
<Icon name="ChevronRight" />
|
<Icon name="ChevronRight" />
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">File {selectedImageIdx + 1} of {fileCount}</div>
|
{#if maximum !== 1}
|
||||||
|
<div class="footer">File {selectedImageIdx + 1} of {fileCount}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if value?.length}
|
{:else if value?.length}
|
||||||
{#each value as file}
|
{#each value as file}
|
||||||
|
|
|
@ -1,58 +1,54 @@
|
||||||
<script context="module">
|
|
||||||
export const directions = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Tooltip from "../Tooltip/Tooltip.svelte"
|
import {
|
||||||
import { fade } from "svelte/transition"
|
default as AbsTooltip,
|
||||||
|
TooltipPosition,
|
||||||
|
TooltipType,
|
||||||
|
} from "../Tooltip/AbsTooltip.svelte"
|
||||||
|
|
||||||
export let direction = "n"
|
|
||||||
export let name = "Add"
|
export let name = "Add"
|
||||||
export let hidden = false
|
export let hidden = false
|
||||||
export let size = "M"
|
export let size = "M"
|
||||||
export let hoverable = false
|
export let hoverable = false
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let color
|
export let color
|
||||||
|
export let hoverColor
|
||||||
export let tooltip
|
export let tooltip
|
||||||
|
export let tooltipPosition = TooltipPosition.Bottom
|
||||||
$: rotation = getRotation(direction)
|
export let tooltipType = TooltipType.Default
|
||||||
|
export let tooltipColor
|
||||||
let showTooltip = false
|
export let tooltipWrap = true
|
||||||
|
export let newStyles = false
|
||||||
const getRotation = direction => {
|
|
||||||
return directions.indexOf(direction) * 45
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<AbsTooltip
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
text={tooltip}
|
||||||
<div
|
type={tooltipType}
|
||||||
class="icon"
|
position={tooltipPosition}
|
||||||
on:mouseover={() => (showTooltip = true)}
|
color={tooltipColor}
|
||||||
on:focus={() => (showTooltip = true)}
|
noWrap={tooltipWrap}
|
||||||
on:mouseleave={() => (showTooltip = false)}
|
|
||||||
on:click={() => (showTooltip = false)}
|
|
||||||
>
|
>
|
||||||
<svg
|
<div class="icon" class:newStyles>
|
||||||
on:click
|
<svg
|
||||||
class:hoverable
|
on:click
|
||||||
class:disabled
|
class:hoverable
|
||||||
class="spectrum-Icon spectrum-Icon--size{size}"
|
class:disabled
|
||||||
focusable="false"
|
class="spectrum-Icon spectrum-Icon--size{size}"
|
||||||
aria-hidden={hidden}
|
focusable="false"
|
||||||
aria-label={name}
|
aria-hidden={hidden}
|
||||||
style={`transform: rotate(${rotation}deg); ${
|
aria-label={name}
|
||||||
color ? `color: ${color};` : ""
|
style={`${color ? `color: ${color};` : ""} ${
|
||||||
}`}
|
hoverColor
|
||||||
>
|
? `--hover-color: ${hoverColor}`
|
||||||
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-{name}" />
|
: "--hover-color: var(--spectrum-alias-icon-color-selected-hover)"
|
||||||
</svg>
|
}`}
|
||||||
{#if tooltip && showTooltip}
|
>
|
||||||
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
|
<use
|
||||||
<Tooltip textWrapping direction="top" text={tooltip} />
|
style="pointer-events: none;"
|
||||||
</div>
|
xlink:href="#spectrum-icon-18-{name}"
|
||||||
{/if}
|
/>
|
||||||
</div>
|
</svg>
|
||||||
|
</div>
|
||||||
|
</AbsTooltip>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.icon {
|
.icon {
|
||||||
|
@ -60,19 +56,25 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
.newStyles {
|
||||||
|
color: var(--spectrum-global-color-gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
svg.hoverable {
|
svg.hoverable {
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
transition: color var(--spectrum-global-animation-duration-100, 130ms);
|
transition: color var(--spectrum-global-animation-duration-100, 130ms);
|
||||||
}
|
}
|
||||||
svg.hoverable:hover {
|
svg.hoverable:hover {
|
||||||
color: var(--spectrum-alias-icon-color-selected-hover) !important;
|
color: var(--hover-color) !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
svg.hoverable:active {
|
svg.hoverable:active {
|
||||||
color: var(--spectrum-global-color-blue-400) !important;
|
color: var(--spectrum-global-color-blue-400) !important;
|
||||||
}
|
}
|
||||||
|
.newStyles svg.hoverable:hover,
|
||||||
|
.newStyles svg.hoverable:active {
|
||||||
|
color: var(--spectrum-global-color-gray-900) !important;
|
||||||
|
}
|
||||||
svg.disabled {
|
svg.disabled {
|
||||||
color: var(--spectrum-global-color-gray-500) !important;
|
color: var(--spectrum-global-color-gray-500) !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
|
|
|
@ -1,22 +1,41 @@
|
||||||
<script>
|
<script>
|
||||||
import Icon from "./Icon.svelte"
|
import Icon from "./Icon.svelte"
|
||||||
|
|
||||||
|
import Tooltip from "../Tooltip/Tooltip.svelte"
|
||||||
|
import { fade } from "svelte/transition"
|
||||||
|
|
||||||
export let icon
|
export let icon
|
||||||
export let background
|
export let background
|
||||||
export let color
|
export let color
|
||||||
export let size = "M"
|
export let size = "M"
|
||||||
|
export let tooltip
|
||||||
|
|
||||||
|
let showTooltip = false
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<div
|
<div
|
||||||
class="icon size--{size}"
|
class="icon size--{size}"
|
||||||
style="background: {background || `transparent`};"
|
style="background: {background || `transparent`};"
|
||||||
class:filled={!!background}
|
class:filled={!!background}
|
||||||
|
on:mouseover={() => (showTooltip = true)}
|
||||||
|
on:mouseleave={() => (showTooltip = false)}
|
||||||
|
on:focus={() => (showTooltip = true)}
|
||||||
|
on:blur={() => (showTooltip = false)}
|
||||||
|
on:click={() => (showTooltip = false)}
|
||||||
>
|
>
|
||||||
<Icon name={icon} color={background ? "white" : color} />
|
<Icon name={icon} color={background ? "white" : color} />
|
||||||
|
{#if tooltip && showTooltip}
|
||||||
|
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
|
||||||
|
<Tooltip textWrapping direction="right" text={tooltip} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.icon {
|
.icon {
|
||||||
|
position: relative;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
flex: 0 0 28px;
|
flex: 0 0 28px;
|
||||||
|
@ -32,6 +51,15 @@
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
.icon.size--XS {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
}
|
||||||
|
.icon.size--XS :global(.spectrum-Icon) {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
.icon.size--S {
|
.icon.size--S {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
@ -58,4 +86,14 @@
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
left: calc(50% + 8px);
|
||||||
|
bottom: calc(-50% + 6px);
|
||||||
|
/* transform: translateY(-50%); */
|
||||||
|
text-align: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
export let inline = false
|
export let inline = false
|
||||||
export let disableCancel = false
|
export let disableCancel = false
|
||||||
export let autoFocus = true
|
export let autoFocus = true
|
||||||
|
export let zIndex = 999
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
let visible = fixed || inline
|
let visible = fixed || inline
|
||||||
|
@ -101,7 +102,11 @@
|
||||||
<Portal target=".modal-container">
|
<Portal target=".modal-container">
|
||||||
{#if visible}
|
{#if visible}
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div class="spectrum-Underlay is-open" on:mousedown|self={cancel}>
|
<div
|
||||||
|
class="spectrum-Underlay is-open"
|
||||||
|
on:mousedown|self={cancel}
|
||||||
|
style="z-index:{zIndex || 999}"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="background"
|
class="background"
|
||||||
in:fade={{ duration: 200 }}
|
in:fade={{ duration: 200 }}
|
||||||
|
@ -132,7 +137,6 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 999;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
export let anchor
|
export let anchor
|
||||||
export let align = "right"
|
export let align = "right"
|
||||||
export let portalTarget
|
export let portalTarget
|
||||||
|
export let minWidth
|
||||||
export let maxWidth
|
export let maxWidth
|
||||||
export let maxHeight
|
export let maxHeight
|
||||||
export let open = false
|
export let open = false
|
||||||
|
@ -21,7 +22,6 @@
|
||||||
export let customHeight
|
export let customHeight
|
||||||
export let animate = true
|
export let animate = true
|
||||||
export let customZindex
|
export let customZindex
|
||||||
|
|
||||||
export let handlePostionUpdate
|
export let handlePostionUpdate
|
||||||
export let showPopover = true
|
export let showPopover = true
|
||||||
export let clickOutsideOverride = false
|
export let clickOutsideOverride = false
|
||||||
|
@ -87,6 +87,7 @@
|
||||||
align,
|
align,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
|
minWidth,
|
||||||
useAnchorWidth,
|
useAnchorWidth,
|
||||||
offset,
|
offset,
|
||||||
customUpdate: handlePostionUpdate,
|
customUpdate: handlePostionUpdate,
|
||||||
|
@ -102,6 +103,8 @@
|
||||||
role="presentation"
|
role="presentation"
|
||||||
style="height: {customHeight}; --customZindex: {customZindex};"
|
style="height: {customHeight}; --customZindex: {customZindex};"
|
||||||
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
|
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
|
||||||
|
on:mouseenter
|
||||||
|
on:mouseleave
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
export let schema
|
export let schema
|
||||||
export let value
|
export let value
|
||||||
export let customRenderers = []
|
export let customRenderers = []
|
||||||
|
export let snippets
|
||||||
|
|
||||||
let renderer
|
let renderer
|
||||||
const typeMap = {
|
const typeMap = {
|
||||||
|
@ -44,7 +45,7 @@
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
return processStringSync(template, { value })
|
return processStringSync(template, { value, snippets })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -42,6 +42,7 @@
|
||||||
export let customPlaceholder = false
|
export let customPlaceholder = false
|
||||||
export let showHeaderBorder = true
|
export let showHeaderBorder = true
|
||||||
export let placeholderText = "No rows found"
|
export let placeholderText = "No rows found"
|
||||||
|
export let snippets = []
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -425,6 +426,7 @@
|
||||||
<CellRenderer
|
<CellRenderer
|
||||||
{customRenderers}
|
{customRenderers}
|
||||||
{row}
|
{row}
|
||||||
|
{snippets}
|
||||||
schema={schema[field]}
|
schema={schema[field]}
|
||||||
value={deepGet(row, field)}
|
value={deepGet(row, field)}
|
||||||
on:clickrelationship
|
on:clickrelationship
|
||||||
|
@ -470,6 +472,7 @@
|
||||||
--table-border: 1px solid var(--spectrum-alias-border-color-mid);
|
--table-border: 1px solid var(--spectrum-alias-border-color-mid);
|
||||||
--cell-padding: var(--spectrum-global-dimension-size-250);
|
--cell-padding: var(--spectrum-global-dimension-size-250);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
display: contents;
|
||||||
}
|
}
|
||||||
.wrapper--quiet {
|
.wrapper--quiet {
|
||||||
--table-bg: var(--spectrum-alias-background-color-transparent);
|
--table-bg: var(--spectrum-alias-background-color-transparent);
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
export let text = ""
|
export let text = ""
|
||||||
export let fixed = false
|
export let fixed = false
|
||||||
export let color = null
|
export let color = null
|
||||||
|
export let noWrap = false
|
||||||
|
|
||||||
let wrapper
|
let wrapper
|
||||||
let hovered = false
|
let hovered = false
|
||||||
|
@ -105,6 +106,7 @@
|
||||||
<Portal target=".spectrum">
|
<Portal target=".spectrum">
|
||||||
<span
|
<span
|
||||||
class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open"
|
class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open"
|
||||||
|
class:noWrap
|
||||||
style={`left:${left}px;top:${top}px;${tooltipStyle}`}
|
style={`left:${left}px;top:${top}px;${tooltipStyle}`}
|
||||||
transition:fade|local={{ duration: 130 }}
|
transition:fade|local={{ duration: 130 }}
|
||||||
>
|
>
|
||||||
|
@ -118,6 +120,9 @@
|
||||||
.abs-tooltip {
|
.abs-tooltip {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
.spectrum-Tooltip.noWrap .spectrum-Tooltip-label {
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
.spectrum-Tooltip {
|
.spectrum-Tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
|
|
|
@ -19,7 +19,7 @@ export { default as ActionMenu } from "./ActionMenu/ActionMenu.svelte"
|
||||||
export { default as Button } from "./Button/Button.svelte"
|
export { default as Button } from "./Button/Button.svelte"
|
||||||
export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte"
|
export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte"
|
||||||
export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
|
export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
|
||||||
export { default as Icon, directions } from "./Icon/Icon.svelte"
|
export { default as Icon } from "./Icon/Icon.svelte"
|
||||||
export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
|
export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
|
||||||
export { default as Toggle } from "./Form/Toggle.svelte"
|
export { default as Toggle } from "./Form/Toggle.svelte"
|
||||||
export { default as RadioGroup } from "./Form/RadioGroup.svelte"
|
export { default as RadioGroup } from "./Form/RadioGroup.svelte"
|
||||||
|
|
|
@ -66,10 +66,11 @@
|
||||||
"@spectrum-css/page": "^3.0.1",
|
"@spectrum-css/page": "^3.0.1",
|
||||||
"@spectrum-css/vars": "^3.0.1",
|
"@spectrum-css/vars": "^3.0.1",
|
||||||
"@zerodevx/svelte-json-view": "^1.0.7",
|
"@zerodevx/svelte-json-view": "^1.0.7",
|
||||||
"codemirror": "^5.59.0",
|
"codemirror": "^5.65.16",
|
||||||
"dayjs": "^1.10.8",
|
"dayjs": "^1.10.8",
|
||||||
"downloadjs": "1.4.7",
|
"downloadjs": "1.4.7",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
|
"json-format-highlight": "^1.0.4",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"posthog-js": "^1.36.0",
|
"posthog-js": "^1.36.0",
|
||||||
"remixicon": "2.5.0",
|
"remixicon": "2.5.0",
|
||||||
|
|
|
@ -9,13 +9,17 @@ const intercom = new IntercomClient(process.env.INTERCOM_TOKEN)
|
||||||
class AnalyticsHub {
|
class AnalyticsHub {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.clients = [posthog, intercom]
|
this.clients = [posthog, intercom]
|
||||||
|
this.initialised = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async activate() {
|
async activate() {
|
||||||
// Check analytics are enabled
|
// Check analytics are enabled
|
||||||
const analyticsStatus = await API.getAnalyticsStatus()
|
const analyticsStatus = await API.getAnalyticsStatus()
|
||||||
if (analyticsStatus.enabled) {
|
if (analyticsStatus.enabled && !this.initialised) {
|
||||||
this.clients.forEach(client => client.init())
|
this.clients.forEach(client => {
|
||||||
|
client.init()
|
||||||
|
})
|
||||||
|
this.initialised = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,7 +49,7 @@
|
||||||
<div class="side-bar-controls">
|
<div class="side-bar-controls">
|
||||||
<NavHeader
|
<NavHeader
|
||||||
title="Automations"
|
title="Automations"
|
||||||
placeholder="Search for automation"
|
placeholder="Search for automations"
|
||||||
bind:value={searchString}
|
bind:value={searchString}
|
||||||
onAdd={() => modal.show()}
|
onAdd={() => modal.show()}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
Drawer,
|
Drawer,
|
||||||
Modal,
|
Modal,
|
||||||
notifications,
|
notifications,
|
||||||
Icon,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
|
@ -31,7 +30,7 @@
|
||||||
import Editor from "components/integration/QueryEditor.svelte"
|
import Editor from "components/integration/QueryEditor.svelte"
|
||||||
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
|
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
|
||||||
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
|
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
|
||||||
import BindingPicker from "components/common/bindings/BindingPicker.svelte"
|
import BindingSidePanel from "components/common/bindings/BindingSidePanel.svelte"
|
||||||
import { BindingHelpers } from "components/common/bindings/utils"
|
import { BindingHelpers } from "components/common/bindings/utils"
|
||||||
import {
|
import {
|
||||||
bindingsToCompletions,
|
bindingsToCompletions,
|
||||||
|
@ -52,11 +51,12 @@
|
||||||
export let testData
|
export let testData
|
||||||
export let schemaProperties
|
export let schemaProperties
|
||||||
export let isTestModal = false
|
export let isTestModal = false
|
||||||
|
|
||||||
let webhookModal
|
let webhookModal
|
||||||
let drawer
|
let drawer
|
||||||
let fillWidth = true
|
|
||||||
let inputData
|
let inputData
|
||||||
let insertAtPos, getCaretPosition
|
let insertAtPos, getCaretPosition
|
||||||
|
|
||||||
$: filters = lookForFilters(schemaProperties) || []
|
$: filters = lookForFilters(schemaProperties) || []
|
||||||
$: tempFilters = filters
|
$: tempFilters = filters
|
||||||
$: stepId = block.stepId
|
$: stepId = block.stepId
|
||||||
|
@ -80,7 +80,6 @@
|
||||||
})
|
})
|
||||||
$: editingJs = codeMode === EditorModes.JS
|
$: editingJs = codeMode === EditorModes.JS
|
||||||
$: requiredProperties = block.schema.inputs.required || []
|
$: requiredProperties = block.schema.inputs.required || []
|
||||||
|
|
||||||
$: stepCompletions =
|
$: stepCompletions =
|
||||||
codeMode === EditorModes.Handlebars
|
codeMode === EditorModes.Handlebars
|
||||||
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
|
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
|
||||||
|
@ -377,12 +376,13 @@
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
{#each schemaProperties as [key, value]}
|
{#each schemaProperties as [key, value]}
|
||||||
{#if canShowField(key, value)}
|
{#if canShowField(key, value)}
|
||||||
|
{@const label = getFieldLabel(key, value)}
|
||||||
<div class:block-field={shouldRenderField(value)}>
|
<div class:block-field={shouldRenderField(value)}>
|
||||||
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)}
|
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)}
|
||||||
<Label
|
<Label
|
||||||
tooltip={value.title === "Binding / Value"
|
tooltip={value.title === "Binding / Value"
|
||||||
? "If using the String input type, please use a comma or newline separated string"
|
? "If using the String input type, please use a comma or newline separated string"
|
||||||
: null}>{getFieldLabel(key, value)}</Label
|
: null}>{label}</Label
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<div class:field-width={shouldRenderField(value)}>
|
<div class:field-width={shouldRenderField(value)}>
|
||||||
|
@ -415,8 +415,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if value.type === "date"}
|
{:else if value.type === "date"}
|
||||||
<DrawerBindableSlot
|
<DrawerBindableSlot
|
||||||
fillWidth
|
title={value.title ?? label}
|
||||||
title={value.title}
|
|
||||||
panel={AutomationBindingPanel}
|
panel={AutomationBindingPanel}
|
||||||
type={"date"}
|
type={"date"}
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
|
@ -439,7 +438,7 @@
|
||||||
/>
|
/>
|
||||||
{:else if value.customType === "filters"}
|
{:else if value.customType === "filters"}
|
||||||
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
|
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
|
||||||
<Drawer bind:this={drawer} {fillWidth} title="Filtering">
|
<Drawer bind:this={drawer} title="Filtering">
|
||||||
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
|
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -450,7 +449,6 @@
|
||||||
{schemaFields}
|
{schemaFields}
|
||||||
datasource={{ type: "table", tableId }}
|
datasource={{ type: "table", tableId }}
|
||||||
panel={AutomationBindingPanel}
|
panel={AutomationBindingPanel}
|
||||||
fillWidth
|
|
||||||
on:change={e => (tempFilters = e.detail)}
|
on:change={e => (tempFilters = e.detail)}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
@ -463,19 +461,17 @@
|
||||||
{:else if value.customType === "email"}
|
{:else if value.customType === "email"}
|
||||||
{#if isTestModal}
|
{#if isTestModal}
|
||||||
<ModalBindableInput
|
<ModalBindableInput
|
||||||
title={value.title}
|
title={value.title ?? label}
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
panel={AutomationBindingPanel}
|
panel={AutomationBindingPanel}
|
||||||
type="email"
|
type="email"
|
||||||
on:change={e => onChange(e, key)}
|
on:change={e => onChange(e, key)}
|
||||||
{bindings}
|
{bindings}
|
||||||
fillWidth
|
|
||||||
updateOnChange={false}
|
updateOnChange={false}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<DrawerBindableInput
|
<DrawerBindableInput
|
||||||
fillWidth
|
title={value.title ?? label}
|
||||||
title={value.title}
|
|
||||||
panel={AutomationBindingPanel}
|
panel={AutomationBindingPanel}
|
||||||
type="email"
|
type="email"
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
|
@ -550,7 +546,7 @@
|
||||||
{:else if value.customType === "code"}
|
{:else if value.customType === "code"}
|
||||||
<CodeEditorModal>
|
<CodeEditorModal>
|
||||||
<div class:js-editor={editingJs}>
|
<div class:js-editor={editingJs}>
|
||||||
<div class:js-code={editingJs} style="width: 100%">
|
<div class:js-code={editingJs} style="width:100%;height:500px;">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
on:change={e => {
|
on:change={e => {
|
||||||
|
@ -563,24 +559,14 @@
|
||||||
autocompleteEnabled={codeMode !== EditorModes.JS}
|
autocompleteEnabled={codeMode !== EditorModes.JS}
|
||||||
bind:getCaretPosition
|
bind:getCaretPosition
|
||||||
bind:insertAtPos
|
bind:insertAtPos
|
||||||
height={500}
|
placeholder={codeMode === EditorModes.Handlebars
|
||||||
|
? "Add bindings by typing {{"
|
||||||
|
: null}
|
||||||
/>
|
/>
|
||||||
<div class="messaging">
|
|
||||||
{#if codeMode === EditorModes.Handlebars}
|
|
||||||
<Icon name="FlashOn" />
|
|
||||||
<div class="messaging-wrap">
|
|
||||||
<div>
|
|
||||||
Add available bindings by typing <strong>
|
|
||||||
}}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{#if editingJs}
|
{#if editingJs}
|
||||||
<div class="js-binding-picker">
|
<div class="js-binding-picker">
|
||||||
<BindingPicker
|
<BindingSidePanel
|
||||||
{bindings}
|
{bindings}
|
||||||
allowHelpers={false}
|
allowHelpers={false}
|
||||||
addBinding={binding =>
|
addBinding={binding =>
|
||||||
|
@ -609,7 +595,7 @@
|
||||||
{:else if value.type === "string" || value.type === "number" || value.type === "integer"}
|
{:else if value.type === "string" || value.type === "number" || value.type === "integer"}
|
||||||
{#if isTestModal}
|
{#if isTestModal}
|
||||||
<ModalBindableInput
|
<ModalBindableInput
|
||||||
title={value.title}
|
title={value.title || label}
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
panel={AutomationBindingPanel}
|
panel={AutomationBindingPanel}
|
||||||
type={value.customType}
|
type={value.customType}
|
||||||
|
@ -620,8 +606,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="test">
|
<div class="test">
|
||||||
<DrawerBindableInput
|
<DrawerBindableInput
|
||||||
fillWidth={true}
|
title={value.title ?? label}
|
||||||
title={value.title}
|
|
||||||
panel={AutomationBindingPanel}
|
panel={AutomationBindingPanel}
|
||||||
type={value.customType}
|
type={value.customType}
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
|
@ -654,11 +639,6 @@
|
||||||
width: 320px;
|
width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messaging {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
.fields {
|
.fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -670,7 +650,6 @@
|
||||||
.block-field {
|
.block-field {
|
||||||
display: flex; /* Use Flexbox */
|
display: flex; /* Use Flexbox */
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
|
||||||
flex-direction: row; /* Arrange label and field side by side */
|
flex-direction: row; /* Arrange label and field side by side */
|
||||||
align-items: center; /* Align vertically in the center */
|
align-items: center; /* Align vertically in the center */
|
||||||
gap: 10px; /* Add some space between label and field */
|
gap: 10px; /* Add some space between label and field */
|
||||||
|
|
|
@ -57,7 +57,6 @@
|
||||||
on:change={e => onChange(e, field)}
|
on:change={e => onChange(e, field)}
|
||||||
type="string"
|
type="string"
|
||||||
{bindings}
|
{bindings}
|
||||||
fillWidth={true}
|
|
||||||
updateOnChange={false}
|
updateOnChange={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -52,7 +52,6 @@
|
||||||
on:change={e => onChange(e, field)}
|
on:change={e => onChange(e, field)}
|
||||||
type="string"
|
type="string"
|
||||||
{bindings}
|
{bindings}
|
||||||
fillWidth={true}
|
|
||||||
updateOnChange={false}
|
updateOnChange={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -129,8 +129,7 @@
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<DrawerBindableSlot
|
<DrawerBindableSlot
|
||||||
fillWidth
|
title={value.title || field}
|
||||||
title={value.title}
|
|
||||||
panel={AutomationBindingPanel}
|
panel={AutomationBindingPanel}
|
||||||
type={schema.type}
|
type={schema.type}
|
||||||
{schema}
|
{schema}
|
||||||
|
|
|
@ -85,8 +85,8 @@
|
||||||
on:change={e => onChange(e, field)}
|
on:change={e => onChange(e, field)}
|
||||||
type="string"
|
type="string"
|
||||||
bindings={parsedBindings}
|
bindings={parsedBindings}
|
||||||
fillWidth={true}
|
|
||||||
allowJS={true}
|
allowJS={true}
|
||||||
updateOnChange={false}
|
updateOnChange={false}
|
||||||
|
title={schema.name}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -36,6 +36,8 @@
|
||||||
import { ValidColumnNameRegex } from "@budibase/shared-core"
|
import { ValidColumnNameRegex } from "@budibase/shared-core"
|
||||||
import { FieldType, FieldSubtype, SourceName } from "@budibase/types"
|
import { FieldType, FieldSubtype, SourceName } from "@budibase/types"
|
||||||
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
|
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
|
||||||
|
import { RowUtils } from "@budibase/frontend-core"
|
||||||
|
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
|
||||||
|
|
||||||
const AUTO_TYPE = FIELDS.AUTO.type
|
const AUTO_TYPE = FIELDS.AUTO.type
|
||||||
const FORMULA_TYPE = FIELDS.FORMULA.type
|
const FORMULA_TYPE = FIELDS.FORMULA.type
|
||||||
|
@ -49,43 +51,21 @@
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
|
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
|
||||||
const { dispatch: gridDispatch } = getContext("grid")
|
const { dispatch: gridDispatch, rows } = getContext("grid")
|
||||||
|
|
||||||
export let field
|
export let field
|
||||||
|
|
||||||
let mounted = false
|
let mounted = false
|
||||||
const fieldDefinitions = Object.values(FIELDS).reduce(
|
|
||||||
// Storing the fields by complex field id
|
|
||||||
(acc, field) => ({
|
|
||||||
...acc,
|
|
||||||
[makeFieldId(field.type, field.subtype)]: field,
|
|
||||||
}),
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
|
|
||||||
function makeFieldId(type, subtype, autocolumn) {
|
|
||||||
// don't make field IDs for auto types
|
|
||||||
if (type === AUTO_TYPE || autocolumn) {
|
|
||||||
return type.toUpperCase()
|
|
||||||
} else {
|
|
||||||
return `${type}${subtype || ""}`.toUpperCase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let originalName
|
let originalName
|
||||||
let linkEditDisabled
|
let linkEditDisabled
|
||||||
let primaryDisplay
|
let primaryDisplay
|
||||||
let indexes = [...($tables.selected.indexes || [])]
|
let indexes = [...($tables.selected.indexes || [])]
|
||||||
let isCreating = undefined
|
let isCreating = undefined
|
||||||
|
|
||||||
let relationshipPart1 = PrettyRelationshipDefinitions.Many
|
let relationshipPart1 = PrettyRelationshipDefinitions.Many
|
||||||
let relationshipPart2 = PrettyRelationshipDefinitions.One
|
let relationshipPart2 = PrettyRelationshipDefinitions.One
|
||||||
|
|
||||||
let relationshipTableIdPrimary = null
|
let relationshipTableIdPrimary = null
|
||||||
let relationshipTableIdSecondary = null
|
let relationshipTableIdSecondary = null
|
||||||
|
|
||||||
let table = $tables.selected
|
let table = $tables.selected
|
||||||
|
|
||||||
let confirmDeleteDialog
|
let confirmDeleteDialog
|
||||||
let savingColumn
|
let savingColumn
|
||||||
let deleteColName
|
let deleteColName
|
||||||
|
@ -99,11 +79,6 @@
|
||||||
}
|
}
|
||||||
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
|
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
|
||||||
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
|
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
|
||||||
|
|
||||||
$: if (primaryDisplay) {
|
|
||||||
editableColumn.constraints.presence = { allowEmpty: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
let relationshipMap = {
|
let relationshipMap = {
|
||||||
[RelationshipType.ONE_TO_MANY]: {
|
[RelationshipType.ONE_TO_MANY]: {
|
||||||
part1: PrettyRelationshipDefinitions.MANY,
|
part1: PrettyRelationshipDefinitions.MANY,
|
||||||
|
@ -118,7 +93,12 @@
|
||||||
part2: PrettyRelationshipDefinitions.MANY,
|
part2: PrettyRelationshipDefinitions.MANY,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
let autoColumnInfo = getAutoColumnInformation()
|
||||||
|
|
||||||
|
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
|
||||||
|
$: if (primaryDisplay) {
|
||||||
|
editableColumn.constraints.presence = { allowEmpty: false }
|
||||||
|
}
|
||||||
$: {
|
$: {
|
||||||
// this parses any changes the user has made when creating a new internal relationship
|
// this parses any changes the user has made when creating a new internal relationship
|
||||||
// into what we expect the schema to look like
|
// into what we expect the schema to look like
|
||||||
|
@ -148,6 +128,76 @@
|
||||||
editableColumn.tableId = relationshipTableIdSecondary
|
editableColumn.tableId = relationshipTableIdSecondary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$: initialiseField(field, savingColumn)
|
||||||
|
$: checkConstraints(editableColumn)
|
||||||
|
$: required = !!editableColumn?.constraints?.presence || primaryDisplay
|
||||||
|
$: uneditable =
|
||||||
|
$tables.selected?._id === TableNames.USERS &&
|
||||||
|
UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
|
||||||
|
$: invalid =
|
||||||
|
!editableColumn?.name ||
|
||||||
|
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
|
||||||
|
Object.keys(errors).length !== 0
|
||||||
|
$: errors = checkErrors(editableColumn)
|
||||||
|
$: datasource = $datasources.list.find(
|
||||||
|
source => source._id === table?.sourceId
|
||||||
|
)
|
||||||
|
$: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected)
|
||||||
|
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce((acc, key) => {
|
||||||
|
if (!tableAutoColumnsTypes.includes(key)) {
|
||||||
|
acc[key] = autoColumnInfo[key]
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
$: availableAutoColumnKeys = availableAutoColumns
|
||||||
|
? Object.keys(availableAutoColumns)
|
||||||
|
: []
|
||||||
|
$: autoColumnOptions = editableColumn.autocolumn
|
||||||
|
? autoColumnInfo
|
||||||
|
: availableAutoColumns
|
||||||
|
// used to select what different options can be displayed for column type
|
||||||
|
$: canBeDisplay =
|
||||||
|
editableColumn?.type !== LINK_TYPE &&
|
||||||
|
editableColumn?.type !== AUTO_TYPE &&
|
||||||
|
editableColumn?.type !== JSON_TYPE &&
|
||||||
|
!editableColumn.autocolumn
|
||||||
|
$: canBeRequired =
|
||||||
|
editableColumn?.type !== LINK_TYPE &&
|
||||||
|
!uneditable &&
|
||||||
|
editableColumn?.type !== AUTO_TYPE &&
|
||||||
|
!editableColumn.autocolumn
|
||||||
|
$: externalTable = table.sourceType === DB_TYPE_EXTERNAL
|
||||||
|
// in the case of internal tables the sourceId will just be undefined
|
||||||
|
$: tableOptions = $tables.list.filter(
|
||||||
|
opt =>
|
||||||
|
opt.sourceType === table.sourceType && table.sourceId === opt.sourceId
|
||||||
|
)
|
||||||
|
$: typeEnabled =
|
||||||
|
!originalName ||
|
||||||
|
(originalName &&
|
||||||
|
SWITCHABLE_TYPES.indexOf(editableColumn.type) !== -1 &&
|
||||||
|
!editableColumn?.autocolumn)
|
||||||
|
|
||||||
|
const fieldDefinitions = Object.values(FIELDS).reduce(
|
||||||
|
// Storing the fields by complex field id
|
||||||
|
(acc, field) => ({
|
||||||
|
...acc,
|
||||||
|
[makeFieldId(field.type, field.subtype)]: field,
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
function makeFieldId(type, subtype, autocolumn) {
|
||||||
|
// don't make field IDs for auto types
|
||||||
|
if (type === AUTO_TYPE || autocolumn) {
|
||||||
|
return type.toUpperCase()
|
||||||
|
} else if (type === FieldType.BB_REFERENCE) {
|
||||||
|
return `${type}${subtype || ""}`.toUpperCase()
|
||||||
|
} else {
|
||||||
|
return type.toUpperCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initialiseField = (field, savingColumn) => {
|
const initialiseField = (field, savingColumn) => {
|
||||||
isCreating = !field
|
isCreating = !field
|
||||||
if (field && !savingColumn) {
|
if (field && !savingColumn) {
|
||||||
|
@ -187,22 +237,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: initialiseField(field, savingColumn)
|
|
||||||
|
|
||||||
$: checkConstraints(editableColumn)
|
|
||||||
$: required = !!editableColumn?.constraints?.presence || primaryDisplay
|
|
||||||
$: uneditable =
|
|
||||||
$tables.selected?._id === TableNames.USERS &&
|
|
||||||
UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
|
|
||||||
$: invalid =
|
|
||||||
!editableColumn?.name ||
|
|
||||||
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
|
|
||||||
Object.keys(errors).length !== 0
|
|
||||||
$: errors = checkErrors(editableColumn)
|
|
||||||
$: datasource = $datasources.list.find(
|
|
||||||
source => source._id === table?.sourceId
|
|
||||||
)
|
|
||||||
|
|
||||||
const getTableAutoColumnTypes = table => {
|
const getTableAutoColumnTypes = table => {
|
||||||
return Object.keys(table?.schema).reduce((acc, key) => {
|
return Object.keys(table?.schema).reduce((acc, key) => {
|
||||||
let fieldSchema = table?.schema[key]
|
let fieldSchema = table?.schema[key]
|
||||||
|
@ -213,47 +247,6 @@
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
let autoColumnInfo = getAutoColumnInformation()
|
|
||||||
|
|
||||||
$: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected)
|
|
||||||
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce((acc, key) => {
|
|
||||||
if (!tableAutoColumnsTypes.includes(key)) {
|
|
||||||
acc[key] = autoColumnInfo[key]
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
$: availableAutoColumnKeys = availableAutoColumns
|
|
||||||
? Object.keys(availableAutoColumns)
|
|
||||||
: []
|
|
||||||
|
|
||||||
$: autoColumnOptions = editableColumn.autocolumn
|
|
||||||
? autoColumnInfo
|
|
||||||
: availableAutoColumns
|
|
||||||
|
|
||||||
// used to select what different options can be displayed for column type
|
|
||||||
$: canBeDisplay =
|
|
||||||
editableColumn?.type !== LINK_TYPE &&
|
|
||||||
editableColumn?.type !== AUTO_TYPE &&
|
|
||||||
editableColumn?.type !== JSON_TYPE &&
|
|
||||||
!editableColumn.autocolumn
|
|
||||||
$: canBeRequired =
|
|
||||||
editableColumn?.type !== LINK_TYPE &&
|
|
||||||
!uneditable &&
|
|
||||||
editableColumn?.type !== AUTO_TYPE &&
|
|
||||||
!editableColumn.autocolumn
|
|
||||||
$: externalTable = table.sourceType === DB_TYPE_EXTERNAL
|
|
||||||
// in the case of internal tables the sourceId will just be undefined
|
|
||||||
$: tableOptions = $tables.list.filter(
|
|
||||||
opt =>
|
|
||||||
opt.sourceType === table.sourceType && table.sourceId === opt.sourceId
|
|
||||||
)
|
|
||||||
$: typeEnabled =
|
|
||||||
!originalName ||
|
|
||||||
(originalName &&
|
|
||||||
SWITCHABLE_TYPES.indexOf(editableColumn.type) !== -1 &&
|
|
||||||
!editableColumn?.autocolumn)
|
|
||||||
|
|
||||||
async function saveColumn() {
|
async function saveColumn() {
|
||||||
savingColumn = true
|
savingColumn = true
|
||||||
if (errors?.length) {
|
if (errors?.length) {
|
||||||
|
@ -479,7 +472,7 @@
|
||||||
newError.name = `Column name already in use.`
|
newError.name = `Column name already in use.`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fieldInfo.type === "auto" && !fieldInfo.subtype) {
|
if (fieldInfo.type === FieldType.AUTO && !fieldInfo.subtype) {
|
||||||
newError.subtype = `Auto Column requires a type`
|
newError.subtype = `Auto Column requires a type`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -540,18 +533,18 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if editableColumn.type === "string"}
|
{#if editableColumn.type === FieldType.STRING}
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
label="Max Length"
|
label="Max Length"
|
||||||
bind:value={editableColumn.constraints.length.maximum}
|
bind:value={editableColumn.constraints.length.maximum}
|
||||||
/>
|
/>
|
||||||
{:else if editableColumn.type === "options"}
|
{:else if editableColumn.type === FieldType.OPTIONS}
|
||||||
<OptionSelectDnD
|
<OptionSelectDnD
|
||||||
bind:constraints={editableColumn.constraints}
|
bind:constraints={editableColumn.constraints}
|
||||||
bind:optionColors={editableColumn.optionColors}
|
bind:optionColors={editableColumn.optionColors}
|
||||||
/>
|
/>
|
||||||
{:else if editableColumn.type === "longform"}
|
{:else if editableColumn.type === FieldType.LONGFORM}
|
||||||
<div>
|
<div>
|
||||||
<div class="tooltip-alignment">
|
<div class="tooltip-alignment">
|
||||||
<Label size="M">Formatting</Label>
|
<Label size="M">Formatting</Label>
|
||||||
|
@ -569,12 +562,12 @@
|
||||||
text="Enable rich text support (markdown)"
|
text="Enable rich text support (markdown)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if editableColumn.type === "array"}
|
{:else if editableColumn.type === FieldType.ARRAY}
|
||||||
<OptionSelectDnD
|
<OptionSelectDnD
|
||||||
bind:constraints={editableColumn.constraints}
|
bind:constraints={editableColumn.constraints}
|
||||||
bind:optionColors={editableColumn.optionColors}
|
bind:optionColors={editableColumn.optionColors}
|
||||||
/>
|
/>
|
||||||
{:else if editableColumn.type === "datetime" && !editableColumn.autocolumn}
|
{:else if editableColumn.type === FieldType.DATETIME && !editableColumn.autocolumn}
|
||||||
<div class="split-label">
|
<div class="split-label">
|
||||||
<div class="label-length">
|
<div class="label-length">
|
||||||
<Label size="M">Earliest</Label>
|
<Label size="M">Earliest</Label>
|
||||||
|
@ -613,7 +606,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<Toggle bind:value={editableColumn.dateOnly} text="Date only" />
|
<Toggle bind:value={editableColumn.dateOnly} text="Date only" />
|
||||||
{:else if editableColumn.type === "number" && !editableColumn.autocolumn}
|
{:else if editableColumn.type === FieldType.NUMBER && !editableColumn.autocolumn}
|
||||||
<div class="split-label">
|
<div class="split-label">
|
||||||
<div class="label-length">
|
<div class="label-length">
|
||||||
<Label size="M">Min Value</Label>
|
<Label size="M">Min Value</Label>
|
||||||
|
@ -638,7 +631,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if editableColumn.type === "link"}
|
{:else if editableColumn.type === FieldType.LINK}
|
||||||
<RelationshipSelector
|
<RelationshipSelector
|
||||||
bind:relationshipPart1
|
bind:relationshipPart1
|
||||||
bind:relationshipPart2
|
bind:relationshipPart2
|
||||||
|
@ -679,6 +672,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="input-length">
|
<div class="input-length">
|
||||||
<ModalBindableInput
|
<ModalBindableInput
|
||||||
|
panel={ServerBindingPanel}
|
||||||
title="Formula"
|
title="Formula"
|
||||||
value={editableColumn.formula}
|
value={editableColumn.formula}
|
||||||
on:change={e => {
|
on:change={e => {
|
||||||
|
@ -689,6 +683,7 @@
|
||||||
}}
|
}}
|
||||||
bindings={getBindings({ table })}
|
bindings={getBindings({ table })}
|
||||||
allowJS
|
allowJS
|
||||||
|
context={rowGoldenSample}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -40,8 +40,15 @@
|
||||||
part2: PrettyRelationshipDefinitions.MANY,
|
part2: PrettyRelationshipDefinitions.MANY,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
|
$: relationshipOpts1 =
|
||||||
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
|
relationshipPart2 === PrettyRelationshipDefinitions.ONE
|
||||||
|
? [PrettyRelationshipDefinitions.MANY]
|
||||||
|
: Object.values(PrettyRelationshipDefinitions)
|
||||||
|
|
||||||
|
$: relationshipOpts2 =
|
||||||
|
relationshipPart1 === PrettyRelationshipDefinitions.ONE
|
||||||
|
? [PrettyRelationshipDefinitions.MANY]
|
||||||
|
: Object.values(PrettyRelationshipDefinitions)
|
||||||
|
|
||||||
let relationshipPart1 = PrettyRelationshipDefinitions.ONE
|
let relationshipPart1 = PrettyRelationshipDefinitions.ONE
|
||||||
let relationshipPart2 = PrettyRelationshipDefinitions.MANY
|
let relationshipPart2 = PrettyRelationshipDefinitions.MANY
|
||||||
|
|
|
@ -28,7 +28,6 @@
|
||||||
let deleteTableName
|
let deleteTableName
|
||||||
|
|
||||||
$: externalTable = table?.sourceType === DB_TYPE_EXTERNAL
|
$: externalTable = table?.sourceType === DB_TYPE_EXTERNAL
|
||||||
$: allowDeletion = !externalTable || table?.created
|
|
||||||
|
|
||||||
function showDeleteModal() {
|
function showDeleteModal() {
|
||||||
templateScreens = $screenStore.screens.filter(
|
templateScreens = $screenStore.screens.filter(
|
||||||
|
@ -56,7 +55,7 @@
|
||||||
$goto(`./datasource/${table.datasourceId}`)
|
$goto(`./datasource/${table.datasourceId}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error deleting table")
|
notifications.error(`Error deleting table - ${error.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,17 +85,15 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if allowDeletion}
|
<ActionMenu>
|
||||||
<ActionMenu>
|
<div slot="control" class="icon">
|
||||||
<div slot="control" class="icon">
|
<Icon s hoverable name="MoreSmallList" />
|
||||||
<Icon s hoverable name="MoreSmallList" />
|
</div>
|
||||||
</div>
|
{#if !externalTable}
|
||||||
{#if !externalTable}
|
<MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem>
|
||||||
<MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem>
|
{/if}
|
||||||
{/if}
|
<MenuItem icon="Delete" on:click={showDeleteModal}>Delete</MenuItem>
|
||||||
<MenuItem icon="Delete" on:click={showDeleteModal}>Delete</MenuItem>
|
</ActionMenu>
|
||||||
</ActionMenu>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Modal bind:this={editorModal} on:show={initForm}>
|
<Modal bind:this={editorModal} on:show={initForm}>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
|
|
|
@ -40,21 +40,22 @@
|
||||||
indentMore,
|
indentMore,
|
||||||
indentLess,
|
indentLess,
|
||||||
} from "@codemirror/commands"
|
} from "@codemirror/commands"
|
||||||
import { Compartment } from "@codemirror/state"
|
import { Compartment, EditorState } from "@codemirror/state"
|
||||||
import { javascript } from "@codemirror/lang-javascript"
|
import { javascript } from "@codemirror/lang-javascript"
|
||||||
import { EditorModes, getDefaultTheme } from "./"
|
import { EditorModes } from "./"
|
||||||
import { themeStore } from "stores/portal"
|
import { themeStore } from "stores/portal"
|
||||||
|
|
||||||
export let label
|
export let label
|
||||||
export let completions = []
|
export let completions = []
|
||||||
export let height = 200
|
|
||||||
export let resize = "none"
|
|
||||||
export let mode = EditorModes.Handlebars
|
export let mode = EditorModes.Handlebars
|
||||||
export let value = ""
|
export let value = ""
|
||||||
export let placeholder = null
|
export let placeholder = null
|
||||||
export let autocompleteEnabled = true
|
export let autocompleteEnabled = true
|
||||||
export let autofocus = false
|
export let autofocus = false
|
||||||
export let jsBindingWrapping = true
|
export let jsBindingWrapping = true
|
||||||
|
export let readonly = false
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
// Export a function to expose caret position
|
// Export a function to expose caret position
|
||||||
export const getCaretPosition = () => {
|
export const getCaretPosition = () => {
|
||||||
|
@ -82,8 +83,8 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// For handlebars only.
|
// Match decoration for HBS bindings
|
||||||
const bindStyle = new MatchDecorator({
|
const hbsMatchDeco = new MatchDecorator({
|
||||||
regexp: FIND_ANY_HBS_REGEX,
|
regexp: FIND_ANY_HBS_REGEX,
|
||||||
decoration: () => {
|
decoration: () => {
|
||||||
return Decoration.mark({
|
return Decoration.mark({
|
||||||
|
@ -94,12 +95,11 @@
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const hbsMatchDecoPlugin = ViewPlugin.define(
|
||||||
let plugin = ViewPlugin.define(
|
|
||||||
view => ({
|
view => ({
|
||||||
decorations: bindStyle.createDeco(view),
|
decorations: hbsMatchDeco.createDeco(view),
|
||||||
update(u) {
|
update(u) {
|
||||||
this.decorations = bindStyle.updateDeco(u, this.decorations)
|
this.decorations = hbsMatchDeco.updateDeco(u, this.decorations)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
@ -107,7 +107,29 @@
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
// Match decoration for snippets
|
||||||
|
const snippetMatchDeco = new MatchDecorator({
|
||||||
|
regexp: /snippets\.[^\s(]+/g,
|
||||||
|
decoration: () => {
|
||||||
|
return Decoration.mark({
|
||||||
|
tag: "span",
|
||||||
|
attributes: {
|
||||||
|
class: "snippet-wrap",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const snippetMatchDecoPlugin = ViewPlugin.define(
|
||||||
|
view => ({
|
||||||
|
decorations: snippetMatchDeco.createDeco(view),
|
||||||
|
update(u) {
|
||||||
|
this.decorations = snippetMatchDeco.updateDeco(u, this.decorations)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
decorations: v => v.decorations,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Theming!
|
// Theming!
|
||||||
let currentTheme = $themeStore?.theme
|
let currentTheme = $themeStore?.theme
|
||||||
|
@ -117,7 +139,7 @@
|
||||||
const indentWithTabCustom = {
|
const indentWithTabCustom = {
|
||||||
key: "Tab",
|
key: "Tab",
|
||||||
run: view => {
|
run: view => {
|
||||||
if (completionStatus(view.state) == "active") {
|
if (completionStatus(view.state) === "active") {
|
||||||
acceptCompletion(view)
|
acceptCompletion(view)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -131,7 +153,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildKeymap = () => {
|
const buildKeymap = () => {
|
||||||
const baseMap = [
|
return [
|
||||||
...closeBracketsKeymap,
|
...closeBracketsKeymap,
|
||||||
...defaultKeymap,
|
...defaultKeymap,
|
||||||
...historyKeymap,
|
...historyKeymap,
|
||||||
|
@ -139,43 +161,25 @@
|
||||||
...completionKeymap,
|
...completionKeymap,
|
||||||
indentWithTabCustom,
|
indentWithTabCustom,
|
||||||
]
|
]
|
||||||
return baseMap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildBaseExtensions = () => {
|
const buildBaseExtensions = () => {
|
||||||
return [
|
return [
|
||||||
...(mode.name === "handlebars" ? [plugin] : []),
|
|
||||||
history(),
|
|
||||||
drawSelection(),
|
drawSelection(),
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
closeBrackets(),
|
closeBrackets(),
|
||||||
highlightActiveLine(),
|
|
||||||
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
|
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
|
||||||
highlightActiveLineGutter(),
|
|
||||||
highlightSpecialChars(),
|
highlightSpecialChars(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorView.updateListener.of(v => {
|
themeConfig.of([...(isDark ? [oneDark] : [])]),
|
||||||
const docStr = v.state.doc?.toString()
|
|
||||||
if (docStr === value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dispatch("change", docStr)
|
|
||||||
}),
|
|
||||||
keymap.of(buildKeymap()),
|
|
||||||
themeConfig.of([
|
|
||||||
getDefaultTheme({
|
|
||||||
height: editorHeight,
|
|
||||||
resize,
|
|
||||||
dark: isDark,
|
|
||||||
}),
|
|
||||||
...(isDark ? [oneDark] : []),
|
|
||||||
]),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// None of this is reactive, but it never has been, so we just assume most
|
||||||
|
// config flags aren't changed at runtime
|
||||||
const buildExtensions = base => {
|
const buildExtensions = base => {
|
||||||
const complete = [...base]
|
let complete = [...base]
|
||||||
|
|
||||||
if (autocompleteEnabled) {
|
if (autocompleteEnabled) {
|
||||||
complete.push(
|
complete.push(
|
||||||
|
@ -183,7 +187,10 @@
|
||||||
override: [...completions],
|
override: [...completions],
|
||||||
closeOnBlur: true,
|
closeOnBlur: true,
|
||||||
icons: false,
|
icons: false,
|
||||||
optionClass: () => "autocomplete-option",
|
optionClass: completion =>
|
||||||
|
completion.simple
|
||||||
|
? "autocomplete-option-simple"
|
||||||
|
: "autocomplete-option",
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
complete.push(
|
complete.push(
|
||||||
|
@ -209,22 +216,49 @@
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode.name == "javascript") {
|
// JS only plugins
|
||||||
|
if (mode.name === "javascript") {
|
||||||
|
complete.push(snippetMatchDecoPlugin)
|
||||||
complete.push(javascript())
|
complete.push(javascript())
|
||||||
complete.push(highlightWhitespace())
|
if (!readonly) {
|
||||||
complete.push(lineNumbers())
|
complete.push(highlightWhitespace())
|
||||||
complete.push(foldGutter())
|
}
|
||||||
|
}
|
||||||
|
// HBS only plugins
|
||||||
|
else {
|
||||||
|
complete.push(hbsMatchDecoPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (placeholder) {
|
if (placeholder) {
|
||||||
complete.push(placeholderFn(placeholder))
|
complete.push(placeholderFn(placeholder))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (readonly) {
|
||||||
|
complete.push(EditorState.readOnly.of(true))
|
||||||
|
} else {
|
||||||
|
complete = [
|
||||||
|
...complete,
|
||||||
|
history(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightActiveLineGutter(),
|
||||||
|
lineNumbers(),
|
||||||
|
foldGutter(),
|
||||||
|
keymap.of(buildKeymap()),
|
||||||
|
EditorView.updateListener.of(v => {
|
||||||
|
const docStr = v.state.doc?.toString()
|
||||||
|
if (docStr === value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch("change", docStr)
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
return complete
|
return complete
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -249,8 +283,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: editorHeight = typeof height === "number" ? `${height}px` : height
|
|
||||||
|
|
||||||
// Init when all elements are ready
|
// Init when all elements are ready
|
||||||
$: if (mounted && !isEditorInitialised) {
|
$: if (mounted && !isEditorInitialised) {
|
||||||
isEditorInitialised = true
|
isEditorInitialised = true
|
||||||
|
@ -265,14 +297,7 @@
|
||||||
|
|
||||||
// Issue theme compartment update
|
// Issue theme compartment update
|
||||||
editor.dispatch({
|
editor.dispatch({
|
||||||
effects: themeConfig.reconfigure([
|
effects: themeConfig.reconfigure([...(isDark ? [oneDark] : [])]),
|
||||||
getDefaultTheme({
|
|
||||||
height: editorHeight,
|
|
||||||
resize,
|
|
||||||
dark: isDark,
|
|
||||||
}),
|
|
||||||
...(isDark ? [oneDark] : []),
|
|
||||||
]),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -298,27 +323,207 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.code-editor.handlebars :global(.cm-content) {
|
/* Editor */
|
||||||
font-family: var(--font-sans);
|
.code-editor {
|
||||||
|
font-size: 12px;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
.code-editor :global(.cm-tooltip.cm-completionInfo) {
|
.code-editor :global(.cm-editor) {
|
||||||
padding: var(--spacing-m);
|
height: 100%;
|
||||||
|
background: var(--spectrum-global-color-gray-50) !important;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
.code-editor :global(.cm-tooltip-autocomplete > ul > li[aria-selected]) {
|
.code-editor :global(.cm-content) {
|
||||||
border-radius: var(
|
padding: 10px 0;
|
||||||
--spectrum-popover-border-radius,
|
}
|
||||||
var(--spectrum-alias-border-radius-regular)
|
.code-editor > div {
|
||||||
),
|
height: 100%;
|
||||||
var(
|
|
||||||
--spectrum-popover-border-radius,
|
|
||||||
var(--spectrum-alias-border-radius-regular)
|
|
||||||
),
|
|
||||||
0, 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Active line */
|
||||||
|
.code-editor :global(.cm-line) {
|
||||||
|
padding: 0 var(--spacing-s);
|
||||||
|
color: var(--spectrum-alias-text-color);
|
||||||
|
}
|
||||||
|
.code-editor :global(.cm-activeLine) {
|
||||||
|
position: relative;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.code-editor :global(.cm-activeLine::before) {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 1px;
|
||||||
|
height: calc(100% - 2px);
|
||||||
|
width: 100%;
|
||||||
|
background: var(--spectrum-global-color-gray-100) !important;
|
||||||
|
z-index: -2;
|
||||||
|
}
|
||||||
|
.code-editor :global(.cm-highlightSpace:before) {
|
||||||
|
color: var(--spectrum-global-color-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code selection */
|
||||||
|
.code-editor :global(.cm-selectionBackground) {
|
||||||
|
background-color: var(--spectrum-global-color-blue-400) !important;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gutters */
|
||||||
|
.code-editor :global(.cm-gutterElement) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.code-editor :global(.cm-gutters) {
|
||||||
|
background-color: var(--spectrum-global-color-gray-75) !important;
|
||||||
|
color: var(--spectrum-global-color-gray-500);
|
||||||
|
}
|
||||||
|
.code-editor :global(.cm-activeLineGutter::before) {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 1px;
|
||||||
|
height: calc(100% - 2px);
|
||||||
|
width: 100%;
|
||||||
|
background: var(--spectrum-global-color-gray-200) !important;
|
||||||
|
z-index: -2;
|
||||||
|
}
|
||||||
|
.code-editor :global(.cm-activeLineGutter) {
|
||||||
|
color: var(--spectrum-global-color-gray-700);
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor color */
|
||||||
|
.code-editor :global(.cm-focused .cm-cursor) {
|
||||||
|
border-left-color: var(--spectrum-alias-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.code-editor :global(.cm-placeholder) {
|
||||||
|
color: var(--spectrum-global-color-gray-700);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight bindings and snippets */
|
||||||
|
.code-editor :global(.binding-wrap) {
|
||||||
|
color: var(--spectrum-global-color-blue-700) !important;
|
||||||
|
}
|
||||||
|
.code-editor :global(.snippet-wrap *) {
|
||||||
|
color: #61afef !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Completion popover */
|
||||||
|
.code-editor :global(.cm-tooltip-autocomplete) {
|
||||||
|
background: var(--spectrum-global-color-gray-75);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--spectrum-global-color-gray-200);
|
||||||
|
}
|
||||||
|
.code-editor :global(.cm-tooltip-autocomplete > ul) {
|
||||||
|
max-height: 20em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Completion section header*/
|
||||||
|
.code-editor :global(.info-section) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-m);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
color: var(--spectrum-alias-text-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.code-editor :global(.info-section:not(:first-of-type)) {
|
||||||
|
border-top: 1px solid var(--spectrum-global-color-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Completion item container */
|
||||||
|
.code-editor :global(.autocomplete-option),
|
||||||
|
.code-editor :global(.autocomplete-option-simple) {
|
||||||
|
padding: var(--spacing-s) var(--spacing-m) !important;
|
||||||
|
padding-left: calc(16px + 2 * var(--spacing-m)) !important;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
align-items: center;
|
||||||
|
color: var(--spectrum-alias-text-color);
|
||||||
|
}
|
||||||
|
.code-editor :global(.autocomplete-option-simple) {
|
||||||
|
padding-left: var(--spacing-s) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlighted completion item */
|
||||||
|
.code-editor :global(.autocomplete-option[aria-selected]),
|
||||||
|
.code-editor :global(.autocomplete-option-simple[aria-selected]) {
|
||||||
|
background: var(--spectrum-global-color-blue-400);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.code-editor
|
||||||
|
:global(.autocomplete-option[aria-selected] .cm-completionDetail) {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Completion item label */
|
||||||
|
.code-editor :global(.cm-completionLabel) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.code-editor :global(.autocomplete-option-simple .cm-completionLabel) {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Completion item type */
|
||||||
.code-editor :global(.autocomplete-option .cm-completionDetail) {
|
.code-editor :global(.autocomplete-option .cm-completionDetail) {
|
||||||
background-color: var(--spectrum-global-color-gray-200);
|
font-family: var(--font-mono);
|
||||||
|
color: var(--spectrum-global-color-gray-700);
|
||||||
|
font-style: normal;
|
||||||
|
text-transform: capitalize;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live binding value / helper container */
|
||||||
|
.code-editor :global(.cm-completionInfo) {
|
||||||
|
margin-left: var(--spacing-s);
|
||||||
|
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||||
border-radius: var(--border-radius-s);
|
border-radius: var(--border-radius-s);
|
||||||
padding: 4px 6px;
|
background-color: var(--spectrum-global-color-gray-50);
|
||||||
|
padding: var(--spacing-m);
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrapper around helpers */
|
||||||
|
.code-editor :global(.info-bubble) {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
color: var(--spectrum-global-color-gray-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live binding value / helper value */
|
||||||
|
.code-editor :global(.binding__description) {
|
||||||
|
color: var(--spectrum-alias-text-color);
|
||||||
|
font-size: var(--font-size-m);
|
||||||
|
}
|
||||||
|
.code-editor :global(.binding__example) {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: pre;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 480px;
|
||||||
|
}
|
||||||
|
.code-editor :global(.binding__example.helper) {
|
||||||
|
color: var(--spectrum-global-color-blue-700);
|
||||||
|
}
|
||||||
|
.code-editor :global(.binding__example span) {
|
||||||
|
overflow: hidden !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { EditorView } from "@codemirror/view"
|
|
||||||
import { getManifest } from "@budibase/string-templates"
|
import { getManifest } from "@budibase/string-templates"
|
||||||
import sanitizeHtml from "sanitize-html"
|
import sanitizeHtml from "sanitize-html"
|
||||||
import { groupBy } from "lodash"
|
import { groupBy } from "lodash"
|
||||||
|
@ -27,123 +26,33 @@ export const SECTIONS = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDefaultTheme = opts => {
|
|
||||||
const { height, resize, dark } = opts
|
|
||||||
return EditorView.theme(
|
|
||||||
{
|
|
||||||
"&.cm-focused .cm-cursor": {
|
|
||||||
borderLeftColor: "var(--spectrum-alias-text-color)",
|
|
||||||
},
|
|
||||||
"&": {
|
|
||||||
height: height ? `${height}` : "",
|
|
||||||
lineHeight: "1.3",
|
|
||||||
border:
|
|
||||||
"var(--spectrum-alias-border-size-thin) solid var(--spectrum-alias-border-color)",
|
|
||||||
borderRadius: "var(--border-radius-s)",
|
|
||||||
backgroundColor:
|
|
||||||
"var( --spectrum-textfield-m-background-color, var(--spectrum-global-color-gray-50) )",
|
|
||||||
resize: resize ? `${resize}` : "",
|
|
||||||
overflow: "hidden",
|
|
||||||
color: "var(--spectrum-alias-text-color)",
|
|
||||||
},
|
|
||||||
"& .cm-tooltip.cm-tooltip-autocomplete > ul": {
|
|
||||||
fontFamily:
|
|
||||||
"var(--spectrum-alias-body-text-font-family, var(--spectrum-global-font-family-base))",
|
|
||||||
maxHeight: "16em",
|
|
||||||
},
|
|
||||||
"& .cm-placeholder": {
|
|
||||||
color: "var(--spectrum-alias-text-color)",
|
|
||||||
fontStyle: "italic",
|
|
||||||
},
|
|
||||||
"&.cm-focused": {
|
|
||||||
outline: "none",
|
|
||||||
borderColor: "var(--spectrum-alias-border-color-mouse-focus)",
|
|
||||||
},
|
|
||||||
// AUTO COMPLETE
|
|
||||||
"& .cm-completionDetail": {
|
|
||||||
fontStyle: "unset",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
fontSize: "10px",
|
|
||||||
backgroundColor: "var(--spectrum-global-color-gray-100)",
|
|
||||||
color: "var(--spectrum-global-color-gray-600)",
|
|
||||||
},
|
|
||||||
"& .cm-completionLabel": {
|
|
||||||
marginLeft:
|
|
||||||
"calc(var(--spectrum-alias-workflow-icon-size-m) + var(--spacing-m))",
|
|
||||||
},
|
|
||||||
"& .info-bubble": {
|
|
||||||
fontSize: "var(--font-size-s)",
|
|
||||||
display: "grid",
|
|
||||||
gridGap: "var(--spacing-s)",
|
|
||||||
gridTemplateColumns: "1fr",
|
|
||||||
color: "var(--spectrum-global-color-gray-800)",
|
|
||||||
},
|
|
||||||
"& .cm-tooltip": {
|
|
||||||
marginLeft: "var(--spacing-s)",
|
|
||||||
border: "1px solid var(--spectrum-global-color-gray-300)",
|
|
||||||
borderRadius:
|
|
||||||
"var( --spectrum-popover-border-radius, var(--spectrum-alias-border-radius-regular) )",
|
|
||||||
backgroundColor: "var(--spectrum-global-color-gray-50)",
|
|
||||||
},
|
|
||||||
// Section header
|
|
||||||
"& .info-section": {
|
|
||||||
display: "flex",
|
|
||||||
padding: "var(--spacing-s)",
|
|
||||||
gap: "var(--spacing-m)",
|
|
||||||
borderBottom: "1px solid var(--spectrum-global-color-gray-200)",
|
|
||||||
color: "var(--spectrum-global-color-gray-800)",
|
|
||||||
fontWeight: "bold",
|
|
||||||
},
|
|
||||||
"& .info-section .spectrum-Icon": {
|
|
||||||
color: "var(--spectrum-global-color-gray-600)",
|
|
||||||
},
|
|
||||||
// Autocomplete Option
|
|
||||||
"& .cm-tooltip.cm-tooltip-autocomplete .autocomplete-option": {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
fontSize: "var(--spectrum-alias-font-size-default)",
|
|
||||||
padding: "var(--spacing-s)",
|
|
||||||
color: "var(--spectrum-global-color-gray-800)",
|
|
||||||
},
|
|
||||||
"& .cm-tooltip-autocomplete ul li[aria-selected].autocomplete-option": {
|
|
||||||
backgroundColor: "var(--spectrum-global-color-gray-200)",
|
|
||||||
},
|
|
||||||
"& .binding-wrap": {
|
|
||||||
color: "var(--spectrum-global-color-blue-700)",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ dark }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const buildHelperInfoNode = (completion, helper) => {
|
export const buildHelperInfoNode = (completion, helper) => {
|
||||||
const ele = document.createElement("div")
|
const ele = document.createElement("div")
|
||||||
ele.classList.add("info-bubble")
|
ele.classList.add("info-bubble")
|
||||||
|
|
||||||
const exampleNodeHtml = helper.example
|
const exampleNodeHtml = helper.example
|
||||||
? `<div class="binding__example">${helper.example}</div>`
|
? `<div class="binding__example helper">${helper.example}</div>`
|
||||||
: ""
|
: ""
|
||||||
const descriptionMarkup = sanitizeHtml(helper.description, {
|
const descriptionMarkup = sanitizeHtml(helper.description, {
|
||||||
allowedTags: [],
|
allowedTags: [],
|
||||||
allowedAttributes: {},
|
allowedAttributes: {},
|
||||||
})
|
})
|
||||||
const descriptionNodeHtml = `<div class="binding__description">${descriptionMarkup}</div>`
|
const descriptionNodeHtml = `<div class="binding__description helper">${descriptionMarkup}</div>`
|
||||||
|
|
||||||
ele.innerHTML = `
|
ele.innerHTML = `
|
||||||
${exampleNodeHtml}
|
|
||||||
${descriptionNodeHtml}
|
${descriptionNodeHtml}
|
||||||
|
${exampleNodeHtml}
|
||||||
`
|
`
|
||||||
return ele
|
return ele
|
||||||
}
|
}
|
||||||
|
|
||||||
const toSpectrumIcon = name => {
|
const toSpectrumIcon = name => {
|
||||||
return `<svg
|
return `<svg
|
||||||
class="spectrum-Icon spectrum-Icon--sizeM"
|
class="spectrum-Icon spectrum-Icon--sizeS"
|
||||||
focusable="false"
|
focusable="false"
|
||||||
aria-hidden="false"
|
aria-hidden="false"
|
||||||
aria-label="${name}-section-icon"
|
aria-label="${name}-section-icon"
|
||||||
|
style="color:var(--spectrum-global-color-gray-700)"
|
||||||
>
|
>
|
||||||
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-${name}" />
|
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-${name}" />
|
||||||
</svg>`
|
</svg>`
|
||||||
|
@ -152,7 +61,9 @@ const toSpectrumIcon = name => {
|
||||||
export const buildSectionHeader = (type, sectionName, icon, rank) => {
|
export const buildSectionHeader = (type, sectionName, icon, rank) => {
|
||||||
const ele = document.createElement("div")
|
const ele = document.createElement("div")
|
||||||
ele.classList.add("info-section")
|
ele.classList.add("info-section")
|
||||||
ele.classList.add(type)
|
if (type) {
|
||||||
|
ele.classList.add(type)
|
||||||
|
}
|
||||||
ele.innerHTML = `${toSpectrumIcon(icon)}<span>${sectionName}</span>`
|
ele.innerHTML = `${toSpectrumIcon(icon)}<span>${sectionName}</span>`
|
||||||
return {
|
return {
|
||||||
name: sectionName,
|
name: sectionName,
|
||||||
|
@ -174,7 +85,7 @@ export const helpersToCompletion = (helpers, mode) => {
|
||||||
},
|
},
|
||||||
type: "helper",
|
type: "helper",
|
||||||
section: helperSection,
|
section: helperSection,
|
||||||
detail: "FUNCTION",
|
detail: "Function",
|
||||||
apply: (view, completion, from, to) => {
|
apply: (view, completion, from, to) => {
|
||||||
insertBinding(view, from, to, key, mode)
|
insertBinding(view, from, to, key, mode)
|
||||||
},
|
},
|
||||||
|
@ -191,6 +102,29 @@ export const getHelperCompletions = mode => {
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const snippetAutoComplete = snippets => {
|
||||||
|
return function myCompletions(context) {
|
||||||
|
if (!snippets?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const word = context.matchBefore(/\w*/)
|
||||||
|
if (word.from == word.to && !context.explicit) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
from: word.from,
|
||||||
|
options: snippets.map(snippet => ({
|
||||||
|
label: `snippets.${snippet.name}`,
|
||||||
|
type: "text",
|
||||||
|
simple: true,
|
||||||
|
apply: (view, completion, from, to) => {
|
||||||
|
insertSnippet(view, from, to, completion.label)
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bindingFilter = (options, query) => {
|
const bindingFilter = (options, query) => {
|
||||||
return options.filter(completion => {
|
return options.filter(completion => {
|
||||||
const section_parsed = completion.section.name.toLowerCase()
|
const section_parsed = completion.section.name.toLowerCase()
|
||||||
|
@ -252,21 +186,12 @@ export const jsAutocomplete = baseCompletions => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildBindingInfoNode = (completion, binding) => {
|
export const buildBindingInfoNode = (completion, binding) => {
|
||||||
|
if (!binding.valueHTML || binding.value == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const ele = document.createElement("div")
|
const ele = document.createElement("div")
|
||||||
ele.classList.add("info-bubble")
|
ele.classList.add("info-bubble")
|
||||||
|
ele.innerHTML = `<div class="binding__example">${binding.valueHTML}</div>`
|
||||||
const exampleNodeHtml = binding.readableBinding
|
|
||||||
? `<div class="binding__example">{{ ${binding.readableBinding} }}</div>`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
const descriptionNodeHtml = binding.description
|
|
||||||
? `<div class="binding__description">${binding.description}</div>`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
ele.innerHTML = `
|
|
||||||
${exampleNodeHtml}
|
|
||||||
${descriptionNodeHtml}
|
|
||||||
`
|
|
||||||
return ele
|
return ele
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -345,6 +270,20 @@ export const insertBinding = (view, from, to, text, mode) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const insertSnippet = (view, from, to, text) => {
|
||||||
|
let cursorPos = from + text.length
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
insert: text,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
anchor: cursorPos,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const bindingsToCompletions = (bindings, mode) => {
|
export const bindingsToCompletions = (bindings, mode) => {
|
||||||
const bindingByCategory = groupBy(bindings, "category")
|
const bindingByCategory = groupBy(bindings, "category")
|
||||||
const categoryMeta = bindings?.reduce((acc, ele) => {
|
const categoryMeta = bindings?.reduce((acc, ele) => {
|
||||||
|
@ -374,7 +313,7 @@ export const bindingsToCompletions = (bindings, mode) => {
|
||||||
...bindingByCategory[catKey].reduce((acc, binding) => {
|
...bindingByCategory[catKey].reduce((acc, binding) => {
|
||||||
let displayType = binding.fieldSchema?.type || binding.display?.type
|
let displayType = binding.fieldSchema?.type || binding.display?.type
|
||||||
acc.push({
|
acc.push({
|
||||||
label: binding.display?.name || "NO NAME",
|
label: binding.display?.name || binding.readableBinding || "NO NAME",
|
||||||
info: completion => {
|
info: completion => {
|
||||||
return buildBindingInfoNode(completion, binding)
|
return buildBindingInfoNode(completion, binding)
|
||||||
},
|
},
|
||||||
|
|
|
@ -59,7 +59,7 @@
|
||||||
class="searchButton"
|
class="searchButton"
|
||||||
class:hide={search}
|
class:hide={search}
|
||||||
>
|
>
|
||||||
<Icon size="S" name="Search" />
|
<Icon size="S" name="Search" hoverable hoverColor="var(--ink)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -68,7 +68,7 @@
|
||||||
class="addButton"
|
class="addButton"
|
||||||
class:rotate={search}
|
class:rotate={search}
|
||||||
>
|
>
|
||||||
<Icon name="Add" />
|
<Icon name="Add" hoverable hoverColor="var(--ink)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
export let iconTooltip
|
export let iconTooltip
|
||||||
export let withArrow = false
|
export let withArrow = false
|
||||||
export let withActions = true
|
export let withActions = true
|
||||||
|
export let showActions = false
|
||||||
export let indentLevel = 0
|
export let indentLevel = 0
|
||||||
export let text
|
export let text
|
||||||
export let border = true
|
export let border = true
|
||||||
|
@ -68,6 +69,8 @@
|
||||||
class:border
|
class:border
|
||||||
class:selected
|
class:selected
|
||||||
class:withActions
|
class:withActions
|
||||||
|
class:showActions
|
||||||
|
class:actionsOpen={highlighted && withActions}
|
||||||
class:scrollable
|
class:scrollable
|
||||||
class:highlighted
|
class:highlighted
|
||||||
class:selectedBy
|
class:selectedBy
|
||||||
|
@ -168,8 +171,10 @@
|
||||||
--avatars-background: var(--spectrum-global-color-gray-300);
|
--avatars-background: var(--spectrum-global-color-gray-300);
|
||||||
}
|
}
|
||||||
.nav-item:hover .actions,
|
.nav-item:hover .actions,
|
||||||
.hovering .actions {
|
.hovering .actions,
|
||||||
visibility: visible;
|
.nav-item.withActions.actionsOpen .actions,
|
||||||
|
.nav-item.withActions.showActions .actions {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.nav-item-content {
|
.nav-item-content {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
@ -272,7 +277,6 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
visibility: hidden;
|
|
||||||
order: 3;
|
order: 3;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
|
|
@ -1,74 +1,194 @@
|
||||||
<script>
|
<script>
|
||||||
import {
|
import {
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
Tabs,
|
ActionButton,
|
||||||
Tab,
|
Icon,
|
||||||
|
Heading,
|
||||||
Body,
|
Body,
|
||||||
Button,
|
Button,
|
||||||
ActionButton,
|
|
||||||
Heading,
|
|
||||||
Icon,
|
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { createEventDispatcher, onMount, getContext } from "svelte"
|
import { createEventDispatcher, onMount } from "svelte"
|
||||||
import {
|
import {
|
||||||
isValid,
|
|
||||||
decodeJSBinding,
|
decodeJSBinding,
|
||||||
encodeJSBinding,
|
encodeJSBinding,
|
||||||
convertToJS,
|
processStringSync,
|
||||||
} from "@budibase/string-templates"
|
} from "@budibase/string-templates"
|
||||||
import {
|
import { readableToRuntimeBinding } from "dataBinding"
|
||||||
readableToRuntimeBinding,
|
|
||||||
runtimeToReadableBinding,
|
|
||||||
} from "dataBinding"
|
|
||||||
|
|
||||||
import { admin } from "stores/portal"
|
|
||||||
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
|
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
|
||||||
import {
|
import {
|
||||||
getHelperCompletions,
|
getHelperCompletions,
|
||||||
jsAutocomplete,
|
jsAutocomplete,
|
||||||
hbAutocomplete,
|
hbAutocomplete,
|
||||||
|
snippetAutoComplete,
|
||||||
EditorModes,
|
EditorModes,
|
||||||
bindingsToCompletions,
|
bindingsToCompletions,
|
||||||
} from "../CodeEditor"
|
} from "../CodeEditor"
|
||||||
import BindingPicker from "./BindingPicker.svelte"
|
import BindingSidePanel from "./BindingSidePanel.svelte"
|
||||||
|
import EvaluationSidePanel from "./EvaluationSidePanel.svelte"
|
||||||
|
import SnippetSidePanel from "./SnippetSidePanel.svelte"
|
||||||
import { BindingHelpers } from "./utils"
|
import { BindingHelpers } from "./utils"
|
||||||
|
import formatHighlight from "json-format-highlight"
|
||||||
|
import { capitalise } from "helpers"
|
||||||
|
import { Utils } from "@budibase/frontend-core"
|
||||||
|
import { licensing } from "stores/portal"
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
export let bindings
|
export let bindings = []
|
||||||
// jsValue/hbsValue are the state of the value that is being built
|
|
||||||
// within this binding panel - the value should not be updated until
|
|
||||||
// the binding panel is saved. This is the default value of the
|
|
||||||
// expression when the binding panel is opened, but shouldn't be updated.
|
|
||||||
export let value = ""
|
export let value = ""
|
||||||
export let valid
|
export let allowHBS = true
|
||||||
export let allowJS = false
|
export let allowJS = false
|
||||||
export let allowHelpers = true
|
export let allowHelpers = true
|
||||||
|
export let allowSnippets = true
|
||||||
|
export let context = null
|
||||||
|
export let snippets = null
|
||||||
export let autofocusEditor = false
|
export let autofocusEditor = false
|
||||||
|
export let placeholder = null
|
||||||
|
export let showTabBar = true
|
||||||
|
|
||||||
const drawerActions = getContext("drawer-actions")
|
const Modes = {
|
||||||
const bindingDrawerActions = getContext("binding-drawer-actions")
|
Text: "Text",
|
||||||
|
JavaScript: "JavaScript",
|
||||||
|
}
|
||||||
|
const SidePanels = {
|
||||||
|
Bindings: "FlashOn",
|
||||||
|
Evaluation: "Play",
|
||||||
|
Snippets: "Code",
|
||||||
|
}
|
||||||
|
|
||||||
let getCaretPosition
|
let mode
|
||||||
let insertAtPos
|
let sidePanel
|
||||||
let initialValueJS = typeof value === "string" && value?.startsWith("{{ js ")
|
let initialValueJS = value?.startsWith?.("{{ js ")
|
||||||
let mode = initialValueJS ? "JavaScript" : "Text"
|
|
||||||
let jsValue = initialValueJS ? value : null
|
let jsValue = initialValueJS ? value : null
|
||||||
let hbsValue = initialValueJS ? null : value
|
let hbsValue = initialValueJS ? null : value
|
||||||
let sidebar = true
|
let getCaretPosition
|
||||||
|
let insertAtPos
|
||||||
let targetMode = null
|
let targetMode = null
|
||||||
|
let expressionResult
|
||||||
|
let evaluating = false
|
||||||
|
|
||||||
$: usingJS = mode === "JavaScript"
|
$: useSnippets = allowSnippets && !$licensing.isFreePlan
|
||||||
|
$: editorModeOptions = getModeOptions(allowHBS, allowJS)
|
||||||
|
$: sidePanelOptions = getSidePanelOptions(
|
||||||
|
bindings,
|
||||||
|
context,
|
||||||
|
allowSnippets,
|
||||||
|
mode
|
||||||
|
)
|
||||||
|
$: enrichedBindings = enrichBindings(bindings, context, snippets)
|
||||||
|
$: usingJS = mode === Modes.JavaScript
|
||||||
$: editorMode =
|
$: editorMode =
|
||||||
mode === "JavaScript" ? EditorModes.JS : EditorModes.Handlebars
|
mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars
|
||||||
$: bindingCompletions = bindingsToCompletions(bindings, editorMode)
|
$: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue
|
||||||
|
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
|
||||||
|
$: requestEval(runtimeExpression, context, snippets)
|
||||||
|
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
|
||||||
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos)
|
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos)
|
||||||
|
$: hbsCompletions = getHBSCompletions(bindingCompletions)
|
||||||
|
$: jsCompletions = getJSCompletions(bindingCompletions, snippets, useSnippets)
|
||||||
|
$: {
|
||||||
|
// Ensure a valid side panel option is always selected
|
||||||
|
if (sidePanel && !sidePanelOptions.includes(sidePanel)) {
|
||||||
|
sidePanel = sidePanelOptions[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getHBSCompletions = bindingCompletions => {
|
||||||
|
return [
|
||||||
|
hbAutocomplete([
|
||||||
|
...bindingCompletions,
|
||||||
|
...getHelperCompletions(EditorModes.Handlebars),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getJSCompletions = (bindingCompletions, snippets, useSnippets) => {
|
||||||
|
const completions = [
|
||||||
|
jsAutocomplete([
|
||||||
|
...bindingCompletions,
|
||||||
|
...getHelperCompletions(EditorModes.JS),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
if (useSnippets) {
|
||||||
|
completions.push(snippetAutoComplete(snippets))
|
||||||
|
}
|
||||||
|
return completions
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModeOptions = (allowHBS, allowJS) => {
|
||||||
|
let options = []
|
||||||
|
if (allowHBS) {
|
||||||
|
options.push(Modes.Text)
|
||||||
|
}
|
||||||
|
if (allowJS) {
|
||||||
|
options.push(Modes.JavaScript)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSidePanelOptions = (bindings, context, useSnippets, mode) => {
|
||||||
|
let options = []
|
||||||
|
if (bindings?.length) {
|
||||||
|
options.push(SidePanels.Bindings)
|
||||||
|
}
|
||||||
|
if (context) {
|
||||||
|
options.push(SidePanels.Evaluation)
|
||||||
|
}
|
||||||
|
if (useSnippets && mode === Modes.JavaScript) {
|
||||||
|
options.push(SidePanels.Snippets)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedEval = Utils.debounce((expression, context, snippets) => {
|
||||||
|
expressionResult = processStringSync(expression || "", {
|
||||||
|
...context,
|
||||||
|
snippets,
|
||||||
|
})
|
||||||
|
evaluating = false
|
||||||
|
}, 260)
|
||||||
|
|
||||||
|
const requestEval = (expression, context, snippets) => {
|
||||||
|
evaluating = true
|
||||||
|
debouncedEval(expression, context, snippets)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBindingValue = (binding, context, snippets) => {
|
||||||
|
const js = `return $("${binding.runtimeBinding}")`
|
||||||
|
const hbs = encodeJSBinding(js)
|
||||||
|
const res = processStringSync(hbs, { ...context, snippets })
|
||||||
|
return JSON.stringify(res, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightJSON = json => {
|
||||||
|
return formatHighlight(json, {
|
||||||
|
keyColor: "#e06c75",
|
||||||
|
numberColor: "#e5c07b",
|
||||||
|
stringColor: "#98c379",
|
||||||
|
trueColor: "#d19a66",
|
||||||
|
falseColor: "#d19a66",
|
||||||
|
nullColor: "#c678dd",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichBindings = (bindings, context, snippets) => {
|
||||||
|
return bindings.map(binding => {
|
||||||
|
if (!context) {
|
||||||
|
return binding
|
||||||
|
}
|
||||||
|
const value = getBindingValue(binding, context, snippets)
|
||||||
|
return {
|
||||||
|
...binding,
|
||||||
|
value,
|
||||||
|
valueHTML: highlightJSON(value),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const updateValue = val => {
|
const updateValue = val => {
|
||||||
valid = isValid(readableToRuntimeBinding(bindings, val))
|
const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val)
|
||||||
if (valid) {
|
dispatch("change", val)
|
||||||
dispatch("change", val)
|
requestEval(runtimeExpression, context, snippets)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSelectHelper = (helper, js) => {
|
const onSelectHelper = (helper, js) => {
|
||||||
|
@ -80,9 +200,34 @@
|
||||||
bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js })
|
bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onChangeMode = e => {
|
const changeMode = newMode => {
|
||||||
mode = e.detail
|
if (targetMode || newMode === mode) {
|
||||||
updateValue(mode === "JavaScript" ? jsValue : hbsValue)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the raw editor value to see if we are abandoning changes
|
||||||
|
let rawValue = editorValue
|
||||||
|
if (mode === Modes.JavaScript) {
|
||||||
|
rawValue = decodeJSBinding(rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawValue?.length) {
|
||||||
|
targetMode = newMode
|
||||||
|
} else {
|
||||||
|
mode = newMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmChangeMode = () => {
|
||||||
|
jsValue = null
|
||||||
|
hbsValue = null
|
||||||
|
updateValue(null)
|
||||||
|
mode = targetMode
|
||||||
|
targetMode = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeSidePanel = newSidePanel => {
|
||||||
|
sidePanel = newSidePanel === sidePanel ? null : newSidePanel
|
||||||
}
|
}
|
||||||
|
|
||||||
const onChangeHBSValue = e => {
|
const onChangeHBSValue = e => {
|
||||||
|
@ -95,374 +240,188 @@
|
||||||
updateValue(jsValue)
|
updateValue(jsValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const switchMode = () => {
|
|
||||||
if (targetMode == "Text") {
|
|
||||||
jsValue = null
|
|
||||||
updateValue(jsValue)
|
|
||||||
} else {
|
|
||||||
hbsValue = null
|
|
||||||
updateValue(hbsValue)
|
|
||||||
}
|
|
||||||
mode = targetMode + ""
|
|
||||||
targetMode = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const convert = () => {
|
|
||||||
const runtime = readableToRuntimeBinding(bindings, hbsValue)
|
|
||||||
const runtimeJs = encodeJSBinding(convertToJS(runtime))
|
|
||||||
jsValue = runtimeToReadableBinding(bindings, runtimeJs)
|
|
||||||
hbsValue = null
|
|
||||||
mode = "JavaScript"
|
|
||||||
onSelectBinding("", { forceJS: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
valid = isValid(readableToRuntimeBinding(bindings, value))
|
// Set the initial mode appropriately
|
||||||
|
const initialValueMode = initialValueJS ? Modes.JavaScript : Modes.Text
|
||||||
|
if (editorModeOptions.includes(initialValueMode)) {
|
||||||
|
mode = initialValueMode
|
||||||
|
} else {
|
||||||
|
mode = editorModeOptions[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the initial side panel
|
||||||
|
sidePanel = sidePanelOptions[0]
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class="binding-drawer">
|
<DrawerContent padding={false}>
|
||||||
<DrawerContent>
|
<div class="binding-panel">
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<Tabs
|
{#if showTabBar}
|
||||||
selected={mode}
|
<div class="tabs">
|
||||||
on:select={onChangeMode}
|
<div class="editor-tabs">
|
||||||
beforeSwitch={selectedMode => {
|
{#each editorModeOptions as editorMode}
|
||||||
if (selectedMode == mode) {
|
<ActionButton
|
||||||
return true
|
size="M"
|
||||||
}
|
quiet
|
||||||
|
selected={mode === editorMode}
|
||||||
//Get the current mode value
|
on:click={() => changeMode(editorMode)}
|
||||||
const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue
|
>
|
||||||
|
{capitalise(editorMode)}
|
||||||
if (editorValue) {
|
</ActionButton>
|
||||||
targetMode = selectedMode
|
{/each}
|
||||||
return false
|
</div>
|
||||||
}
|
<div class="side-tabs">
|
||||||
return true
|
{#each sidePanelOptions as panel}
|
||||||
}}
|
<ActionButton
|
||||||
>
|
size="M"
|
||||||
<Tab title="Text">
|
quiet
|
||||||
<div class="main-content" class:binding-panel={sidebar}>
|
selected={sidePanel === panel}
|
||||||
<div class="editor">
|
on:click={() => changeSidePanel(panel)}
|
||||||
<div class="overlay-wrap">
|
>
|
||||||
{#if targetMode}
|
<Icon name={panel} size="S" />
|
||||||
<div class="mode-overlay">
|
</ActionButton>
|
||||||
<div class="prompt-body">
|
{/each}
|
||||||
<Heading size="S">
|
|
||||||
{`Switch to ${targetMode}?`}
|
|
||||||
</Heading>
|
|
||||||
<Body>This will discard anything in your binding</Body>
|
|
||||||
<div class="switch-actions">
|
|
||||||
<Button
|
|
||||||
secondary
|
|
||||||
size="S"
|
|
||||||
on:click={() => {
|
|
||||||
targetMode = null
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No - keep text
|
|
||||||
</Button>
|
|
||||||
<Button cta size="S" on:click={switchMode}>
|
|
||||||
Yes - discard text
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<CodeEditor
|
|
||||||
value={hbsValue}
|
|
||||||
on:change={onChangeHBSValue}
|
|
||||||
bind:getCaretPosition
|
|
||||||
bind:insertAtPos
|
|
||||||
completions={[
|
|
||||||
hbAutocomplete([
|
|
||||||
...bindingCompletions,
|
|
||||||
...getHelperCompletions(editorMode),
|
|
||||||
]),
|
|
||||||
]}
|
|
||||||
placeholder=""
|
|
||||||
height="100%"
|
|
||||||
autofocus={autofocusEditor}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="binding-footer">
|
|
||||||
<div class="messaging">
|
|
||||||
{#if !valid}
|
|
||||||
<div class="syntax-error">
|
|
||||||
Current Handlebars syntax is invalid, please check the
|
|
||||||
guide
|
|
||||||
<a href="https://handlebarsjs.com/guide/" target="_blank"
|
|
||||||
>here</a
|
|
||||||
>
|
|
||||||
for more details.
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Icon name="FlashOn" />
|
|
||||||
<div class="messaging-wrap">
|
|
||||||
<div>
|
|
||||||
Add available bindings by typing {{ or use the
|
|
||||||
menu on the right
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
{#if $admin.isDev && allowJS}
|
|
||||||
<ActionButton
|
|
||||||
secondary
|
|
||||||
on:click={() => {
|
|
||||||
convert()
|
|
||||||
targetMode = null
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Convert To JS
|
|
||||||
</ActionButton>
|
|
||||||
{/if}
|
|
||||||
<ActionButton
|
|
||||||
secondary
|
|
||||||
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
|
|
||||||
on:click={() => {
|
|
||||||
sidebar = !sidebar
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if sidebar}
|
|
||||||
<div class="binding-picker">
|
|
||||||
<BindingPicker
|
|
||||||
{bindings}
|
|
||||||
{allowHelpers}
|
|
||||||
addHelper={onSelectHelper}
|
|
||||||
addBinding={onSelectBinding}
|
|
||||||
mode={editorMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</Tab>
|
|
||||||
{#if allowJS}
|
|
||||||
<Tab title="JavaScript">
|
|
||||||
<div class="main-content" class:binding-panel={sidebar}>
|
|
||||||
<div class="editor">
|
|
||||||
<div class="overlay-wrap">
|
|
||||||
{#if targetMode}
|
|
||||||
<div class="mode-overlay">
|
|
||||||
<div class="prompt-body">
|
|
||||||
<Heading size="S">
|
|
||||||
{`Switch to ${targetMode}?`}
|
|
||||||
</Heading>
|
|
||||||
<Body>This will discard anything in your binding</Body>
|
|
||||||
<div class="switch-actions">
|
|
||||||
<Button
|
|
||||||
secondary
|
|
||||||
size="S"
|
|
||||||
on:click={() => {
|
|
||||||
targetMode = null
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No - keep javascript
|
|
||||||
</Button>
|
|
||||||
<Button cta size="S" on:click={switchMode}>
|
|
||||||
Yes - discard javascript
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<CodeEditor
|
|
||||||
value={decodeJSBinding(jsValue)}
|
|
||||||
on:change={onChangeJSValue}
|
|
||||||
completions={[
|
|
||||||
jsAutocomplete([
|
|
||||||
...bindingCompletions,
|
|
||||||
...getHelperCompletions(editorMode),
|
|
||||||
]),
|
|
||||||
]}
|
|
||||||
mode={EditorModes.JS}
|
|
||||||
bind:getCaretPosition
|
|
||||||
bind:insertAtPos
|
|
||||||
height="100%"
|
|
||||||
autofocus={autofocusEditor}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="binding-footer">
|
|
||||||
<div class="messaging">
|
|
||||||
<Icon name="FlashOn" />
|
|
||||||
<div class="messaging-wrap">
|
|
||||||
<div>
|
|
||||||
Add available bindings by typing $ or use the menu on
|
|
||||||
the right
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<ActionButton
|
|
||||||
secondary
|
|
||||||
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
|
|
||||||
on:click={() => {
|
|
||||||
sidebar = !sidebar
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if sidebar}
|
|
||||||
<div class="binding-picker">
|
|
||||||
<BindingPicker
|
|
||||||
{bindings}
|
|
||||||
{allowHelpers}
|
|
||||||
addHelper={onSelectHelper}
|
|
||||||
addBinding={onSelectBinding}
|
|
||||||
mode={editorMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Tab>
|
|
||||||
{/if}
|
|
||||||
<div class="drawer-actions">
|
|
||||||
{#if typeof drawerActions?.hide === "function" && drawerActions?.headless}
|
|
||||||
<Button
|
|
||||||
secondary
|
|
||||||
quiet
|
|
||||||
on:click={() => {
|
|
||||||
drawerActions.hide()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{#if typeof bindingDrawerActions?.save === "function" && drawerActions?.headless}
|
|
||||||
<Button
|
|
||||||
cta
|
|
||||||
disabled={!valid}
|
|
||||||
on:click={() => {
|
|
||||||
bindingDrawerActions.save()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
{/if}
|
||||||
|
<div class="editor">
|
||||||
|
{#if mode === Modes.Text}
|
||||||
|
{#key hbsCompletions}
|
||||||
|
<CodeEditor
|
||||||
|
value={hbsValue}
|
||||||
|
on:change={onChangeHBSValue}
|
||||||
|
bind:getCaretPosition
|
||||||
|
bind:insertAtPos
|
||||||
|
completions={hbsCompletions}
|
||||||
|
autofocus={autofocusEditor}
|
||||||
|
placeholder={placeholder ||
|
||||||
|
"Add bindings by typing {{ or use the menu on the right"}
|
||||||
|
jsBindingWrapping={false}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{:else if mode === Modes.JavaScript}
|
||||||
|
{#key jsCompletions}
|
||||||
|
<CodeEditor
|
||||||
|
value={decodeJSBinding(jsValue)}
|
||||||
|
on:change={onChangeJSValue}
|
||||||
|
completions={jsCompletions}
|
||||||
|
mode={EditorModes.JS}
|
||||||
|
bind:getCaretPosition
|
||||||
|
bind:insertAtPos
|
||||||
|
autofocus={autofocusEditor}
|
||||||
|
placeholder={placeholder ||
|
||||||
|
"Add bindings by typing $ or use the menu on the right"}
|
||||||
|
jsBindingWrapping
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
{#if targetMode}
|
||||||
|
<div class="mode-overlay">
|
||||||
|
<div class="prompt-body">
|
||||||
|
<Heading size="S">
|
||||||
|
Switch to {targetMode}?
|
||||||
|
</Heading>
|
||||||
|
<Body>This will discard anything in your binding</Body>
|
||||||
|
<div class="switch-actions">
|
||||||
|
<Button
|
||||||
|
secondary
|
||||||
|
size="S"
|
||||||
|
on:click={() => {
|
||||||
|
targetMode = null
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No - keep {mode}
|
||||||
|
</Button>
|
||||||
|
<Button cta size="S" on:click={confirmChangeMode}>
|
||||||
|
Yes - discard {mode}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
<div class="side" class:visible={!!sidePanel}>
|
||||||
</span>
|
{#if sidePanel === SidePanels.Bindings}
|
||||||
|
<BindingSidePanel
|
||||||
|
bindings={enrichedBindings}
|
||||||
|
{allowHelpers}
|
||||||
|
{context}
|
||||||
|
addHelper={onSelectHelper}
|
||||||
|
addBinding={onSelectBinding}
|
||||||
|
mode={editorMode}
|
||||||
|
/>
|
||||||
|
{:else if sidePanel === SidePanels.Evaluation}
|
||||||
|
<EvaluationSidePanel
|
||||||
|
{expressionResult}
|
||||||
|
{evaluating}
|
||||||
|
expression={editorValue}
|
||||||
|
/>
|
||||||
|
{:else if sidePanel === SidePanels.Snippets}
|
||||||
|
<SnippetSidePanel
|
||||||
|
addSnippet={snippet => bindingHelpers.onSelectSnippet(snippet)}
|
||||||
|
{snippets}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.binding-drawer :global(.container > .main) {
|
.binding-panel {
|
||||||
overflow: hidden;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binding-drawer :global(.container > .main > .main) {
|
|
||||||
overflow: hidden;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binding-drawer :global(.spectrum-Tabs-content) {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binding-drawer :global(.spectrum-Tabs-content > div),
|
|
||||||
.binding-drawer :global(.spectrum-Tabs-content > div > div),
|
|
||||||
.binding-drawer :global(.spectrum-Tabs-content .main-content) {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.binding-panel,
|
||||||
.binding-drawer .main-content {
|
.tabs {
|
||||||
grid-template-rows: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messaging {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-m);
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.messaging-wrap {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.messaging-wrap > div {
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.main :global(textarea) {
|
|
||||||
min-height: 202px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
padding: var(--spacing-s) var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main :global(.spectrum-Tabs div.drawer-actions) {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-m);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main :global(.spectrum-Tabs-content),
|
|
||||||
.main :global(.spectrum-Tabs-content .main-content) {
|
|
||||||
margin-top: 0px;
|
|
||||||
padding: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main :global(.spectrum-Tabs) {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.syntax-error {
|
|
||||||
color: var(--red);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.syntax-error a {
|
|
||||||
color: var(--red);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binding-footer {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.main-content {
|
.main {
|
||||||
display: grid;
|
flex: 1 1 auto;
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: 380px;
|
|
||||||
}
|
|
||||||
.main-content.binding-panel {
|
|
||||||
grid-template-columns: 1fr 320px;
|
|
||||||
}
|
|
||||||
.binding-picker {
|
|
||||||
border-left: 2px solid var(--border-light);
|
|
||||||
border-left: var(--border-light);
|
|
||||||
overflow: scroll;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.editor {
|
|
||||||
padding: var(--spacing-xl);
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-xl);
|
justify-content: flex-start;
|
||||||
overflow: hidden;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.overlay-wrap {
|
.side {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 360px;
|
||||||
|
margin-right: -360px;
|
||||||
|
transition: margin-right 130ms ease-out;
|
||||||
|
}
|
||||||
|
.side.visible {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
padding: var(--spacing-m);
|
||||||
|
border-bottom: var(--border-light);
|
||||||
|
}
|
||||||
|
.editor-tabs,
|
||||||
|
.side-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
}
|
||||||
|
.side-tabs :global(.icon) {
|
||||||
|
width: 16px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor */
|
||||||
|
.editor {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overlay */
|
||||||
.mode-overlay {
|
.mode-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -471,6 +430,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: var(
|
background-color: var(
|
||||||
|
@ -490,9 +450,4 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-l);
|
gap: var(--spacing-l);
|
||||||
}
|
}
|
||||||
|
|
||||||
.binding-drawer :global(.code-editor),
|
|
||||||
.binding-drawer :global(.code-editor > div) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,399 +0,0 @@
|
||||||
<script>
|
|
||||||
import groupBy from "lodash/fp/groupBy"
|
|
||||||
import { convertToJS } from "@budibase/string-templates"
|
|
||||||
import { Input, Layout, ActionButton, Icon, Popover } from "@budibase/bbui"
|
|
||||||
import { handlebarsCompletions } from "constants/completions"
|
|
||||||
|
|
||||||
export let addHelper
|
|
||||||
export let addBinding
|
|
||||||
export let bindings
|
|
||||||
export let mode
|
|
||||||
export let allowHelpers
|
|
||||||
export let noPaddingTop = false
|
|
||||||
|
|
||||||
let search = ""
|
|
||||||
let popover
|
|
||||||
let popoverAnchor
|
|
||||||
let hoverTarget
|
|
||||||
let helpers = handlebarsCompletions()
|
|
||||||
|
|
||||||
let selectedCategory
|
|
||||||
|
|
||||||
$: searchRgx = new RegExp(search, "ig")
|
|
||||||
|
|
||||||
// Icons
|
|
||||||
$: bindingIcons = bindings?.reduce((acc, ele) => {
|
|
||||||
if (ele.icon) {
|
|
||||||
acc[ele.category] = acc[ele.category] || ele.icon
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
|
|
||||||
|
|
||||||
$: categories = Object.entries(groupBy("category", bindings))
|
|
||||||
$: categoryNames = getCategoryNames(categories)
|
|
||||||
|
|
||||||
$: filteredCategories = categories
|
|
||||||
.map(([name, categoryBindings]) => ({
|
|
||||||
name,
|
|
||||||
bindings: categoryBindings?.filter(binding => {
|
|
||||||
return !search || binding.readableBinding.match(searchRgx)
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
.filter(category => {
|
|
||||||
return (
|
|
||||||
category.bindings?.length > 0 &&
|
|
||||||
(!selectedCategory ? true : selectedCategory === category.name)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
$: filteredHelpers = helpers?.filter(helper => {
|
|
||||||
return (
|
|
||||||
(!search ||
|
|
||||||
helper.label.match(searchRgx) ||
|
|
||||||
helper.description.match(searchRgx)) &&
|
|
||||||
(mode.name !== "javascript" || helper.allowsJs)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const getHelperExample = (helper, js) => {
|
|
||||||
let example = helper.example || ""
|
|
||||||
if (js) {
|
|
||||||
example = convertToJS(example).split("\n")[0].split("= ")[1]
|
|
||||||
if (example === "null;") {
|
|
||||||
example = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return example || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCategoryNames = categories => {
|
|
||||||
let names = [...categories.map(cat => cat[0])]
|
|
||||||
if (allowHelpers) {
|
|
||||||
names.push("Helpers")
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span class="detailPopover">
|
|
||||||
<Popover
|
|
||||||
align="left-outside"
|
|
||||||
bind:this={popover}
|
|
||||||
anchor={popoverAnchor}
|
|
||||||
maxWidth={300}
|
|
||||||
maxHeight={300}
|
|
||||||
dismissible={false}
|
|
||||||
>
|
|
||||||
<Layout gap="S">
|
|
||||||
<div class="helper">
|
|
||||||
{#if hoverTarget.title}
|
|
||||||
<div class="helper__name">{hoverTarget.title}</div>
|
|
||||||
{/if}
|
|
||||||
{#if hoverTarget.description}
|
|
||||||
<div class="helper__description">
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
|
||||||
{@html hoverTarget.description}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if hoverTarget.example}
|
|
||||||
<pre class="helper__example">{hoverTarget.example}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
</Popover>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
|
|
||||||
<Layout noPadding gap="S">
|
|
||||||
{#if selectedCategory}
|
|
||||||
<div class="sub-section-back">
|
|
||||||
<ActionButton
|
|
||||||
secondary
|
|
||||||
icon={"ArrowLeft"}
|
|
||||||
on:click={() => {
|
|
||||||
selectedCategory = null
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !selectedCategory}
|
|
||||||
<div class="search">
|
|
||||||
<span class="search-input">
|
|
||||||
<Input
|
|
||||||
placeholder={"Search for bindings"}
|
|
||||||
autocomplete="off"
|
|
||||||
bind:value={search}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="search-input-icon"
|
|
||||||
on:click={() => {
|
|
||||||
search = null
|
|
||||||
}}
|
|
||||||
class:searching={search}
|
|
||||||
>
|
|
||||||
<Icon name={search ? "Close" : "Search"} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !selectedCategory && !search}
|
|
||||||
<ul class="category-list">
|
|
||||||
{#each categoryNames as categoryName}
|
|
||||||
<li
|
|
||||||
on:click={() => {
|
|
||||||
selectedCategory = categoryName
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name={categoryIcons[categoryName]} />
|
|
||||||
<span class="category-name">{categoryName} </span>
|
|
||||||
<span class="category-chevron"><Icon name="ChevronRight" /></span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if selectedCategory || search}
|
|
||||||
{#each filteredCategories as category}
|
|
||||||
{#if category.bindings?.length}
|
|
||||||
<div class="sub-section">
|
|
||||||
<div class="cat-heading">
|
|
||||||
<Icon name={categoryIcons[category.name]} />{category.name}
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
{#each category.bindings as binding}
|
|
||||||
<li
|
|
||||||
class="binding"
|
|
||||||
on:mouseenter={e => {
|
|
||||||
popoverAnchor = e.target
|
|
||||||
if (!binding.description) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hoverTarget = {
|
|
||||||
title: binding.display?.name || binding.fieldSchema?.name,
|
|
||||||
description: binding.description,
|
|
||||||
}
|
|
||||||
popover.show()
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
on:mouseleave={() => {
|
|
||||||
popover.hide()
|
|
||||||
popoverAnchor = null
|
|
||||||
hoverTarget = null
|
|
||||||
}}
|
|
||||||
on:focus={() => {}}
|
|
||||||
on:blur={() => {}}
|
|
||||||
on:click={() => addBinding(binding)}
|
|
||||||
>
|
|
||||||
<span class="binding__label">
|
|
||||||
{#if binding.display?.name}
|
|
||||||
{binding.display.name}
|
|
||||||
{:else if binding.fieldSchema?.name}
|
|
||||||
{binding.fieldSchema?.name}
|
|
||||||
{:else}
|
|
||||||
{binding.readableBinding}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{#if binding.display?.type || binding.fieldSchema?.type}
|
|
||||||
<span class="binding__typeWrap">
|
|
||||||
<span class="binding__type">
|
|
||||||
{binding.display?.type || binding.fieldSchema?.type}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if selectedCategory === "Helpers" || search}
|
|
||||||
{#if filteredHelpers?.length}
|
|
||||||
<div class="sub-section">
|
|
||||||
<div class="cat-heading">Helpers</div>
|
|
||||||
<ul class="helpers">
|
|
||||||
{#each filteredHelpers as helper}
|
|
||||||
<li
|
|
||||||
class="binding"
|
|
||||||
on:click={() => addHelper(helper, mode.name == "javascript")}
|
|
||||||
on:mouseenter={e => {
|
|
||||||
popoverAnchor = e.target
|
|
||||||
if (!helper.displayText && helper.description) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hoverTarget = {
|
|
||||||
title: helper.displayText,
|
|
||||||
description: helper.description,
|
|
||||||
example: getHelperExample(
|
|
||||||
helper,
|
|
||||||
mode.name == "javascript"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
popover.show()
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
on:mouseleave={() => {
|
|
||||||
popover.hide()
|
|
||||||
popoverAnchor = null
|
|
||||||
hoverTarget = null
|
|
||||||
}}
|
|
||||||
on:focus={() => {}}
|
|
||||||
on:blur={() => {}}
|
|
||||||
>
|
|
||||||
<span class="binding__label">{helper.displayText}</span>
|
|
||||||
<span class="binding__typeWrap">
|
|
||||||
<span class="binding__type">function</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</Layout>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.search :global(input) {
|
|
||||||
border: none;
|
|
||||||
border-radius: 0px;
|
|
||||||
background: none;
|
|
||||||
padding: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
padding: var(--spacing-m) var(--spacing-l);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border-top: 0px;
|
|
||||||
border-bottom: var(--border-light);
|
|
||||||
border-left: 2px solid transparent;
|
|
||||||
border-right: 2px solid transparent;
|
|
||||||
margin-right: 1px;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background-color: var(--background);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input-icon.searching {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.category-list {
|
|
||||||
padding: 0px var(--spacing-l);
|
|
||||||
padding-bottom: var(--spacing-l);
|
|
||||||
}
|
|
||||||
.sub-section {
|
|
||||||
padding: var(--spacing-l);
|
|
||||||
padding-top: 0px;
|
|
||||||
}
|
|
||||||
.sub-section-back {
|
|
||||||
padding: var(--spacing-l);
|
|
||||||
padding-top: var(--spacing-xl);
|
|
||||||
padding-bottom: 0px;
|
|
||||||
}
|
|
||||||
.cat-heading {
|
|
||||||
margin-bottom: var(--spacing-l);
|
|
||||||
}
|
|
||||||
ul.helpers li * {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
ul.category-list li {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-m);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
ul.category-list .category-name {
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
ul.category-list .category-chevron {
|
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
ul.category-list .category-chevron :global(div.icon),
|
|
||||||
.cat-heading :global(div.icon) {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
li.binding {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
li.binding .binding__typeWrap {
|
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.drawer-actions) {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-heading {
|
|
||||||
font-size: var(--font-size-s);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--spectrum-global-color-gray-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-heading {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-m);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
font-size: var(--font-size-s);
|
|
||||||
padding: var(--spacing-m);
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--spectrum-global-color-gray-200);
|
|
||||||
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
|
|
||||||
border-color 130ms ease-in-out;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
li:not(:last-of-type) {
|
|
||||||
margin-bottom: var(--spacing-s);
|
|
||||||
}
|
|
||||||
li :global(*) {
|
|
||||||
transition: color 130ms ease-in-out;
|
|
||||||
}
|
|
||||||
li:hover {
|
|
||||||
color: var(--spectrum-global-color-gray-900);
|
|
||||||
background-color: var(--spectrum-global-color-gray-50);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binding__label {
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binding__type {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
background-color: var(--spectrum-global-color-gray-200);
|
|
||||||
border-radius: var(--border-radius-s);
|
|
||||||
padding: 2px 4px;
|
|
||||||
margin-left: 2px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -0,0 +1,446 @@
|
||||||
|
<script>
|
||||||
|
import groupBy from "lodash/fp/groupBy"
|
||||||
|
import { convertToJS } from "@budibase/string-templates"
|
||||||
|
import { Input, Layout, Icon, Popover } from "@budibase/bbui"
|
||||||
|
import { handlebarsCompletions } from "constants/completions"
|
||||||
|
|
||||||
|
export let addHelper
|
||||||
|
export let addBinding
|
||||||
|
export let bindings
|
||||||
|
export let mode
|
||||||
|
export let allowHelpers
|
||||||
|
export let context = null
|
||||||
|
|
||||||
|
let search = ""
|
||||||
|
let searching = false
|
||||||
|
let popover
|
||||||
|
let popoverAnchor
|
||||||
|
let hoverTarget
|
||||||
|
let helpers = handlebarsCompletions()
|
||||||
|
let selectedCategory
|
||||||
|
let hideTimeout
|
||||||
|
|
||||||
|
$: bindingIcons = bindings?.reduce((acc, ele) => {
|
||||||
|
if (ele.icon) {
|
||||||
|
acc[ele.category] = acc[ele.category] || ele.icon
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
|
||||||
|
$: categories = Object.entries(groupBy("category", bindings))
|
||||||
|
$: categoryNames = getCategoryNames(categories)
|
||||||
|
$: searchRgx = new RegExp(search, "ig")
|
||||||
|
$: filteredCategories = categories
|
||||||
|
.map(([name, categoryBindings]) => ({
|
||||||
|
name,
|
||||||
|
bindings: categoryBindings?.filter(binding => {
|
||||||
|
return !search || binding.readableBinding.match(searchRgx)
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.filter(category => {
|
||||||
|
return (
|
||||||
|
category.bindings?.length > 0 &&
|
||||||
|
(!selectedCategory ? true : selectedCategory === category.name)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
$: filteredHelpers = helpers?.filter(helper => {
|
||||||
|
return (
|
||||||
|
(!search ||
|
||||||
|
helper.label.match(searchRgx) ||
|
||||||
|
helper.description.match(searchRgx)) &&
|
||||||
|
(mode.name !== "javascript" || helper.allowsJs)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getHelperExample = (helper, js) => {
|
||||||
|
let example = helper.example || ""
|
||||||
|
if (js) {
|
||||||
|
example = convertToJS(example).split("\n")[0].split("= ")[1]
|
||||||
|
if (example === "null;") {
|
||||||
|
example = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return example || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryNames = categories => {
|
||||||
|
let names = [...categories.map(cat => cat[0])]
|
||||||
|
if (allowHelpers) {
|
||||||
|
names.push("Helpers")
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
const showBindingPopover = (binding, target) => {
|
||||||
|
if (!context || !binding.value || binding.value === "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles have always been broken for JS. We need to exclude them from
|
||||||
|
// showing a popover as it will show "Error while executing JS".
|
||||||
|
if (binding.category === "Role") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopHidingPopover()
|
||||||
|
popoverAnchor = target
|
||||||
|
hoverTarget = {
|
||||||
|
helper: false,
|
||||||
|
code: binding.valueHTML,
|
||||||
|
}
|
||||||
|
popover.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showHelperPopover = (helper, target) => {
|
||||||
|
stopHidingPopover()
|
||||||
|
if (!helper.displayText && helper.description) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
popoverAnchor = target
|
||||||
|
hoverTarget = {
|
||||||
|
helper: true,
|
||||||
|
description: helper.description,
|
||||||
|
code: getHelperExample(helper, mode.name === "javascript"),
|
||||||
|
}
|
||||||
|
popover.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidePopover = () => {
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
popover.hide()
|
||||||
|
popoverAnchor = null
|
||||||
|
hoverTarget = null
|
||||||
|
hideTimeout = null
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopHidingPopover = () => {
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout)
|
||||||
|
hideTimeout = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSearching = async () => {
|
||||||
|
searching = true
|
||||||
|
search = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopSearching = e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
searching = false
|
||||||
|
search = ""
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
align="left-outside"
|
||||||
|
bind:this={popover}
|
||||||
|
anchor={popoverAnchor}
|
||||||
|
minWidth={0}
|
||||||
|
maxWidth={480}
|
||||||
|
maxHeight={480}
|
||||||
|
dismissible={false}
|
||||||
|
on:mouseenter={stopHidingPopover}
|
||||||
|
on:mouseleave={hidePopover}
|
||||||
|
>
|
||||||
|
<div class="binding-popover" class:helper={hoverTarget.helper}>
|
||||||
|
{#if hoverTarget.description}
|
||||||
|
<div>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
||||||
|
{@html hoverTarget.description}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if hoverTarget.code}
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
||||||
|
<pre>{@html hoverTarget.code}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||||
|
<div class="binding-side-panel">
|
||||||
|
<Layout noPadding gap="S">
|
||||||
|
{#if selectedCategory}
|
||||||
|
<div class="header">
|
||||||
|
<Icon
|
||||||
|
name="BackAndroid"
|
||||||
|
hoverable
|
||||||
|
size="S"
|
||||||
|
on:click={() => (selectedCategory = null)}
|
||||||
|
/>
|
||||||
|
{selectedCategory}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !selectedCategory}
|
||||||
|
<div class="header">
|
||||||
|
{#if searching}
|
||||||
|
<div class="search-input">
|
||||||
|
<Input
|
||||||
|
placeholder="Search for bindings"
|
||||||
|
autocomplete="off"
|
||||||
|
bind:value={search}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
size="S"
|
||||||
|
name="Close"
|
||||||
|
hoverable
|
||||||
|
newStyles
|
||||||
|
on:click={stopSearching}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="title">Bindings</div>
|
||||||
|
<Icon
|
||||||
|
size="S"
|
||||||
|
name="Search"
|
||||||
|
hoverable
|
||||||
|
newStyles
|
||||||
|
on:click={startSearching}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !selectedCategory && !search}
|
||||||
|
<ul class="category-list">
|
||||||
|
{#each categoryNames as categoryName}
|
||||||
|
<li
|
||||||
|
on:click={() => {
|
||||||
|
selectedCategory = categoryName
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size="S"
|
||||||
|
color="var(--spectrum-global-color-gray-700)"
|
||||||
|
name={categoryIcons[categoryName]}
|
||||||
|
/>
|
||||||
|
<span class="category-name">{categoryName} </span>
|
||||||
|
<span class="category-chevron"><Icon name="ChevronRight" /></span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedCategory || search}
|
||||||
|
{#each filteredCategories as category}
|
||||||
|
{#if category.bindings?.length}
|
||||||
|
<div class="sub-section">
|
||||||
|
{#if filteredCategories.length > 1}
|
||||||
|
<div class="cat-heading">
|
||||||
|
<Icon name={categoryIcons[category.name]} />{category.name}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<ul>
|
||||||
|
{#each category.bindings as binding}
|
||||||
|
<li
|
||||||
|
class="binding"
|
||||||
|
on:mouseenter={e => showBindingPopover(binding, e.target)}
|
||||||
|
on:mouseleave={hidePopover}
|
||||||
|
on:click={() => addBinding(binding)}
|
||||||
|
>
|
||||||
|
<span class="binding__label">
|
||||||
|
{#if binding.display?.name}
|
||||||
|
{binding.display.name}
|
||||||
|
{:else if binding.fieldSchema?.name}
|
||||||
|
{binding.fieldSchema?.name}
|
||||||
|
{:else}
|
||||||
|
{binding.readableBinding}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if binding.display?.type || binding.fieldSchema?.type}
|
||||||
|
<span class="binding__typeWrap">
|
||||||
|
<span class="binding__type">
|
||||||
|
{binding.display?.type || binding.fieldSchema?.type}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if selectedCategory === "Helpers" || search}
|
||||||
|
{#if filteredHelpers?.length}
|
||||||
|
<div class="sub-section">
|
||||||
|
<ul class="helpers">
|
||||||
|
{#each filteredHelpers as helper}
|
||||||
|
<li
|
||||||
|
class="binding"
|
||||||
|
on:mouseenter={e => showHelperPopover(helper, e.target)}
|
||||||
|
on:mouseleave={hidePopover}
|
||||||
|
on:click={() => addHelper(helper, mode.name === "javascript")}
|
||||||
|
>
|
||||||
|
<span class="binding__label">{helper.displayText}</span>
|
||||||
|
<span class="binding__typeWrap">
|
||||||
|
<span class="binding__type">function</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.binding-side-panel {
|
||||||
|
border-left: var(--border-light);
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
height: 53px;
|
||||||
|
padding: 0 var(--spacing-l);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--border-light);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
background: var(--background);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.header :global(input) {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.search-input,
|
||||||
|
.title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.category-list {
|
||||||
|
padding: 0 var(--spacing-l);
|
||||||
|
padding-bottom: var(--spacing-l);
|
||||||
|
}
|
||||||
|
.sub-section {
|
||||||
|
padding: var(--spacing-l);
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
ul.helpers li * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
ul.category-list li {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
ul.category-list :global(.spectrum-Icon) {
|
||||||
|
margin: -4px 0;
|
||||||
|
}
|
||||||
|
ul.category-list .category-name {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
ul.category-list .category-chevron {
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
ul.category-list .category-chevron :global(div.icon),
|
||||||
|
.cat-heading :global(div.icon) {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
li.binding {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
}
|
||||||
|
li.binding .binding__typeWrap {
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.drawer-actions) {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-heading {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--spectrum-global-color-gray-700);
|
||||||
|
margin-bottom: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-heading {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
padding: var(--spacing-m);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--spectrum-global-color-gray-200);
|
||||||
|
transition: background-color 130ms ease-out, color 130ms ease-out,
|
||||||
|
border-color 130ms ease-out;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
li:not(:last-of-type) {
|
||||||
|
margin-bottom: var(--spacing-s);
|
||||||
|
}
|
||||||
|
li :global(*) {
|
||||||
|
transition: color 130ms ease-out;
|
||||||
|
}
|
||||||
|
li:hover {
|
||||||
|
color: var(--spectrum-global-color-gray-900);
|
||||||
|
background-color: var(--spectrum-global-color-gray-50);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding__label {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.binding__type {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--spectrum-global-color-gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-popover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
padding: var(--spacing-m);
|
||||||
|
}
|
||||||
|
.binding-popover pre {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.binding-popover.helper pre {
|
||||||
|
color: var(--spectrum-global-color-blue-700);
|
||||||
|
}
|
||||||
|
.binding-popover pre :global(span) {
|
||||||
|
overflow: hidden !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
.binding-popover :global(p) {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.binding-popover.helper :global(code) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,12 +1,14 @@
|
||||||
<script>
|
<script>
|
||||||
import BindingPanel from "./BindingPanel.svelte"
|
import BindingPanel from "./BindingPanel.svelte"
|
||||||
|
import { previewStore, snippets } from "stores/builder"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
|
||||||
export let bindings = []
|
export let bindings = []
|
||||||
export let valid
|
|
||||||
export let value = ""
|
export let value = ""
|
||||||
export let allowJS = false
|
export let allowJS = false
|
||||||
export let allowHelpers = true
|
export let allowHelpers = true
|
||||||
export let autofocusEditor = false
|
export let autofocusEditor = false
|
||||||
|
export let context = null
|
||||||
|
|
||||||
$: enrichedBindings = enrichBindings(bindings)
|
$: enrichedBindings = enrichBindings(bindings)
|
||||||
|
|
||||||
|
@ -20,11 +22,14 @@
|
||||||
type: null,
|
type: null,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(previewStore.requestComponentContext)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BindingPanel
|
<BindingPanel
|
||||||
bind:valid
|
|
||||||
bindings={enrichedBindings}
|
bindings={enrichedBindings}
|
||||||
|
context={{ ...$previewStore.selectedComponentContext, ...context }}
|
||||||
|
snippets={$snippets}
|
||||||
{value}
|
{value}
|
||||||
{allowJS}
|
{allowJS}
|
||||||
{allowHelpers}
|
{allowHelpers}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue