merge
This commit is contained in:
commit
c11e26cb2e
|
@ -0,0 +1,46 @@
|
|||
name: Budibase Smoke Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js 14.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.x
|
||||
- run: yarn
|
||||
- run: yarn bootstrap
|
||||
- run: yarn build
|
||||
- name: Pull cypress.env.yaml from budibase-infra
|
||||
run: |
|
||||
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
|
||||
-H 'Accept: application/vnd.github.v3.raw' \
|
||||
-o packages/builder/cypress.env.json \
|
||||
-L https://api.github.com/repos/budibase/budibase-infra/contents/test/cypress.env.json
|
||||
wc -l packages/builder/cypress.env.json
|
||||
- run: yarn test:e2e:ci
|
||||
env:
|
||||
CI: true
|
||||
name: Budibase CI
|
||||
|
||||
# TODO: upload recordings to s3
|
||||
# - name: Configure AWS Credentials
|
||||
# uses: aws-actions/configure-aws-credentials@v1
|
||||
# with:
|
||||
# aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
# aws-region: eu-west-1
|
||||
|
||||
# TODO look at cypress reporters
|
||||
# - name: Discord Webhook Action
|
||||
# uses: tsickert/discord-webhook@v4.0.0
|
||||
# with:
|
||||
# webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
|
||||
# content: "Production Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Cloud."
|
||||
# embed-title: ${{ env.RELEASE_VERSION }}
|
||||
|
|
@ -201,9 +201,6 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|||
<td align="center"><a href="https://github.com/seoulaja"><img src="https://avatars.githubusercontent.com/u/15101654?v=4?s=100" width="100px;" alt=""/><br /><sub><b>seoulaja</b></sub></a><br /><a href="#translation-seoulaja" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/mslourens"><img src="https://avatars.githubusercontent.com/u/1907152?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maurits Lourens</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Tests">⚠️</a> <a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/Rory-Powell"><img src="https://avatars.githubusercontent.com/u/8755148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rory Powell</b></sub></a><br /><a href="#infra-Rory-Powell" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Tests">⚠️</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
|
|
|
@ -99,13 +99,17 @@ spec:
|
|||
- name: PLATFORM_URL
|
||||
value: {{ .Values.globals.platformUrl | quote }}
|
||||
- name: USE_QUOTAS
|
||||
value: "1"
|
||||
value: {{ .Values.globals.useQuotas | quote }}
|
||||
- name: EXCLUDE_QUOTAS_TENANTS
|
||||
value: {{ .Values.globals.excludeQuotasTenants | quote }}
|
||||
- name: ACCOUNT_PORTAL_URL
|
||||
value: {{ .Values.globals.accountPortalUrl | quote }}
|
||||
- name: ACCOUNT_PORTAL_API_KEY
|
||||
value: {{ .Values.globals.accountPortalApiKey | quote }}
|
||||
- name: COOKIE_DOMAIN
|
||||
value: {{ .Values.globals.cookieDomain | quote }}
|
||||
- name: HTTP_MIGRATIONS
|
||||
value: {{ .Values.globals.httpMigrations | quote }}
|
||||
image: budibase/apps:{{ .Values.globals.appVersion }}
|
||||
imagePullPolicy: Always
|
||||
name: bbapps
|
||||
|
|
|
@ -93,10 +93,13 @@ globals:
|
|||
logLevel: info
|
||||
selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup
|
||||
multiTenancy: "0" # set to 0 to disable multiple orgs, set to 1 to enable multiple orgs
|
||||
useQuotas: "0"
|
||||
excludeQuotasTenants: "" # comma seperated list of tenants to exclude from quotas
|
||||
accountPortalUrl: ""
|
||||
accountPortalApiKey: ""
|
||||
cookieDomain: ""
|
||||
platformUrl: ""
|
||||
httpMigrations: "0"
|
||||
|
||||
createSecrets: true # creates an internal API key, JWT secrets and redis password for you
|
||||
|
||||
|
@ -239,7 +242,8 @@ couchdb:
|
|||
hosts:
|
||||
- chart-example.local
|
||||
path: /
|
||||
annotations: []
|
||||
annotations:
|
||||
[]
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
tls:
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "1.0.43",
|
||||
"version": "1.0.49-alpha.5",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
|
|
|
@ -36,10 +36,10 @@
|
|||
"dev:server": "lerna run --parallel dev:builder --concurrency 1 --scope @budibase/worker --scope @budibase/server",
|
||||
"test": "lerna run test",
|
||||
"lint:eslint": "eslint packages",
|
||||
"lint:prettier": "prettier --check \"packages/**/*.{js,svelte}\"",
|
||||
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\"",
|
||||
"lint": "yarn run lint:eslint && yarn run lint:prettier",
|
||||
"lint:fix:eslint": "eslint --fix packages",
|
||||
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,svelte}\"",
|
||||
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\"",
|
||||
"lint:fix:ts": "lerna run lint:fix",
|
||||
"lint:fix": "yarn run lint:fix:ts && yarn run lint:fix:prettier && yarn run lint:fix:eslint",
|
||||
"test:e2e": "lerna run cy:test",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
module.exports = {
|
||||
...require("./src/db/utils"),
|
||||
...require("./src/db/constants"),
|
||||
...require("./src/db/views"),
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
module.exports = require("./src/migrations")
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/backend-core",
|
||||
"version": "1.0.43",
|
||||
"version": "1.0.49-alpha.5",
|
||||
"description": "Budibase backend core libraries used in server and worker",
|
||||
"main": "src/index.js",
|
||||
"author": "Budibase",
|
||||
|
|
|
@ -12,6 +12,8 @@ const {
|
|||
tenancy,
|
||||
appTenancy,
|
||||
authError,
|
||||
csrf,
|
||||
internalApi,
|
||||
} = require("./middleware")
|
||||
|
||||
// Strategies
|
||||
|
@ -42,4 +44,6 @@ module.exports = {
|
|||
buildAppTenancyMiddleware: appTenancy,
|
||||
auditLog,
|
||||
authError,
|
||||
buildCsrfMiddleware: csrf,
|
||||
internalApi,
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ exports.Cookies = {
|
|||
CurrentApp: "budibase:currentapp",
|
||||
Auth: "budibase:auth",
|
||||
Init: "budibase:init",
|
||||
DatasourceAuth: "budibase:datasourceauth",
|
||||
OIDC_CONFIG: "budibase:oidc:config",
|
||||
}
|
||||
|
||||
|
@ -17,6 +18,7 @@ exports.Headers = {
|
|||
TYPE: "x-budibase-type",
|
||||
TENANT_ID: "x-budibase-tenant-id",
|
||||
TOKEN: "x-budibase-token",
|
||||
CSRF_TOKEN: "x-csrf-token",
|
||||
}
|
||||
|
||||
exports.GlobalRoles = {
|
||||
|
|
|
@ -21,6 +21,7 @@ exports.StaticDatabases = {
|
|||
name: "global-db",
|
||||
docs: {
|
||||
apiKeys: "apikeys",
|
||||
usageQuota: "usage_quota",
|
||||
},
|
||||
},
|
||||
// contains information about tenancy and so on
|
||||
|
@ -28,7 +29,6 @@ exports.StaticDatabases = {
|
|||
name: "global-info",
|
||||
docs: {
|
||||
tenants: "tenants",
|
||||
usageQuota: "usage_quota",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -450,7 +450,7 @@ async function getScopedConfig(db, params) {
|
|||
|
||||
function generateNewUsageQuotaDoc() {
|
||||
return {
|
||||
_id: StaticDatabases.PLATFORM_INFO.docs.usageQuota,
|
||||
_id: StaticDatabases.GLOBAL.docs.usageQuota,
|
||||
quotaReset: Date.now() + 2592000000,
|
||||
usageQuota: {
|
||||
automationRuns: 0,
|
||||
|
|
|
@ -14,4 +14,5 @@ module.exports = {
|
|||
cache: require("../cache"),
|
||||
auth: require("../auth"),
|
||||
constants: require("../constants"),
|
||||
migrations: require("../migrations"),
|
||||
}
|
||||
|
|
|
@ -60,6 +60,7 @@ module.exports = (
|
|||
} else {
|
||||
user = await getUser(userId, session.tenantId)
|
||||
}
|
||||
user.csrfToken = session.csrfToken
|
||||
delete user.password
|
||||
authenticated = true
|
||||
} catch (err) {
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
const { Headers } = require("../constants")
|
||||
const { buildMatcherRegex, matches } = require("./matchers")
|
||||
|
||||
/**
|
||||
* GET, HEAD and OPTIONS methods are considered safe operations
|
||||
*
|
||||
* POST, PUT, PATCH, and DELETE methods, being state changing verbs,
|
||||
* should have a CSRF token attached to the request
|
||||
*/
|
||||
const EXCLUDED_METHODS = ["GET", "HEAD", "OPTIONS"]
|
||||
|
||||
/**
|
||||
* There are only three content type values that can be used in cross domain requests.
|
||||
* If any other value is used, e.g. application/json, the browser will first make a OPTIONS
|
||||
* request which will be protected by CORS.
|
||||
*/
|
||||
const INCLUDED_CONTENT_TYPES = [
|
||||
"application/x-www-form-urlencoded",
|
||||
"multipart/form-data",
|
||||
"text/plain",
|
||||
]
|
||||
|
||||
/**
|
||||
* Validate the CSRF token generated aganst the user session.
|
||||
* Compare the token with the x-csrf-token header.
|
||||
*
|
||||
* If the token is not found within the request or the value provided
|
||||
* does not match the value within the user session, the request is rejected.
|
||||
*
|
||||
* CSRF protection provided using the 'Synchronizer Token Pattern'
|
||||
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern
|
||||
*
|
||||
*/
|
||||
module.exports = (opts = { noCsrfPatterns: [] }) => {
|
||||
const noCsrfOptions = buildMatcherRegex(opts.noCsrfPatterns)
|
||||
return async (ctx, next) => {
|
||||
// don't apply for excluded paths
|
||||
const found = matches(ctx, noCsrfOptions)
|
||||
if (found) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// don't apply for the excluded http methods
|
||||
if (EXCLUDED_METHODS.indexOf(ctx.method) !== -1) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// don't apply when the content type isn't supported
|
||||
let contentType = ctx.get("content-type")
|
||||
? ctx.get("content-type").toLowerCase()
|
||||
: ""
|
||||
if (
|
||||
!INCLUDED_CONTENT_TYPES.filter(type => contentType.includes(type)).length
|
||||
) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// don't apply csrf when the internal api key has been used
|
||||
if (ctx.internal) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// apply csrf when there is a token in the session (new logins)
|
||||
// in future there should be a hard requirement that the token is present
|
||||
const userToken = ctx.user.csrfToken
|
||||
if (!userToken) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// reject if no token in request or mismatch
|
||||
const requestToken = ctx.get(Headers.CSRF_TOKEN)
|
||||
if (!requestToken || requestToken !== userToken) {
|
||||
ctx.throw(403, "Invalid CSRF token")
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
|
@ -7,6 +7,9 @@ const authenticated = require("./authenticated")
|
|||
const auditLog = require("./auditLog")
|
||||
const tenancy = require("./tenancy")
|
||||
const appTenancy = require("./appTenancy")
|
||||
const internalApi = require("./internalApi")
|
||||
const datasourceGoogle = require("./passport/datasource/google")
|
||||
const csrf = require("./csrf")
|
||||
|
||||
module.exports = {
|
||||
google,
|
||||
|
@ -18,4 +21,9 @@ module.exports = {
|
|||
tenancy,
|
||||
appTenancy,
|
||||
authError,
|
||||
internalApi,
|
||||
datasource: {
|
||||
google: datasourceGoogle,
|
||||
},
|
||||
csrf,
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
const env = require("../environment")
|
||||
const { Headers } = require("../constants")
|
||||
|
||||
/**
|
||||
* API Key only endpoint.
|
||||
*/
|
||||
module.exports = async (ctx, next) => {
|
||||
const apiKey = ctx.request.headers[Headers.API_KEY]
|
||||
if (apiKey !== env.INTERNAL_API_KEY) {
|
||||
ctx.throw(403, "Unauthorized")
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
const { getScopedConfig } = require("../../../db/utils")
|
||||
const { getGlobalDB } = require("../../../tenancy")
|
||||
const google = require("../google")
|
||||
const { Configs, Cookies } = require("../../../constants")
|
||||
const { clearCookie, getCookie } = require("../../../utils")
|
||||
const { getDB } = require("../../../db")
|
||||
|
||||
async function preAuth(passport, ctx, next) {
|
||||
const db = getGlobalDB()
|
||||
// get the relevant config
|
||||
const config = await getScopedConfig(db, {
|
||||
type: Configs.GOOGLE,
|
||||
workspace: ctx.query.workspace,
|
||||
})
|
||||
const publicConfig = await getScopedConfig(db, {
|
||||
type: Configs.SETTINGS,
|
||||
})
|
||||
let callbackUrl = `${publicConfig.platformUrl}/api/global/auth/datasource/google/callback`
|
||||
const strategy = await google.strategyFactory(config, callbackUrl)
|
||||
|
||||
if (!ctx.query.appId || !ctx.query.datasourceId) {
|
||||
ctx.throw(400, "appId and datasourceId query params not present.")
|
||||
}
|
||||
|
||||
return passport.authenticate(strategy, {
|
||||
scope: ["profile", "email", "https://www.googleapis.com/auth/spreadsheets"],
|
||||
accessType: "offline",
|
||||
prompt: "consent",
|
||||
})(ctx, next)
|
||||
}
|
||||
|
||||
async function postAuth(passport, ctx, next) {
|
||||
const db = getGlobalDB()
|
||||
|
||||
const config = await getScopedConfig(db, {
|
||||
type: Configs.GOOGLE,
|
||||
workspace: ctx.query.workspace,
|
||||
})
|
||||
|
||||
const publicConfig = await getScopedConfig(db, {
|
||||
type: Configs.SETTINGS,
|
||||
})
|
||||
|
||||
let callbackUrl = `${publicConfig.platformUrl}/api/global/auth/datasource/google/callback`
|
||||
const strategy = await google.strategyFactory(
|
||||
config,
|
||||
callbackUrl,
|
||||
(accessToken, refreshToken, profile, done) => {
|
||||
clearCookie(ctx, Cookies.DatasourceAuth)
|
||||
done(null, { accessToken, refreshToken })
|
||||
}
|
||||
)
|
||||
|
||||
const authStateCookie = getCookie(ctx, Cookies.DatasourceAuth)
|
||||
|
||||
return passport.authenticate(
|
||||
strategy,
|
||||
{ successRedirect: "/", failureRedirect: "/error" },
|
||||
async (err, tokens) => {
|
||||
// update the DB for the datasource with all the user info
|
||||
const db = getDB(authStateCookie.appId)
|
||||
const datasource = await db.get(authStateCookie.datasourceId)
|
||||
if (!datasource.config) {
|
||||
datasource.config = {}
|
||||
}
|
||||
datasource.config.auth = { type: "google", ...tokens }
|
||||
await db.put(datasource)
|
||||
ctx.redirect(
|
||||
`/builder/app/${authStateCookie.appId}/data/datasource/${authStateCookie.datasourceId}`
|
||||
)
|
||||
}
|
||||
)(ctx, next)
|
||||
}
|
||||
|
||||
exports.preAuth = preAuth
|
||||
exports.postAuth = postAuth
|
|
@ -1,18 +1,17 @@
|
|||
const { DEFAULT_TENANT_ID } = require("../constants")
|
||||
const { DocumentTypes } = require("../db/constants")
|
||||
const { getGlobalDB } = require("../tenancy")
|
||||
const { getAllApps } = require("../db/utils")
|
||||
const environment = require("../environment")
|
||||
const {
|
||||
doInTenant,
|
||||
getTenantIds,
|
||||
getGlobalDBName,
|
||||
getTenantId,
|
||||
} = require("../tenancy")
|
||||
|
||||
exports.MIGRATION_DBS = {
|
||||
GLOBAL_DB: "GLOBAL_DB",
|
||||
}
|
||||
|
||||
exports.MIGRATIONS = {
|
||||
USER_EMAIL_VIEW_CASING: "user_email_view_casing",
|
||||
}
|
||||
|
||||
const DB_LOOKUP = {
|
||||
[exports.MIGRATION_DBS.GLOBAL_DB]: [
|
||||
exports.MIGRATIONS.USER_EMAIL_VIEW_CASING,
|
||||
],
|
||||
exports.MIGRATION_TYPES = {
|
||||
GLOBAL: "global", // run once, recorded in global db, global db is provided as an argument
|
||||
APP: "app", // run per app, recorded in each app db, app db is provided as an argument
|
||||
}
|
||||
|
||||
exports.getMigrationsDoc = async db => {
|
||||
|
@ -26,36 +25,90 @@ exports.getMigrationsDoc = async db => {
|
|||
}
|
||||
}
|
||||
|
||||
exports.migrateIfRequired = async (migrationDb, migrationName, migrateFn) => {
|
||||
try {
|
||||
let db
|
||||
if (migrationDb === exports.MIGRATION_DBS.GLOBAL_DB) {
|
||||
db = getGlobalDB()
|
||||
} else {
|
||||
throw new Error(`Unrecognised migration db [${migrationDb}]`)
|
||||
}
|
||||
const runMigration = async (CouchDB, migration, options = {}) => {
|
||||
const tenantId = getTenantId()
|
||||
const migrationType = migration.type
|
||||
const migrationName = migration.name
|
||||
|
||||
if (!DB_LOOKUP[migrationDb].includes(migrationName)) {
|
||||
// get the db to store the migration in
|
||||
let dbNames
|
||||
if (migrationType === exports.MIGRATION_TYPES.GLOBAL) {
|
||||
dbNames = [getGlobalDBName()]
|
||||
} else if (migrationType === exports.MIGRATION_TYPES.APP) {
|
||||
const apps = await getAllApps(CouchDB, migration.opts)
|
||||
dbNames = apps.map(app => app.appId)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unrecognised migration name [${migrationName}] for db [${migrationDb}]`
|
||||
`[Tenant: ${tenantId}] Unrecognised migration type [${migrationType}]`
|
||||
)
|
||||
}
|
||||
|
||||
// run the migration against each db
|
||||
for (const dbName of dbNames) {
|
||||
const db = new CouchDB(dbName)
|
||||
try {
|
||||
const doc = await exports.getMigrationsDoc(db)
|
||||
// exit if the migration has been performed
|
||||
|
||||
// exit if the migration has been performed already
|
||||
if (doc[migrationName]) {
|
||||
return
|
||||
if (
|
||||
options.force &&
|
||||
options.force[migrationType] &&
|
||||
options.force[migrationType].includes(migrationName)
|
||||
) {
|
||||
console.log(
|
||||
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Forcing`
|
||||
)
|
||||
} else {
|
||||
// the migration has already been performed
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Performing migration: ${migrationName}`)
|
||||
await migrateFn()
|
||||
console.log(`Migration complete: ${migrationName}`)
|
||||
console.log(
|
||||
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Running`
|
||||
)
|
||||
// run the migration with tenant context
|
||||
await migration.fn(db)
|
||||
console.log(
|
||||
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Complete`
|
||||
)
|
||||
|
||||
// mark as complete
|
||||
doc[migrationName] = Date.now()
|
||||
await db.put(doc)
|
||||
} catch (err) {
|
||||
console.error(`Error performing migration: ${migrationName}: `, err)
|
||||
console.error(
|
||||
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Error: `,
|
||||
err
|
||||
)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.runMigrations = async (CouchDB, migrations, options = {}) => {
|
||||
console.log("Running migrations")
|
||||
let tenantIds
|
||||
if (environment.MULTI_TENANCY) {
|
||||
if (!options.tenantIds || !options.tenantIds.length) {
|
||||
// run for all tenants
|
||||
tenantIds = await getTenantIds()
|
||||
}
|
||||
} else {
|
||||
// single tenancy
|
||||
tenantIds = [DEFAULT_TENANT_ID]
|
||||
}
|
||||
|
||||
// for all tenants
|
||||
for (const tenantId of tenantIds) {
|
||||
// for all migrations
|
||||
for (const migration of migrations) {
|
||||
// run the migration
|
||||
await doInTenant(tenantId, () =>
|
||||
runMigration(CouchDB, migration, options)
|
||||
)
|
||||
}
|
||||
}
|
||||
console.log("Migrations complete")
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
exports[`migrations should match snapshot 1`] = `
|
||||
Object {
|
||||
"_id": "migrations",
|
||||
"_rev": "1-af6c272fe081efafecd2ea49a8fcbb40",
|
||||
"user_email_view_casing": 1487076708000,
|
||||
"_rev": "1-6277abc4e3db950221768e5a2618a059",
|
||||
"test": 1487076708000,
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
require("../../tests/utilities/dbConfig")
|
||||
|
||||
const { migrateIfRequired, MIGRATION_DBS, MIGRATIONS, getMigrationsDoc } = require("../index")
|
||||
const database = require("../../db")
|
||||
const { runMigrations, getMigrationsDoc } = require("../index")
|
||||
const CouchDB = require("../../db").getCouch()
|
||||
const {
|
||||
StaticDatabases,
|
||||
} = require("../../db/utils")
|
||||
|
@ -13,8 +13,14 @@ describe("migrations", () => {
|
|||
|
||||
const migrationFunction = jest.fn()
|
||||
|
||||
const MIGRATIONS = [{
|
||||
type: "global",
|
||||
name: "test",
|
||||
fn: migrationFunction
|
||||
}]
|
||||
|
||||
beforeEach(() => {
|
||||
db = database.getDB(StaticDatabases.GLOBAL.name)
|
||||
db = new CouchDB(StaticDatabases.GLOBAL.name)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
@ -22,39 +28,29 @@ describe("migrations", () => {
|
|||
await db.destroy()
|
||||
})
|
||||
|
||||
const validMigration = () => {
|
||||
return migrateIfRequired(MIGRATION_DBS.GLOBAL_DB, MIGRATIONS.USER_EMAIL_VIEW_CASING, migrationFunction)
|
||||
const migrate = () => {
|
||||
return runMigrations(CouchDB, MIGRATIONS)
|
||||
}
|
||||
|
||||
it("should run a new migration", async () => {
|
||||
await validMigration()
|
||||
await migrate()
|
||||
expect(migrationFunction).toHaveBeenCalled()
|
||||
const doc = await getMigrationsDoc(db)
|
||||
expect(doc.test).toBeDefined()
|
||||
})
|
||||
|
||||
it("should match snapshot", async () => {
|
||||
await validMigration()
|
||||
await migrate()
|
||||
const doc = await getMigrationsDoc(db)
|
||||
expect(doc).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("should skip a previously run migration", async () => {
|
||||
await validMigration()
|
||||
await validMigration()
|
||||
await migrate()
|
||||
const previousMigrationTime = await getMigrationsDoc(db).test
|
||||
await migrate()
|
||||
const currentMigrationTime = await getMigrationsDoc(db).test
|
||||
expect(migrationFunction).toHaveBeenCalledTimes(1)
|
||||
expect(currentMigrationTime).toBe(previousMigrationTime)
|
||||
})
|
||||
|
||||
it("should reject an unknown migration name", async () => {
|
||||
expect(async () => {
|
||||
await migrateIfRequired(MIGRATION_DBS.GLOBAL_DB, "bogus_name", migrationFunction)
|
||||
}).rejects.toThrow()
|
||||
expect(migrationFunction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should reject an unknown database name", async () => {
|
||||
expect(async () => {
|
||||
await migrateIfRequired("bogus_db", MIGRATIONS.USER_EMAIL_VIEW_CASING, migrationFunction)
|
||||
}).rejects.toThrow()
|
||||
expect(migrationFunction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
})
|
|
@ -1,4 +1,5 @@
|
|||
const redis = require("../redis/authRedis")
|
||||
const { v4: uuidv4 } = require("uuid")
|
||||
|
||||
// a week in seconds
|
||||
const EXPIRY_SECONDS = 86400 * 7
|
||||
|
@ -16,6 +17,9 @@ function makeSessionID(userId, sessionId) {
|
|||
exports.createASession = async (userId, session) => {
|
||||
const client = await redis.getSessionClient()
|
||||
const sessionId = session.sessionId
|
||||
if (!session.csrfToken) {
|
||||
session.csrfToken = uuidv4()
|
||||
}
|
||||
session = {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
|
|
|
@ -148,3 +148,15 @@ exports.isUserInAppTenant = (appId, user = null) => {
|
|||
const tenantId = exports.getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID
|
||||
return tenantId === userTenantId
|
||||
}
|
||||
|
||||
exports.getTenantIds = async () => {
|
||||
const db = getDB(PLATFORM_INFO_DB)
|
||||
let tenants
|
||||
try {
|
||||
tenants = await db.get(TENANT_DOC)
|
||||
} catch (err) {
|
||||
// if theres an error the doc doesn't exist, no tenants exist
|
||||
return []
|
||||
}
|
||||
return (tenants && tenants.tenantIds) || []
|
||||
}
|
||||
|
|
|
@ -20,9 +20,6 @@ const { hash } = require("./hashing")
|
|||
const userCache = require("./cache/user")
|
||||
const env = require("./environment")
|
||||
const { getUserSessions, invalidateSessions } = require("./security/sessions")
|
||||
const { migrateIfRequired } = require("./migrations")
|
||||
const { USER_EMAIL_VIEW_CASING } = require("./migrations").MIGRATIONS
|
||||
const { GLOBAL_DB } = require("./migrations").MIGRATION_DBS
|
||||
|
||||
const APP_PREFIX = DocumentTypes.APP + SEPARATOR
|
||||
|
||||
|
@ -144,11 +141,6 @@ exports.getGlobalUserByEmail = async email => {
|
|||
}
|
||||
const db = getGlobalDB()
|
||||
|
||||
await migrateIfRequired(GLOBAL_DB, USER_EMAIL_VIEW_CASING, async () => {
|
||||
// re-create the view with latest changes
|
||||
await createUserEmailView(db)
|
||||
})
|
||||
|
||||
try {
|
||||
let users = (
|
||||
await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {
|
||||
|
|
|
@ -3410,9 +3410,9 @@ node-fetch@2.6.0:
|
|||
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
|
||||
|
||||
node-fetch@^2.6.1:
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89"
|
||||
integrity sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA==
|
||||
version "2.6.7"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
|
||||
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
|
||||
dependencies:
|
||||
whatwg-url "^5.0.0"
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@budibase/bbui",
|
||||
"description": "A UI solution used in the different Budibase projects.",
|
||||
"version": "1.0.43",
|
||||
"version": "1.0.49-alpha.5",
|
||||
"license": "MPL-2.0",
|
||||
"svelte": "src/index.js",
|
||||
"module": "dist/bbui.es.js",
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
export let error = null
|
||||
export let fileTags = []
|
||||
export let maximum = null
|
||||
export let extensions = "*"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const imageExtensions = [
|
||||
|
@ -146,7 +147,9 @@
|
|||
<img alt="preview" src={selectedUrl} />
|
||||
{:else}
|
||||
<div class="placeholder">
|
||||
<div class="extension">{selectedImage.extension}</div>
|
||||
<div class="extension">
|
||||
{selectedImage.name || "Unknown file"}
|
||||
</div>
|
||||
<div>Preview not supported</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -207,6 +210,7 @@
|
|||
{disabled}
|
||||
type="file"
|
||||
multiple
|
||||
accept={extensions}
|
||||
on:change={handleFile}
|
||||
/>
|
||||
<svg
|
||||
|
@ -357,18 +361,21 @@
|
|||
white-space: nowrap;
|
||||
width: 0;
|
||||
margin-right: 10px;
|
||||
user-select: all;
|
||||
}
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.extension {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.nav {
|
||||
|
|
|
@ -6,11 +6,12 @@
|
|||
export let label = null
|
||||
export let labelPosition = "above"
|
||||
export let error = null
|
||||
export let tooltip = ""
|
||||
</script>
|
||||
|
||||
<div class="spectrum-Form-item" class:above={labelPosition === "above"}>
|
||||
{#if label}
|
||||
<FieldLabel forId={id} {label} position={labelPosition} />
|
||||
<FieldLabel forId={id} {label} position={labelPosition} {tooltip} />
|
||||
{/if}
|
||||
<div class="spectrum-Form-itemField">
|
||||
<slot />
|
||||
|
|
|
@ -1,19 +1,24 @@
|
|||
<script>
|
||||
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
|
||||
|
||||
import "@spectrum-css/fieldlabel/dist/index-vars.css"
|
||||
|
||||
export let forId
|
||||
export let label
|
||||
export let position = "above"
|
||||
export let tooltip = ""
|
||||
|
||||
$: className = position === "above" ? "" : `spectrum-FieldLabel--${position}`
|
||||
</script>
|
||||
|
||||
<label
|
||||
<TooltipWrapper {tooltip} size="S">
|
||||
<label
|
||||
for={forId}
|
||||
class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${className}`}
|
||||
>
|
||||
>
|
||||
{label || ""}
|
||||
</label>
|
||||
</label>
|
||||
</TooltipWrapper>
|
||||
|
||||
<style>
|
||||
label {
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
export let quiet = false
|
||||
export let autoWidth = false
|
||||
export let sort = false
|
||||
export let tooltip = ""
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = e => {
|
||||
|
@ -32,7 +33,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<Field {label} {labelPosition} {error} {tooltip}>
|
||||
<Select
|
||||
{quiet}
|
||||
{error}
|
||||
|
|
|
@ -1,73 +1,20 @@
|
|||
<script>
|
||||
import "@spectrum-css/fieldlabel/dist/index-vars.css"
|
||||
import Tooltip from "../Tooltip/Tooltip.svelte"
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
|
||||
|
||||
export let size = "M"
|
||||
export let tooltip = ""
|
||||
export let showTooltip = false
|
||||
</script>
|
||||
|
||||
{#if tooltip}
|
||||
<div class="container">
|
||||
<label
|
||||
for=""
|
||||
class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}
|
||||
>
|
||||
<slot />
|
||||
</label>
|
||||
<div class="icon-container">
|
||||
<div
|
||||
class="icon"
|
||||
class:icon-small={size === "M" || size === "S"}
|
||||
on:mouseover={() => (showTooltip = true)}
|
||||
on:mouseleave={() => (showTooltip = false)}
|
||||
>
|
||||
<Icon name="InfoOutline" size="S" disabled={true} />
|
||||
</div>
|
||||
{#if showTooltip}
|
||||
<div class="tooltip">
|
||||
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<TooltipWrapper {tooltip} {size}>
|
||||
<label for="" class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}>
|
||||
<slot />
|
||||
</label>
|
||||
{/if}
|
||||
</TooltipWrapper>
|
||||
|
||||
<style>
|
||||
label {
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
top: 15px;
|
||||
z-index: 1;
|
||||
width: 160px;
|
||||
}
|
||||
.icon {
|
||||
transform: scale(0.75);
|
||||
}
|
||||
.icon-small {
|
||||
margin-top: -2px;
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
<script>
|
||||
import Tooltip from "./Tooltip.svelte"
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
|
||||
export let tooltip = ""
|
||||
export let size = "M"
|
||||
|
||||
let showTooltip = false
|
||||
</script>
|
||||
|
||||
<div class:container={!!tooltip}>
|
||||
<slot />
|
||||
{#if tooltip}
|
||||
<div class="icon-container">
|
||||
<div
|
||||
class="icon"
|
||||
class:icon-small={size === "M" || size === "S"}
|
||||
on:mouseover={() => (showTooltip = true)}
|
||||
on:mouseleave={() => (showTooltip = false)}
|
||||
>
|
||||
<Icon name="InfoOutline" size="S" disabled={true} />
|
||||
</div>
|
||||
{#if showTooltip}
|
||||
<div class="tooltip">
|
||||
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
top: 15px;
|
||||
z-index: 1;
|
||||
width: 160px;
|
||||
}
|
||||
.icon {
|
||||
transform: scale(0.75);
|
||||
}
|
||||
.icon-small {
|
||||
margin-top: -2px;
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
</style>
|
|
@ -40,7 +40,7 @@ async function run() {
|
|||
// it will cause environment module to be loaded prematurely
|
||||
const server = require("../../server/dist/app")
|
||||
process.env.PORT = WORKER_PORT
|
||||
const worker = require("../../worker/src/index")
|
||||
const worker = require("../../worker/dist/index")
|
||||
// reload main port for rest of system
|
||||
process.env.PORT = MAIN_PORT
|
||||
server.on("close", () => console.log("Server Closed"))
|
||||
|
|
|
@ -35,7 +35,13 @@ Cypress.Commands.add("login", () => {
|
|||
Cypress.Commands.add("createApp", name => {
|
||||
cy.visit(`localhost:${Cypress.env("PORT")}/builder`)
|
||||
cy.wait(500)
|
||||
cy.contains(/Start from scratch/).dblclick()
|
||||
cy.request(`${Cypress.config().baseUrl}api/applications?status=all`)
|
||||
.its("body")
|
||||
.then(body => {
|
||||
if (body.length > 0) {
|
||||
cy.get(".spectrum-Button").contains("Create app").click({ force: true })
|
||||
}
|
||||
})
|
||||
cy.get(".spectrum-Modal").within(() => {
|
||||
cy.get("input").eq(0).type(name).should("have.value", name).blur()
|
||||
cy.get(".spectrum-ButtonGroup").contains("Create app").click()
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/builder",
|
||||
"version": "1.0.43",
|
||||
"version": "1.0.49-alpha.5",
|
||||
"license": "GPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
@ -65,10 +65,10 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/bbui": "^1.0.43",
|
||||
"@budibase/client": "^1.0.43",
|
||||
"@budibase/bbui": "^1.0.49-alpha.5",
|
||||
"@budibase/client": "^1.0.49-alpha.5",
|
||||
"@budibase/colorpicker": "1.1.2",
|
||||
"@budibase/string-templates": "^1.0.43",
|
||||
"@budibase/string-templates": "^1.0.49-alpha.5",
|
||||
"@sentry/browser": "5.19.1",
|
||||
"@spectrum-css/page": "^3.0.1",
|
||||
"@spectrum-css/vars": "^3.0.1",
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
import { store } from "./index"
|
||||
import { get as svelteGet } from "svelte/store"
|
||||
import { removeCookie, Cookies } from "./cookies"
|
||||
import { auth } from "stores/portal"
|
||||
|
||||
const apiCall =
|
||||
method =>
|
||||
async (url, body, headers = { "Content-Type": "application/json" }) => {
|
||||
headers["x-budibase-app-id"] = svelteGet(store).appId
|
||||
headers["x-budibase-api-version"] = "1"
|
||||
|
||||
// add csrf token if authenticated
|
||||
const user = svelteGet(auth).user
|
||||
if (user && user.csrfToken) {
|
||||
headers["x-csrf-token"] = user.csrfToken
|
||||
}
|
||||
|
||||
const json = headers["Content-Type"] === "application/json"
|
||||
const resp = await fetch(url, {
|
||||
method: method,
|
||||
|
|
|
@ -1,16 +1,26 @@
|
|||
export const Cookies = {
|
||||
Auth: "budibase:auth",
|
||||
CurrentApp: "budibase:currentapp",
|
||||
ReturnUrl: "budibase:returnurl",
|
||||
}
|
||||
|
||||
export function setCookie(name, value) {
|
||||
if (getCookie(name)) {
|
||||
removeCookie(name)
|
||||
}
|
||||
window.document.cookie = `${name}=${value}; Path=/;`
|
||||
}
|
||||
|
||||
export function getCookie(cookieName) {
|
||||
return document.cookie.split(";").some(cookie => {
|
||||
return cookie.trim().startsWith(`${cookieName}=`)
|
||||
})
|
||||
const value = `; ${document.cookie}`
|
||||
const parts = value.split(`; ${cookieName}=`)
|
||||
if (parts.length === 2) {
|
||||
return parts[1].split(";").shift()
|
||||
}
|
||||
}
|
||||
|
||||
export function removeCookie(cookieName) {
|
||||
if (getCookie(cookieName)) {
|
||||
document.cookie = `${cookieName}=; Max-Age=-99999999;`
|
||||
document.cookie = `${cookieName}=; Max-Age=-99999999; Path=/;`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { getFrontendStore } from "./store/frontend"
|
||||
import { getAutomationStore } from "./store/automation"
|
||||
import { getHostingStore } from "./store/hosting"
|
||||
import { getThemeStore } from "./store/theme"
|
||||
import { derived, writable } from "svelte/store"
|
||||
import { FrontendTypes, LAYOUT_NAMES } from "../constants"
|
||||
|
@ -9,7 +8,6 @@ import { findComponent } from "./componentUtils"
|
|||
export const store = getFrontendStore()
|
||||
export const automationStore = getAutomationStore()
|
||||
export const themeStore = getThemeStore()
|
||||
export const hostingStore = getHostingStore()
|
||||
|
||||
export const currentAsset = derived(store, $store => {
|
||||
const type = $store.currentFrontEndType
|
||||
|
|
|
@ -2,7 +2,6 @@ import { get, writable } from "svelte/store"
|
|||
import { cloneDeep } from "lodash/fp"
|
||||
import {
|
||||
allScreens,
|
||||
hostingStore,
|
||||
currentAsset,
|
||||
mainLayout,
|
||||
selectedComponent,
|
||||
|
@ -100,7 +99,6 @@ export const getFrontendStore = () => {
|
|||
version: application.version,
|
||||
revertableVersion: application.revertableVersion,
|
||||
}))
|
||||
await hostingStore.actions.fetch()
|
||||
|
||||
// Initialise backend stores
|
||||
const [_integrations] = await Promise.all([
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import { writable } from "svelte/store"
|
||||
import api, { get } from "../api"
|
||||
|
||||
const INITIAL_HOSTING_UI_STATE = {
|
||||
appUrl: "",
|
||||
deployedApps: {},
|
||||
deployedAppNames: [],
|
||||
deployedAppUrls: [],
|
||||
}
|
||||
|
||||
export const getHostingStore = () => {
|
||||
const store = writable({ ...INITIAL_HOSTING_UI_STATE })
|
||||
store.actions = {
|
||||
fetch: async () => {
|
||||
const response = await api.get("/api/hosting/urls")
|
||||
const urls = await response.json()
|
||||
store.update(state => {
|
||||
state.appUrl = urls.app
|
||||
return state
|
||||
})
|
||||
},
|
||||
fetchDeployedApps: async () => {
|
||||
let deployments = await (await get("/api/hosting/apps")).json()
|
||||
store.update(state => {
|
||||
state.deployedApps = deployments
|
||||
state.deployedAppNames = Object.values(deployments).map(app => app.name)
|
||||
state.deployedAppUrls = Object.values(deployments).map(app => app.url)
|
||||
return state
|
||||
})
|
||||
return deployments
|
||||
},
|
||||
}
|
||||
return store
|
||||
}
|
|
@ -22,8 +22,10 @@
|
|||
RelationshipTypes,
|
||||
ALLOWABLE_STRING_OPTIONS,
|
||||
ALLOWABLE_NUMBER_OPTIONS,
|
||||
ALLOWABLE_JSON_OPTIONS,
|
||||
ALLOWABLE_STRING_TYPES,
|
||||
ALLOWABLE_NUMBER_TYPES,
|
||||
ALLOWABLE_JSON_TYPES,
|
||||
SWITCHABLE_TYPES,
|
||||
} from "constants/backend"
|
||||
import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils"
|
||||
|
@ -126,7 +128,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
field.name = originalName
|
||||
}
|
||||
|
||||
function deleteColumn() {
|
||||
field.name = deleteColName
|
||||
if (field.name === $tables.selected.primaryDisplay) {
|
||||
notifications.error("You cannot delete the display column")
|
||||
} else {
|
||||
|
@ -145,6 +152,7 @@
|
|||
delete field.subtype
|
||||
delete field.tableId
|
||||
delete field.relationshipType
|
||||
delete field.formulaType
|
||||
|
||||
// Add in defaults and initial definition
|
||||
const definition = fieldDefinitions[event.detail?.toUpperCase()]
|
||||
|
@ -156,6 +164,9 @@
|
|||
if (field.type === LINK_TYPE) {
|
||||
field.relationshipType = RelationshipTypes.MANY_TO_MANY
|
||||
}
|
||||
if (field.type === FORMULA_TYPE) {
|
||||
field.formulaType = "dynamic"
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeRequired(e) {
|
||||
|
@ -236,6 +247,11 @@
|
|||
ALLOWABLE_NUMBER_TYPES.indexOf(field.type) !== -1
|
||||
) {
|
||||
return ALLOWABLE_NUMBER_OPTIONS
|
||||
} else if (
|
||||
originalName &&
|
||||
ALLOWABLE_JSON_TYPES.indexOf(field.type) !== -1
|
||||
) {
|
||||
return ALLOWABLE_JSON_OPTIONS
|
||||
} else if (!external) {
|
||||
return [
|
||||
...Object.values(fieldDefinitions),
|
||||
|
@ -307,6 +323,7 @@
|
|||
title={originalName ? "Edit Column" : "Create Column"}
|
||||
confirmText="Save Column"
|
||||
onConfirm={saveColumn}
|
||||
onCancel={cancelEdit}
|
||||
disabled={invalid}
|
||||
>
|
||||
<Input
|
||||
|
@ -425,8 +442,22 @@
|
|||
error={errors.relatedName}
|
||||
/>
|
||||
{:else if field.type === FORMULA_TYPE}
|
||||
{#if !table.sql}
|
||||
<Select
|
||||
label="Formula type"
|
||||
bind:value={field.formulaType}
|
||||
options={[
|
||||
{ label: "Dynamic", value: "dynamic" },
|
||||
{ label: "Static", value: "static" },
|
||||
]}
|
||||
getOptionLabel={option => option.label}
|
||||
getOptionValue={option => option.value}
|
||||
tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered,
|
||||
while static formula are calculated when the row is saved."
|
||||
/>
|
||||
{/if}
|
||||
<ModalBindableInput
|
||||
title="Handlebars Formula"
|
||||
title="Formula"
|
||||
label="Formula"
|
||||
value={field.formula}
|
||||
on:change={e => (field.formula = e.detail)}
|
||||
|
@ -435,7 +466,7 @@
|
|||
/>
|
||||
{:else if field.type === AUTO_TYPE}
|
||||
<Select
|
||||
label="Auto Column Type"
|
||||
label="Auto column type"
|
||||
value={field.subtype}
|
||||
on:change={e => (field.subtype = e.detail)}
|
||||
options={Object.entries(getAutoColumnInformation())}
|
||||
|
@ -470,16 +501,16 @@
|
|||
onOk={deleteColumn}
|
||||
onCancel={hideDeleteDialog}
|
||||
title="Confirm Deletion"
|
||||
disabled={deleteColName !== field.name}
|
||||
disabled={deleteColName !== originalName}
|
||||
>
|
||||
<p>
|
||||
Are you sure you wish to delete the column <b>{field.name}?</b>
|
||||
Are you sure you wish to delete the column <b>{originalName}?</b>
|
||||
Your data will be deleted and this action cannot be undone - enter the column
|
||||
name to confirm.
|
||||
</p>
|
||||
<Input
|
||||
dataCy="delete-column-confirm"
|
||||
bind:value={deleteColName}
|
||||
placeholder={field.name}
|
||||
placeholder={originalName}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
Select,
|
||||
Body,
|
||||
Layout,
|
||||
ActionButton,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount, createEventDispatcher } from "svelte"
|
||||
import { FIELDS } from "constants/backend"
|
||||
|
@ -20,8 +21,8 @@
|
|||
let dispatcher = createEventDispatcher()
|
||||
let mode = "Form"
|
||||
let fieldCount = 0
|
||||
let fieldKeys = {},
|
||||
fieldTypes = {}
|
||||
let fieldKeys = [],
|
||||
fieldTypes = []
|
||||
let keyValueOptions = [
|
||||
{ label: "String", value: FIELDS.STRING.type },
|
||||
{ label: "Number", value: FIELDS.NUMBER.type },
|
||||
|
@ -50,27 +51,48 @@
|
|||
if (!schema) {
|
||||
schema = {}
|
||||
}
|
||||
let i = 0
|
||||
for (let [key, value] of Object.entries(schema)) {
|
||||
fieldKeys[i] = key
|
||||
fieldTypes[i] = value.type
|
||||
i++
|
||||
// find the entries which aren't in the list
|
||||
const schemaEntries = Object.entries(schema).filter(
|
||||
([key]) => !fieldKeys.includes(key)
|
||||
)
|
||||
for (let [key, value] of schemaEntries) {
|
||||
fieldKeys.push(key)
|
||||
fieldTypes.push(value.type)
|
||||
}
|
||||
fieldCount = i
|
||||
fieldCount = fieldKeys.length
|
||||
}
|
||||
|
||||
function saveSchema() {
|
||||
for (let i of Object.keys(fieldKeys)) {
|
||||
const key = fieldKeys[i]
|
||||
const newSchema = {}
|
||||
for (let [index, key] of fieldKeys.entries()) {
|
||||
// they were added to schema, rather than generated
|
||||
if (!schema[key]) {
|
||||
schema[key] = {
|
||||
type: fieldTypes[i],
|
||||
newSchema[key] = {
|
||||
...schema[key],
|
||||
type: fieldTypes[index],
|
||||
}
|
||||
}
|
||||
dispatcher("save", { schema: newSchema, json })
|
||||
schema = newSchema
|
||||
}
|
||||
|
||||
dispatcher("save", { schema, json })
|
||||
function removeKey(index) {
|
||||
const keyToRemove = fieldKeys[index]
|
||||
if (fieldKeys[index + 1] != null) {
|
||||
fieldKeys[index] = fieldKeys[index + 1]
|
||||
fieldTypes[index] = fieldTypes[index + 1]
|
||||
}
|
||||
fieldKeys.splice(index, 1)
|
||||
fieldTypes.splice(index, 1)
|
||||
fieldCount--
|
||||
if (json) {
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
delete parsed[keyToRemove]
|
||||
json = JSON.stringify(parsed, null, 2)
|
||||
} catch (err) {
|
||||
// json not valid, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
@ -97,6 +119,7 @@
|
|||
getOptionValue={field => field.value}
|
||||
getOptionLabel={field => field.label}
|
||||
/>
|
||||
<ActionButton icon="Close" quiet on:click={() => removeKey(i)} />
|
||||
</div>
|
||||
{/each}
|
||||
<div class:add-field-btn={fieldCount !== 0}>
|
||||
|
@ -118,9 +141,9 @@
|
|||
<style>
|
||||
.horizontal {
|
||||
display: grid;
|
||||
grid-template-columns: 30% 1fr;
|
||||
grid-template-columns: 30% 1fr 40px;
|
||||
grid-gap: var(--spacing-s);
|
||||
align-items: center;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.add-field-btn {
|
||||
|
|
|
@ -6,9 +6,12 @@
|
|||
export let datasource
|
||||
|
||||
let name = ""
|
||||
let submitted = false
|
||||
$: valid = name && name.length > 0 && !datasource?.entities[name]
|
||||
$: error =
|
||||
name && datasource?.entities[name] ? "Table name already in use." : null
|
||||
!submitted && name && datasource?.entities[name]
|
||||
? "Table name already in use."
|
||||
: null
|
||||
|
||||
function buildDefaultTable(tableName, datasourceId) {
|
||||
return {
|
||||
|
@ -26,6 +29,7 @@
|
|||
}
|
||||
|
||||
async function saveTable() {
|
||||
submitted = true
|
||||
const table = await tables.save(buildDefaultTable(name, datasource._id))
|
||||
await datasources.fetch()
|
||||
$goto(`../../table/${table._id}`)
|
||||
|
|
|
@ -188,7 +188,7 @@
|
|||
{:else}
|
||||
<Body size="S"><i>No tables found.</i></Body>
|
||||
{/if}
|
||||
{#if plusTables?.length !== 0}
|
||||
{#if plusTables?.length !== 0 && integration.relationships}
|
||||
<Divider size="S" />
|
||||
<div class="query-header">
|
||||
<Heading size="S">Relationships</Heading>
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
<script>
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import GoogleLogo from "assets/google-logo.png"
|
||||
import { store } from "builderStore"
|
||||
import { auth } from "stores/portal"
|
||||
|
||||
export let preAuthStep
|
||||
export let datasource
|
||||
|
||||
$: tenantId = $auth.tenantId
|
||||
</script>
|
||||
|
||||
<ActionButton
|
||||
on:click={async () => {
|
||||
let ds = datasource
|
||||
if (!ds) {
|
||||
ds = await preAuthStep()
|
||||
}
|
||||
window.open(
|
||||
`/api/global/auth/${tenantId}/datasource/google?datasourceId=${datasource._id}&appId=${$store.appId}`,
|
||||
"_blank"
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div class="inner">
|
||||
<img src={GoogleLogo} alt="google icon" />
|
||||
<p>Sign in with Google</p>
|
||||
</div>
|
||||
</ActionButton>
|
||||
|
||||
<style>
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: var(--spacing-xs);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
}
|
||||
.inner img {
|
||||
width: 18px;
|
||||
margin: 3px 10px 3px 3px;
|
||||
}
|
||||
.inner p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,184 @@
|
|||
<script>
|
||||
export let width = "100"
|
||||
export let height = "100"
|
||||
</script>
|
||||
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
version="1.0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 50 80"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-1"
|
||||
/>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-3"
|
||||
/>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-5"
|
||||
/>
|
||||
<linearGradient
|
||||
x1="50.0053945%"
|
||||
y1="8.58610612%"
|
||||
x2="50.0053945%"
|
||||
y2="100.013939%"
|
||||
id="linearGradient-7"
|
||||
>
|
||||
<stop stop-color="#263238" stop-opacity="0.2" offset="0%" />
|
||||
<stop stop-color="#263238" stop-opacity="0.02" offset="100%" />
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-8"
|
||||
/>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-10"
|
||||
/>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-12"
|
||||
/>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="path-14"
|
||||
/>
|
||||
<radialGradient
|
||||
cx="3.16804688%"
|
||||
cy="2.71744318%"
|
||||
fx="3.16804688%"
|
||||
fy="2.71744318%"
|
||||
r="161.248516%"
|
||||
gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.727273),translate(-0.031680,-0.027174)"
|
||||
id="radialGradient-16"
|
||||
>
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%" />
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0" offset="100%" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g
|
||||
id="Consumer-Apps-Sheets-Large-VD-R8-"
|
||||
transform="translate(-451.000000, -451.000000)"
|
||||
>
|
||||
<g id="Hero" transform="translate(0.000000, 63.000000)">
|
||||
<g id="Personal" transform="translate(277.000000, 299.000000)">
|
||||
<g id="Sheets-icon" transform="translate(174.833333, 89.958333)">
|
||||
<g id="Group">
|
||||
<g id="Clipped">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L36.9791667,10.3541667 L29.5833333,0 Z"
|
||||
id="Path"
|
||||
fill="#0F9D58"
|
||||
fill-rule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
/>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlink:href="#path-3" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<path
|
||||
d="M11.8333333,31.8020833 L11.8333333,53.25 L35.5,53.25 L35.5,31.8020833 L11.8333333,31.8020833 Z M22.1875,50.2916667 L14.7916667,50.2916667 L14.7916667,46.59375 L22.1875,46.59375 L22.1875,50.2916667 Z M22.1875,44.375 L14.7916667,44.375 L14.7916667,40.6770833 L22.1875,40.6770833 L22.1875,44.375 Z M22.1875,38.4583333 L14.7916667,38.4583333 L14.7916667,34.7604167 L22.1875,34.7604167 L22.1875,38.4583333 Z M32.5416667,50.2916667 L25.1458333,50.2916667 L25.1458333,46.59375 L32.5416667,46.59375 L32.5416667,50.2916667 Z M32.5416667,44.375 L25.1458333,44.375 L25.1458333,40.6770833 L32.5416667,40.6770833 L32.5416667,44.375 Z M32.5416667,38.4583333 L25.1458333,38.4583333 L25.1458333,34.7604167 L32.5416667,34.7604167 L32.5416667,38.4583333 Z"
|
||||
id="Shape"
|
||||
fill="#F1F1F1"
|
||||
fill-rule="nonzero"
|
||||
mask="url(#mask-4)"
|
||||
/>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-6" fill="white">
|
||||
<use xlink:href="#path-5" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<polygon
|
||||
id="Path"
|
||||
fill="url(#linearGradient-7)"
|
||||
fill-rule="nonzero"
|
||||
mask="url(#mask-6)"
|
||||
points="30.8813021 16.4520313 47.3333333 32.9003646 47.3333333 17.75"
|
||||
/>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-9" fill="white">
|
||||
<use xlink:href="#path-8" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<g id="Group" mask="url(#mask-9)">
|
||||
<g transform="translate(26.625000, -2.958333)">
|
||||
<path
|
||||
d="M2.95833333,2.95833333 L2.95833333,16.2708333 C2.95833333,18.7225521 4.94411458,20.7083333 7.39583333,20.7083333 L20.7083333,20.7083333 L2.95833333,2.95833333 Z"
|
||||
id="Path"
|
||||
fill="#87CEAC"
|
||||
fill-rule="nonzero"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-11" fill="white">
|
||||
<use xlink:href="#path-10" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<path
|
||||
d="M4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,4.80729167 C0,2.36666667 1.996875,0.369791667 4.4375,0.369791667 L29.5833333,0.369791667 L29.5833333,0 L4.4375,0 Z"
|
||||
id="Path"
|
||||
fill-opacity="0.2"
|
||||
fill="#FFFFFF"
|
||||
fill-rule="nonzero"
|
||||
mask="url(#mask-11)"
|
||||
/>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-13" fill="white">
|
||||
<use xlink:href="#path-12" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<path
|
||||
d="M42.8958333,64.7135417 L4.4375,64.7135417 C1.996875,64.7135417 0,62.7166667 0,60.2760417 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,60.2760417 C47.3333333,62.7166667 45.3364583,64.7135417 42.8958333,64.7135417 Z"
|
||||
id="Path"
|
||||
fill-opacity="0.2"
|
||||
fill="#263238"
|
||||
fill-rule="nonzero"
|
||||
mask="url(#mask-13)"
|
||||
/>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-15" fill="white">
|
||||
<use xlink:href="#path-14" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<path
|
||||
d="M34.0208333,17.75 C31.5691146,17.75 29.5833333,15.7642188 29.5833333,13.3125 L29.5833333,13.6822917 C29.5833333,16.1340104 31.5691146,18.1197917 34.0208333,18.1197917 L47.3333333,18.1197917 L47.3333333,17.75 L34.0208333,17.75 Z"
|
||||
id="Path"
|
||||
fill-opacity="0.1"
|
||||
fill="#263238"
|
||||
fill-rule="nonzero"
|
||||
mask="url(#mask-15)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
|
||||
id="Path"
|
||||
fill="url(#radialGradient-16)"
|
||||
fill-rule="nonzero"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
|
@ -11,6 +11,7 @@ import ArangoDB from "./ArangoDB.svelte"
|
|||
import Rest from "./Rest.svelte"
|
||||
import Budibase from "./Budibase.svelte"
|
||||
import Oracle from "./Oracle.svelte"
|
||||
import GoogleSheets from "./GoogleSheets.svelte"
|
||||
|
||||
export default {
|
||||
BUDIBASE: Budibase,
|
||||
|
@ -26,4 +27,5 @@ export default {
|
|||
ARANGODB: ArangoDB,
|
||||
REST: Rest,
|
||||
ORACLE: Oracle,
|
||||
GOOGLE_SHEETS: GoogleSheets,
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
import { IntegrationNames, IntegrationTypes } from "constants/backend"
|
||||
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
|
||||
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
|
||||
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte"
|
||||
import { createRestDatasource } from "builderStore/datasource"
|
||||
import { goto } from "@roxi/routify"
|
||||
import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte"
|
||||
|
@ -38,6 +39,7 @@
|
|||
plus: selected.plus,
|
||||
config,
|
||||
schema: selected.datasource,
|
||||
auth: selected.auth,
|
||||
}
|
||||
checkShowImport()
|
||||
}
|
||||
|
@ -79,7 +81,11 @@
|
|||
</Modal>
|
||||
|
||||
<Modal bind:this={externalDatasourceModal}>
|
||||
{#if integration?.auth?.type === "google"}
|
||||
<GoogleDatasourceConfigModal {integration} {modal} />
|
||||
{:else}
|
||||
<DatasourceConfigModal {integration} {modal} />
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={importModal}>
|
||||
|
|
|
@ -51,13 +51,9 @@
|
|||
>Connect your database to Budibase using the config below.
|
||||
</Body>
|
||||
</Layout>
|
||||
|
||||
<IntegrationConfigForm
|
||||
schema={datasource.schema}
|
||||
bind:datasource
|
||||
creating={true}
|
||||
/>
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
<script>
|
||||
import { ModalContent, Body, Layout } from "@budibase/bbui"
|
||||
import { IntegrationNames } from "constants/backend"
|
||||
import cloneDeep from "lodash/cloneDeepWith"
|
||||
import GoogleButton from "../_components/GoogleButton.svelte"
|
||||
import { saveDatasource as save } from "builderStore/datasource"
|
||||
|
||||
export let integration
|
||||
export let modal
|
||||
|
||||
// kill the reference so the input isn't saved
|
||||
let datasource = cloneDeep(integration)
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
title={`Connect to ${IntegrationNames[datasource.type]}`}
|
||||
onCancel={() => modal.show()}
|
||||
cancelText="Back"
|
||||
size="L"
|
||||
>
|
||||
<Layout noPadding>
|
||||
<Body size="XS"
|
||||
>Authenticate with your google account to use the {IntegrationNames[
|
||||
datasource.type
|
||||
]} integration.</Body
|
||||
>
|
||||
</Layout>
|
||||
<GoogleButton preAuthStep={() => save(datasource, true)} />
|
||||
</ModalContent>
|
|
@ -53,7 +53,9 @@
|
|||
}
|
||||
|
||||
// Create table
|
||||
const table = await tables.save(newTable)
|
||||
let table
|
||||
try {
|
||||
table = await tables.save(newTable)
|
||||
notifications.success(`Table ${name} created successfully.`)
|
||||
analytics.captureEvent(Events.TABLE.CREATED, { name })
|
||||
|
||||
|
@ -63,6 +65,11 @@
|
|||
? `./table/${table._id}`
|
||||
: `../../table/${table._id}`
|
||||
$goto(path)
|
||||
} catch (e) {
|
||||
notifications.error(e)
|
||||
// reload in case the table was created
|
||||
await tables.fetch()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import api from "builderStore/api"
|
||||
import { notifications } from "@budibase/bbui"
|
||||
import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte"
|
||||
import { store, hostingStore } from "builderStore"
|
||||
import { store } from "builderStore"
|
||||
|
||||
const DeploymentStatus = {
|
||||
SUCCESS: "SUCCESS",
|
||||
|
@ -37,7 +37,7 @@
|
|||
let poll
|
||||
let deployments = []
|
||||
let urlComponent = $store.url || `/${appId}`
|
||||
let deploymentUrl = `${$hostingStore.appUrl}${urlComponent}`
|
||||
let deploymentUrl = `${urlComponent}`
|
||||
|
||||
const formatDate = (date, format) =>
|
||||
Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
import { Select } from "@budibase/bbui"
|
||||
import { store } from "builderStore"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
const themeOptions = [
|
||||
{
|
||||
|
@ -20,6 +21,17 @@
|
|||
value: "spectrum--darkest",
|
||||
},
|
||||
]
|
||||
|
||||
const onChangeTheme = async theme => {
|
||||
await store.actions.theme.save(theme)
|
||||
await store.actions.customTheme.save({
|
||||
...get(store).customTheme,
|
||||
navBackground:
|
||||
theme === "spectrum--light"
|
||||
? "var(--spectrum-global-color-gray-50)"
|
||||
: "var(--spectrum-global-color-gray-100)",
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
|
@ -27,7 +39,7 @@
|
|||
value={$store.theme}
|
||||
options={themeOptions}
|
||||
placeholder={null}
|
||||
on:change={e => store.actions.theme.save(e.detail)}
|
||||
on:change={e => onChangeTheme(e.detail)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
primaryColor: "var(--spectrum-global-color-blue-600)",
|
||||
primaryColorHover: "var(--spectrum-global-color-blue-500)",
|
||||
buttonBorderRadius: "16px",
|
||||
navBackground: "var(--spectrum-global-color-gray-100)",
|
||||
navBackground: "var(--spectrum-global-color-gray-50)",
|
||||
navTextColor: "var(--spectrum-global-color-gray-800)",
|
||||
}
|
||||
|
||||
|
@ -52,7 +52,14 @@
|
|||
}
|
||||
|
||||
const resetTheme = () => {
|
||||
store.actions.customTheme.save(null)
|
||||
const theme = get(store).theme
|
||||
store.actions.customTheme.save({
|
||||
...defaultTheme,
|
||||
navBackground:
|
||||
theme === "spectrum--light"
|
||||
? "var(--spectrum-global-color-gray-50)"
|
||||
: "var(--spectrum-global-color-gray-100)",
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -44,7 +44,8 @@
|
|||
"relationshipfield",
|
||||
"daterangepicker",
|
||||
"multifieldselect",
|
||||
"jsonfield"
|
||||
"jsonfield",
|
||||
"s3upload"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
@ -55,8 +55,8 @@
|
|||
<div>
|
||||
<BindingBuilder
|
||||
bind:customParams={parameters.queryParams}
|
||||
bindings={query.parameters}
|
||||
bind:bindableOptions={bindings}
|
||||
queryBindings={query.parameters}
|
||||
bind:bindings
|
||||
/>
|
||||
<IntegrationQueryEditor
|
||||
height={200}
|
||||
|
|
|
@ -1,13 +1,38 @@
|
|||
<script>
|
||||
import { Body } from "@budibase/bbui"
|
||||
import { Label, Body, Layout } from "@budibase/bbui"
|
||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||
|
||||
export let parameters
|
||||
export let bindings = []
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<Body size="S">This action doesn't require any additional settings.</Body>
|
||||
<Layout noPadding gap="M">
|
||||
<Body size="S">
|
||||
Please enter the URL you would like to be redirected to after logging out.
|
||||
If you don't enter a value, you'll be redirected to the login screen.
|
||||
</Body>
|
||||
<div class="content">
|
||||
<Label small>Redirect URL</Label>
|
||||
<DrawerBindableInput
|
||||
title="Return URL"
|
||||
value={parameters.redirectUrl}
|
||||
on:change={value => (parameters.redirectUrl = value.detail)}
|
||||
{bindings}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.content {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
<script>
|
||||
import { Select, Label } from "@budibase/bbui"
|
||||
import { currentAsset } from "builderStore"
|
||||
import { findAllMatchingComponents } from "builderStore/componentUtils"
|
||||
|
||||
export let parameters
|
||||
|
||||
$: components = findAllMatchingComponents($currentAsset.props, component =>
|
||||
component._component.endsWith("s3upload")
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<Label small>S3 Upload Component</Label>
|
||||
<Select
|
||||
bind:value={parameters.componentId}
|
||||
options={components}
|
||||
getOptionLabel={x => x._instanceName}
|
||||
getOptionValue={x => x._id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: grid;
|
||||
column-gap: var(--spacing-l);
|
||||
row-gap: var(--spacing-s);
|
||||
grid-template-columns: 120px 1fr;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
|
@ -11,3 +11,4 @@ export { default as ChangeFormStep } from "./ChangeFormStep.svelte"
|
|||
export { default as UpdateState } from "./UpdateState.svelte"
|
||||
export { default as RefreshDataProvider } from "./RefreshDataProvider.svelte"
|
||||
export { default as DuplicateRow } from "./DuplicateRow.svelte"
|
||||
export { default as S3Upload } from "./S3Upload.svelte"
|
||||
|
|
|
@ -70,6 +70,16 @@
|
|||
"name": "Update State",
|
||||
"component": "UpdateState",
|
||||
"dependsOnFeature": "state"
|
||||
},
|
||||
{
|
||||
"name": "Upload File to S3",
|
||||
"component": "S3Upload",
|
||||
"context": [
|
||||
{
|
||||
"label": "File URL",
|
||||
"value": "publicUrl"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -169,8 +169,8 @@
|
|||
{#if getQueryParams(value).length > 0}
|
||||
<BindingBuilder
|
||||
bind:customParams={tmpQueryParams}
|
||||
bindings={getQueryParams(value)}
|
||||
bind:bindableOptions={bindings}
|
||||
queryBindings={getQueryParams(value)}
|
||||
bind:bindings
|
||||
/>
|
||||
{/if}
|
||||
<IntegrationQueryEditor
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
|
||||
import { generate } from "shortid"
|
||||
import { getValidOperatorsForType, OperatorOptions } from "constants/lucene"
|
||||
import { getFields } from "helpers/searchFields"
|
||||
|
||||
export let schemaFields
|
||||
export let filters = []
|
||||
|
@ -21,11 +22,8 @@
|
|||
export let panel = ClientBindingPanel
|
||||
export let allowBindings = true
|
||||
|
||||
const BannedTypes = ["link", "attachment", "formula", "json", "jsonarray"]
|
||||
|
||||
$: fieldOptions = (schemaFields ?? [])
|
||||
.filter(field => !BannedTypes.includes(field.type))
|
||||
.map(field => field.name)
|
||||
$: enrichedSchemaFields = getFields(schemaFields || [])
|
||||
$: fieldOptions = enrichedSchemaFields.map(field => field.name) || []
|
||||
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
|
||||
|
||||
const addFilter = () => {
|
||||
|
@ -53,7 +51,7 @@
|
|||
|
||||
const onFieldChange = (expression, field) => {
|
||||
// Update the field type
|
||||
expression.type = schemaFields.find(x => x.name === field)?.type
|
||||
expression.type = enrichedSchemaFields.find(x => x.name === field)?.type
|
||||
|
||||
// Ensure a valid operator is set
|
||||
const validOperators = getValidOperatorsForType(expression.type).map(
|
||||
|
@ -85,7 +83,7 @@
|
|||
}
|
||||
|
||||
const getFieldOptions = field => {
|
||||
const schema = schemaFields.find(x => x.name === field)
|
||||
const schema = enrichedSchemaFields.find(x => x.name === field)
|
||||
return schema?.constraints?.inclusion || []
|
||||
}
|
||||
</script>
|
||||
|
@ -133,7 +131,7 @@
|
|||
{bindings}
|
||||
on:change={event => (filter.value = event.detail)}
|
||||
/>
|
||||
{:else if ["string", "longform", "number"].includes(filter.type)}
|
||||
{:else if ["string", "longform", "number", "formula"].includes(filter.type)}
|
||||
<Input disabled={filter.noValue} bind:value={filter.value} />
|
||||
{:else if ["options", "array"].includes(filter.type)}
|
||||
<Combobox
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<script>
|
||||
import { Select } from "@budibase/bbui"
|
||||
import { datasources } from "stores/backend"
|
||||
|
||||
export let value = null
|
||||
|
||||
$: dataSources = $datasources.list
|
||||
.filter(ds => ds.source === "S3" && !ds.config?.endpoint)
|
||||
.map(ds => ({
|
||||
label: ds.name,
|
||||
value: ds._id,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<Select options={dataSources} {value} on:change />
|
|
@ -0,0 +1,47 @@
|
|||
<script>
|
||||
import { Multiselect } from "@budibase/bbui"
|
||||
import {
|
||||
getDatasourceForProvider,
|
||||
getSchemaForDatasource,
|
||||
} from "builderStore/dataBinding"
|
||||
import { currentAsset } from "builderStore"
|
||||
import { tables } from "stores/backend"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { getFields } from "helpers/searchFields"
|
||||
|
||||
export let componentInstance = {}
|
||||
export let value = ""
|
||||
export let placeholder
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
|
||||
$: schema = getSchemaForDatasource($currentAsset, datasource).schema
|
||||
$: options = getOptions(datasource, schema || {})
|
||||
$: boundValue = getSelectedOption(value, options)
|
||||
|
||||
function getOptions(ds, dsSchema) {
|
||||
let base = Object.values(dsSchema)
|
||||
if (!ds?.tableId) {
|
||||
return base
|
||||
}
|
||||
const currentTable = $tables.list.find(table => table._id === ds.tableId)
|
||||
return getFields(base, { allowLinks: currentTable.sql }).map(
|
||||
field => field.name
|
||||
)
|
||||
}
|
||||
|
||||
function getSelectedOption(selectedOptions, allOptions) {
|
||||
// Fix the hardcoded default string value
|
||||
if (!Array.isArray(selectedOptions)) {
|
||||
selectedOptions = []
|
||||
}
|
||||
return selectedOptions.filter(val => allOptions.indexOf(val) !== -1)
|
||||
}
|
||||
|
||||
const setValue = value => {
|
||||
boundValue = getSelectedOption(value.detail, options)
|
||||
dispatch("change", boundValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Multiselect {placeholder} value={boundValue} on:change={setValue} {options} />
|
|
@ -1,5 +1,6 @@
|
|||
import { Checkbox, Select, Stepper } from "@budibase/bbui"
|
||||
import DataSourceSelect from "./DataSourceSelect.svelte"
|
||||
import S3DataSourceSelect from "./S3DataSourceSelect.svelte"
|
||||
import DataProviderSelect from "./DataProviderSelect.svelte"
|
||||
import ButtonActionEditor from "./ButtonActionEditor/ButtonActionEditor.svelte"
|
||||
import TableSelect from "./TableSelect.svelte"
|
||||
|
@ -7,6 +8,7 @@ import ColorPicker from "./ColorPicker.svelte"
|
|||
import { IconSelect } from "./IconSelect"
|
||||
import FieldSelect from "./FieldSelect.svelte"
|
||||
import MultiFieldSelect from "./MultiFieldSelect.svelte"
|
||||
import SearchFieldSelect from "./SearchFieldSelect.svelte"
|
||||
import SchemaSelect from "./SchemaSelect.svelte"
|
||||
import SectionSelect from "./SectionSelect.svelte"
|
||||
import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte"
|
||||
|
@ -21,6 +23,7 @@ const componentMap = {
|
|||
text: DrawerBindableCombobox,
|
||||
select: Select,
|
||||
dataSource: DataSourceSelect,
|
||||
"dataSource/s3": S3DataSourceSelect,
|
||||
dataProvider: DataProviderSelect,
|
||||
boolean: Checkbox,
|
||||
number: Stepper,
|
||||
|
@ -30,6 +33,7 @@ const componentMap = {
|
|||
icon: IconSelect,
|
||||
field: FieldSelect,
|
||||
multifield: MultiFieldSelect,
|
||||
searchfield: SearchFieldSelect,
|
||||
options: OptionsEditor,
|
||||
schema: SchemaSelect,
|
||||
section: SectionSelect,
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
<script>
|
||||
import { Label, Select } from "@budibase/bbui"
|
||||
import { permissions, roles } from "stores/backend"
|
||||
import { onMount } from "svelte"
|
||||
import { Roles } from "constants/backend"
|
||||
|
||||
export let query
|
||||
export let saveId
|
||||
export let label
|
||||
|
||||
$: updateRole(roleId, saveId)
|
||||
$: getPermissions(query)
|
||||
|
||||
let roleId, loaded
|
||||
let roleId, loaded, fetched
|
||||
|
||||
async function updateRole(role, id) {
|
||||
roleId = role
|
||||
|
@ -26,19 +24,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!query || !query._id) {
|
||||
async function getPermissions(queryToFetch) {
|
||||
if (fetched?._id === queryToFetch?._id) {
|
||||
return
|
||||
}
|
||||
fetched = queryToFetch
|
||||
if (!queryToFetch || !queryToFetch._id) {
|
||||
roleId = Roles.BASIC
|
||||
loaded = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
roleId = (await permissions.forResource(query._id))["read"]
|
||||
roleId = (await permissions.forResource(queryToFetch._id))["read"]
|
||||
} catch (err) {
|
||||
roleId = Roles.BASIC
|
||||
}
|
||||
loaded = true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loaded}
|
||||
|
|
|
@ -7,27 +7,24 @@
|
|||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||
|
||||
export let bindable = true
|
||||
export let queryBindings = []
|
||||
export let bindings = []
|
||||
export let bindableOptions = []
|
||||
export let customParams = {}
|
||||
|
||||
function newQueryBinding() {
|
||||
bindings = [...bindings, {}]
|
||||
queryBindings = [...queryBindings, {}]
|
||||
}
|
||||
|
||||
function deleteQueryBinding(idx) {
|
||||
bindings.splice(idx, 1)
|
||||
bindings = bindings
|
||||
queryBindings.splice(idx, 1)
|
||||
queryBindings = queryBindings
|
||||
}
|
||||
|
||||
// This is necessary due to the way readable and writable bindings are stored.
|
||||
// The readable binding in the UI gets converted to a UUID value that the client understands
|
||||
// for parsing, then converted back so we can display it the readable form in the UI
|
||||
function onBindingChange(param, valueToParse) {
|
||||
customParams[param] = readableToRuntimeBinding(
|
||||
bindableOptions,
|
||||
valueToParse
|
||||
)
|
||||
customParams[param] = readableToRuntimeBinding(bindings, valueToParse)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -49,7 +46,7 @@
|
|||
{/if}
|
||||
</Body>
|
||||
<div class="bindings" class:bindable>
|
||||
{#each bindings as binding, idx}
|
||||
{#each queryBindings as binding, idx}
|
||||
<Input
|
||||
placeholder="Binding Name"
|
||||
thin
|
||||
|
@ -69,10 +66,10 @@
|
|||
thin
|
||||
on:change={evt => onBindingChange(binding.name, evt.detail)}
|
||||
value={runtimeToReadableBinding(
|
||||
bindableOptions,
|
||||
bindings,
|
||||
customParams?.[binding.name]
|
||||
)}
|
||||
{bindableOptions}
|
||||
{bindings}
|
||||
/>
|
||||
{:else}
|
||||
<Icon hoverable name="Close" on:click={() => deleteQueryBinding(idx)} />
|
||||
|
|
|
@ -120,7 +120,7 @@
|
|||
config={integrationInfo.extra}
|
||||
/>
|
||||
{/if}
|
||||
<BindingBuilder bind:bindings={query.parameters} bindable={false} />
|
||||
<BindingBuilder bind:queryBindings={query.parameters} bindable={false} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if shouldShowQueryConfig}
|
||||
|
|
|
@ -1,101 +1,46 @@
|
|||
<script>
|
||||
import { writable, get as svelteGet } from "svelte/store"
|
||||
|
||||
import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
|
||||
import { store, automationStore, hostingStore } from "builderStore"
|
||||
import { admin, auth } from "stores/portal"
|
||||
import { string, mixed, object } from "yup"
|
||||
import { store, automationStore } from "builderStore"
|
||||
import { apps, admin, auth } from "stores/portal"
|
||||
import api, { get, post } from "builderStore/api"
|
||||
import analytics, { Events } from "analytics"
|
||||
import { onMount } from "svelte"
|
||||
import { capitalise } from "helpers"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { APP_NAME_REGEX } from "constants"
|
||||
import TemplateList from "./TemplateList.svelte"
|
||||
import { createValidationStore } from "helpers/validation/yup"
|
||||
import * as appValidation from "helpers/validation/yup/app"
|
||||
|
||||
export let template
|
||||
export let inline
|
||||
|
||||
const values = writable({ name: null })
|
||||
const errors = writable({})
|
||||
const touched = writable({})
|
||||
const validator = {
|
||||
name: string()
|
||||
.trim()
|
||||
.required("Your application must have a name")
|
||||
.matches(
|
||||
APP_NAME_REGEX,
|
||||
"App name must be letters, numbers and spaces only"
|
||||
),
|
||||
file: template?.fromFile
|
||||
? mixed().required("Please choose a file to import")
|
||||
: null,
|
||||
}
|
||||
|
||||
let submitting = false
|
||||
let valid = false
|
||||
let initialTemplateInfo = template?.fromFile || template?.key
|
||||
|
||||
$: checkValidity($values, validator)
|
||||
$: showTemplateSelection = !template && !initialTemplateInfo
|
||||
const values = writable({ name: "", url: null })
|
||||
const validation = createValidationStore()
|
||||
$: validation.check($values)
|
||||
|
||||
onMount(async () => {
|
||||
await hostingStore.actions.fetchDeployedApps()
|
||||
const existingAppNames = svelteGet(hostingStore).deployedAppNames
|
||||
validator.name = string()
|
||||
.trim()
|
||||
.required("Your application must have a name")
|
||||
.matches(APP_NAME_REGEX, "App name must be letters and numbers only")
|
||||
.test(
|
||||
"non-existing-app-name",
|
||||
"Another app with the same name already exists",
|
||||
value => {
|
||||
return !existingAppNames.some(
|
||||
appName => appName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
}
|
||||
)
|
||||
await setupValidation()
|
||||
})
|
||||
|
||||
const checkValidity = async (values, validator) => {
|
||||
const obj = object().shape(validator)
|
||||
Object.keys(validator).forEach(key => ($errors[key] = null))
|
||||
if (template?.fromFile && values.file == null) {
|
||||
valid = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await obj.validate(values, { abortEarly: false })
|
||||
} catch (validationErrors) {
|
||||
validationErrors.inner.forEach(error => {
|
||||
$errors[error.path] = capitalise(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
valid = await obj.isValid(values)
|
||||
const setupValidation = async () => {
|
||||
const applications = svelteGet(apps)
|
||||
appValidation.name(validation, { apps: applications })
|
||||
appValidation.url(validation, { apps: applications })
|
||||
appValidation.file(validation, { template })
|
||||
// init validation
|
||||
validation.check($values)
|
||||
}
|
||||
|
||||
async function createNewApp() {
|
||||
const templateToUse = Object.keys(template).length === 0 ? null : template
|
||||
submitting = true
|
||||
|
||||
// Check a template exists if we are important
|
||||
if (templateToUse?.fromFile && !$values.file) {
|
||||
$errors.file = "Please choose a file to import"
|
||||
valid = false
|
||||
submitting = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Create form data to create app
|
||||
let data = new FormData()
|
||||
data.append("name", $values.name.trim())
|
||||
data.append("useTemplate", templateToUse != null)
|
||||
if (templateToUse) {
|
||||
data.append("templateName", templateToUse.name)
|
||||
data.append("templateKey", templateToUse.key)
|
||||
if ($values.url) {
|
||||
data.append("url", $values.url.trim())
|
||||
}
|
||||
data.append("useTemplate", template != null)
|
||||
if (template) {
|
||||
data.append("templateName", template.name)
|
||||
data.append("templateKey", template.key)
|
||||
data.append("templateFile", $values.file)
|
||||
}
|
||||
|
||||
|
@ -109,7 +54,7 @@
|
|||
analytics.captureEvent(Events.APP.CREATED, {
|
||||
name: $values.name,
|
||||
appId: appJson.instance._id,
|
||||
templateToUse,
|
||||
templateToUse: template,
|
||||
})
|
||||
|
||||
// Select Correct Application/DB in prep for creating user
|
||||
|
@ -137,68 +82,51 @@
|
|||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error(error)
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
template = null
|
||||
await auth.setInitInfo({})
|
||||
// auto add slash to url
|
||||
$: {
|
||||
if ($values.url && !$values.url.startsWith("/")) {
|
||||
$values.url = `/${$values.url}`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showTemplateSelection}
|
||||
<ModalContent
|
||||
title={"Get started quickly"}
|
||||
showConfirmButton={false}
|
||||
size="L"
|
||||
onConfirm={() => {
|
||||
template = {}
|
||||
return false
|
||||
}}
|
||||
showCancelButton={!inline}
|
||||
showCloseIcon={!inline}
|
||||
>
|
||||
<TemplateList
|
||||
onSelect={(selected, { useImport } = {}) => {
|
||||
if (!selected) {
|
||||
template = useImport ? { fromFile: true } : {}
|
||||
return
|
||||
}
|
||||
template = selected
|
||||
}}
|
||||
/>
|
||||
</ModalContent>
|
||||
{:else}
|
||||
<ModalContent
|
||||
title={"Name your app"}
|
||||
<ModalContent
|
||||
title={"Create your app"}
|
||||
confirmText={template?.fromFile ? "Import app" : "Create app"}
|
||||
onConfirm={createNewApp}
|
||||
onCancel={inline ? onCancel : null}
|
||||
cancelText={inline ? "Back" : undefined}
|
||||
showCloseIcon={!inline}
|
||||
disabled={!valid}
|
||||
>
|
||||
disabled={!$validation.valid}
|
||||
>
|
||||
{#if template?.fromFile}
|
||||
<Dropzone
|
||||
error={$touched.file && $errors.file}
|
||||
error={$validation.touched.file && $validation.errors.file}
|
||||
gallery={false}
|
||||
label="File to import"
|
||||
value={[$values.file]}
|
||||
on:change={e => {
|
||||
$values.file = e.detail?.[0]
|
||||
$touched.file = true
|
||||
$validation.touched.file = true
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<Input
|
||||
bind:value={$values.name}
|
||||
error={$touched.name && $errors.name}
|
||||
on:blur={() => ($touched.name = true)}
|
||||
error={$validation.touched.name && $validation.errors.name}
|
||||
on:blur={() => ($validation.touched.name = true)}
|
||||
label="Name"
|
||||
placeholder={$auth.user.firstName
|
||||
? `${$auth.user.firstName}'s app`
|
||||
? `${$auth.user.firstName}s app`
|
||||
: "My app"}
|
||||
/>
|
||||
</ModalContent>
|
||||
{/if}
|
||||
<Input
|
||||
bind:value={$values.url}
|
||||
error={$validation.touched.url && $validation.errors.url}
|
||||
on:blur={() => ($validation.touched.url = true)}
|
||||
label="URL"
|
||||
placeholder={$values.name
|
||||
? "/" + encodeURIComponent($values.name).toLowerCase()
|
||||
: "/"}
|
||||
/>
|
||||
</ModalContent>
|
||||
|
|
|
@ -1,69 +0,0 @@
|
|||
<script>
|
||||
import { Heading, Layout, Icon } from "@budibase/bbui"
|
||||
|
||||
export let onSelect
|
||||
</script>
|
||||
|
||||
<Layout gap="XS" noPadding>
|
||||
<div class="template start-from-scratch" on:click={() => onSelect(null)}>
|
||||
<div
|
||||
class="background-icon"
|
||||
style={`background: rgb(50, 50, 50); color: white;`}
|
||||
>
|
||||
<Icon name="Add" />
|
||||
</div>
|
||||
<Heading size="XS">Start from scratch</Heading>
|
||||
<p class="detail">BLANK</p>
|
||||
</div>
|
||||
<div
|
||||
class="template import"
|
||||
on:click={() => onSelect(null, { useImport: true })}
|
||||
>
|
||||
<div
|
||||
class="background-icon"
|
||||
style={`background: rgb(50, 50, 50); color: white;`}
|
||||
>
|
||||
<Icon name="Add" />
|
||||
</div>
|
||||
<Heading size="XS">Import an app</Heading>
|
||||
<p class="detail">BLANK</p>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.background-icon {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.template {
|
||||
min-height: 60px;
|
||||
display: grid;
|
||||
grid-gap: var(--layout-s);
|
||||
grid-template-columns: auto 1fr auto;
|
||||
border: 1px solid #494949;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
background: var(--background-alt);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.detail {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.start-from-scratch {
|
||||
background: var(--spectrum-global-color-gray-50);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.import {
|
||||
background: var(--spectrum-global-color-gray-50);
|
||||
}
|
||||
</style>
|
|
@ -1,120 +1,75 @@
|
|||
<script>
|
||||
import { writable, get as svelteGet } from "svelte/store"
|
||||
import {
|
||||
notifications,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Body,
|
||||
} from "@budibase/bbui"
|
||||
import { hostingStore } from "builderStore"
|
||||
import { notifications, Input, ModalContent, Body } from "@budibase/bbui"
|
||||
import { apps } from "stores/portal"
|
||||
import { string, object } from "yup"
|
||||
import { onMount } from "svelte"
|
||||
import { capitalise } from "helpers"
|
||||
import { APP_NAME_REGEX } from "constants"
|
||||
|
||||
const values = writable({ name: null })
|
||||
const errors = writable({})
|
||||
const touched = writable({})
|
||||
const validator = {
|
||||
name: string()
|
||||
.trim()
|
||||
.required("Your application must have a name")
|
||||
.matches(
|
||||
APP_NAME_REGEX,
|
||||
"App name must be letters, numbers and spaces only"
|
||||
),
|
||||
}
|
||||
import { createValidationStore } from "helpers/validation/yup"
|
||||
import * as appValidation from "helpers/validation/yup/app"
|
||||
|
||||
export let app
|
||||
|
||||
let modal
|
||||
let valid = false
|
||||
let dirty = false
|
||||
$: checkValidity($values, validator)
|
||||
$: {
|
||||
// prevent validation by setting name to undefined without an app
|
||||
if (app) {
|
||||
$values.name = app?.name
|
||||
}
|
||||
}
|
||||
const values = writable({ name: "", url: null })
|
||||
const validation = createValidationStore()
|
||||
$: validation.check($values)
|
||||
|
||||
onMount(async () => {
|
||||
await hostingStore.actions.fetchDeployedApps()
|
||||
const existingAppNames = svelteGet(hostingStore).deployedAppNames
|
||||
validator.name = string()
|
||||
.trim()
|
||||
.required("Your application must have a name")
|
||||
.matches(
|
||||
APP_NAME_REGEX,
|
||||
"App name must be letters, numbers and spaces only"
|
||||
)
|
||||
.test(
|
||||
"non-existing-app-name",
|
||||
"Another app with the same name already exists",
|
||||
value => {
|
||||
return !existingAppNames.some(
|
||||
appName => dirty && appName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
}
|
||||
)
|
||||
$values.name = app.name
|
||||
$values.url = app.url
|
||||
setupValidation()
|
||||
})
|
||||
|
||||
const checkValidity = async (values, validator) => {
|
||||
const obj = object().shape(validator)
|
||||
Object.keys(validator).forEach(key => ($errors[key] = null))
|
||||
try {
|
||||
await obj.validate(values, { abortEarly: false })
|
||||
} catch (validationErrors) {
|
||||
validationErrors.inner.forEach(error => {
|
||||
$errors[error.path] = capitalise(error.message)
|
||||
})
|
||||
}
|
||||
valid = await obj.isValid(values)
|
||||
const setupValidation = async () => {
|
||||
const applications = svelteGet(apps)
|
||||
appValidation.name(validation, { apps: applications, currentApp: app })
|
||||
appValidation.url(validation, { apps: applications, currentApp: app })
|
||||
// init validation
|
||||
validation.check($values)
|
||||
}
|
||||
|
||||
async function updateApp() {
|
||||
try {
|
||||
// Update App
|
||||
await apps.update(app.instance._id, { name: $values.name.trim() })
|
||||
hide()
|
||||
const body = {
|
||||
name: $values.name.trim(),
|
||||
}
|
||||
if ($values.url) {
|
||||
body.url = $values.url.trim()
|
||||
}
|
||||
await apps.update(app.instance._id, body)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const show = () => {
|
||||
modal.show()
|
||||
// auto add slash to url
|
||||
$: {
|
||||
if ($values.url && !$values.url.startsWith("/")) {
|
||||
$values.url = `/${$values.url}`
|
||||
}
|
||||
export const hide = () => {
|
||||
modal.hide()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
hide()
|
||||
}
|
||||
|
||||
const onShow = () => {
|
||||
dirty = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal} on:hide={onCancel} on:show={onShow}>
|
||||
<ModalContent
|
||||
<ModalContent
|
||||
title={"Edit app"}
|
||||
confirmText={"Save"}
|
||||
onConfirm={updateApp}
|
||||
disabled={!(valid && dirty)}
|
||||
>
|
||||
disabled={!$validation.valid}
|
||||
>
|
||||
<Body size="S">Update the name of your app.</Body>
|
||||
<Input
|
||||
bind:value={$values.name}
|
||||
error={$touched.name && $errors.name}
|
||||
on:blur={() => ($touched.name = true)}
|
||||
on:change={() => (dirty = true)}
|
||||
error={$validation.touched.name && $validation.errors.name}
|
||||
on:blur={() => ($validation.touched.name = true)}
|
||||
label="Name"
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Input
|
||||
bind:value={$values.url}
|
||||
error={$validation.touched.url && $validation.errors.url}
|
||||
on:blur={() => ($validation.touched.url = true)}
|
||||
label="URL"
|
||||
placeholder={$values.name
|
||||
? "/" + encodeURIComponent($values.name).toLowerCase()
|
||||
: "/"}
|
||||
/>
|
||||
</ModalContent>
|
||||
|
|
|
@ -148,20 +148,23 @@ export const RelationshipTypes = {
|
|||
}
|
||||
|
||||
export const ALLOWABLE_STRING_OPTIONS = [FIELDS.STRING, FIELDS.OPTIONS]
|
||||
|
||||
export const ALLOWABLE_STRING_TYPES = ALLOWABLE_STRING_OPTIONS.map(
|
||||
opt => opt.type
|
||||
)
|
||||
|
||||
export const ALLOWABLE_NUMBER_OPTIONS = [FIELDS.NUMBER, FIELDS.BOOLEAN]
|
||||
|
||||
export const ALLOWABLE_NUMBER_TYPES = ALLOWABLE_NUMBER_OPTIONS.map(
|
||||
opt => opt.type
|
||||
)
|
||||
|
||||
export const SWITCHABLE_TYPES = ALLOWABLE_NUMBER_TYPES.concat(
|
||||
ALLOWABLE_STRING_TYPES
|
||||
)
|
||||
export const ALLOWABLE_JSON_OPTIONS = [FIELDS.JSON, FIELDS.ARRAY]
|
||||
export const ALLOWABLE_JSON_TYPES = ALLOWABLE_JSON_OPTIONS.map(opt => opt.type)
|
||||
|
||||
export const SWITCHABLE_TYPES = [
|
||||
...ALLOWABLE_STRING_TYPES,
|
||||
...ALLOWABLE_NUMBER_TYPES,
|
||||
...ALLOWABLE_JSON_TYPES,
|
||||
]
|
||||
|
||||
export const IntegrationTypes = {
|
||||
POSTGRES: "POSTGRES",
|
||||
|
@ -177,6 +180,7 @@ export const IntegrationTypes = {
|
|||
ARANGODB: "ARANGODB",
|
||||
ORACLE: "ORACLE",
|
||||
INTERNAL: "INTERNAL",
|
||||
GOOGLE_SHEETS: "GOOGLE_SHEETS",
|
||||
}
|
||||
|
||||
export const IntegrationNames = {
|
||||
|
@ -193,6 +197,7 @@ export const IntegrationNames = {
|
|||
[IntegrationTypes.ARANGODB]: "ArangoDB",
|
||||
[IntegrationTypes.ORACLE]: "Oracle",
|
||||
[IntegrationTypes.INTERNAL]: "Internal",
|
||||
[IntegrationTypes.GOOGLE_SHEETS]: "Google Sheets",
|
||||
}
|
||||
|
||||
export const SchemaTypeOptions = [
|
||||
|
@ -229,3 +234,11 @@ export const PaginationLocations = [
|
|||
{ label: "Query parameters", value: "query" },
|
||||
{ label: "Request body", value: "body" },
|
||||
]
|
||||
|
||||
export const BannedSearchTypes = [
|
||||
"link",
|
||||
"attachment",
|
||||
"formula",
|
||||
"json",
|
||||
"jsonarray",
|
||||
]
|
||||
|
|
|
@ -15,6 +15,22 @@ export const AppStatus = {
|
|||
DEPLOYED: "published",
|
||||
}
|
||||
|
||||
export const IntegrationNames = {
|
||||
POSTGRES: "PostgreSQL",
|
||||
MONGODB: "MongoDB",
|
||||
COUCHDB: "CouchDB",
|
||||
S3: "S3",
|
||||
MYSQL: "MySQL",
|
||||
REST: "REST",
|
||||
DYNAMODB: "DynamoDB",
|
||||
ELASTICSEARCH: "ElasticSearch",
|
||||
SQL_SERVER: "SQL Server",
|
||||
AIRTABLE: "Airtable",
|
||||
ARANGODB: "ArangoDB",
|
||||
ORACLE: "Oracle",
|
||||
GOOGLE_SHEETS: "Google Sheets",
|
||||
}
|
||||
|
||||
// fields on the user table that cannot be edited
|
||||
export const UNEDITABLE_USER_FIELDS = [
|
||||
"email",
|
||||
|
@ -36,4 +52,7 @@ export const LAYOUT_NAMES = {
|
|||
|
||||
export const BUDIBASE_INTERNAL_DB = "bb_internal"
|
||||
|
||||
// one or more word characters and whitespace
|
||||
export const APP_NAME_REGEX = /^[\w\s]+$/
|
||||
// zero or more non-whitespace characters
|
||||
export const APP_URL_REGEX = /^\S*$/
|
||||
|
|
|
@ -59,8 +59,7 @@ export const NoEmptyFilterStrings = [
|
|||
*/
|
||||
export const getValidOperatorsForType = type => {
|
||||
const Op = OperatorOptions
|
||||
if (type === "string") {
|
||||
return [
|
||||
const stringOps = [
|
||||
Op.Equals,
|
||||
Op.NotEquals,
|
||||
Op.StartsWith,
|
||||
|
@ -68,8 +67,7 @@ export const getValidOperatorsForType = type => {
|
|||
Op.Empty,
|
||||
Op.NotEmpty,
|
||||
]
|
||||
} else if (type === "number") {
|
||||
return [
|
||||
const numOps = [
|
||||
Op.Equals,
|
||||
Op.NotEquals,
|
||||
Op.MoreThan,
|
||||
|
@ -77,6 +75,10 @@ export const getValidOperatorsForType = type => {
|
|||
Op.Empty,
|
||||
Op.NotEmpty,
|
||||
]
|
||||
if (type === "string") {
|
||||
return stringOps
|
||||
} else if (type === "number") {
|
||||
return numOps
|
||||
} else if (type === "options") {
|
||||
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
|
||||
} else if (type === "array") {
|
||||
|
@ -84,23 +86,11 @@ export const getValidOperatorsForType = type => {
|
|||
} else if (type === "boolean") {
|
||||
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
|
||||
} else if (type === "longform") {
|
||||
return [
|
||||
Op.Equals,
|
||||
Op.NotEquals,
|
||||
Op.StartsWith,
|
||||
Op.Like,
|
||||
Op.Empty,
|
||||
Op.NotEmpty,
|
||||
]
|
||||
return stringOps
|
||||
} else if (type === "datetime") {
|
||||
return [
|
||||
Op.Equals,
|
||||
Op.NotEquals,
|
||||
Op.MoreThan,
|
||||
Op.LessThan,
|
||||
Op.Empty,
|
||||
Op.NotEmpty,
|
||||
]
|
||||
return numOps
|
||||
} else if (type === "formula") {
|
||||
return stringOps.concat([Op.MoreThan, Op.LessThan])
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { tables } from "../stores/backend"
|
||||
import { BannedSearchTypes } from "../constants/backend"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
export function getTableFields(linkField) {
|
||||
const table = get(tables).list.find(table => table._id === linkField.tableId)
|
||||
if (!table || !table.sql) {
|
||||
return []
|
||||
}
|
||||
const linkFields = getFields(Object.values(table.schema), {
|
||||
allowLinks: false,
|
||||
})
|
||||
return linkFields.map(field => ({
|
||||
...field,
|
||||
name: `${table.name}.${field.name}`,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getFields(fields, { allowLinks } = { allowLinks: true }) {
|
||||
let filteredFields = fields.filter(
|
||||
field => !BannedSearchTypes.includes(field.type)
|
||||
)
|
||||
if (allowLinks) {
|
||||
const linkFields = fields.filter(field => field.type === "link")
|
||||
for (let linkField of linkFields) {
|
||||
// only allow one depth of SQL relationship filtering
|
||||
filteredFields = filteredFields.concat(getTableFields(linkField))
|
||||
}
|
||||
}
|
||||
const staticFormulaFields = fields.filter(
|
||||
field => field.type === "formula" && field.formulaType === "static"
|
||||
)
|
||||
return filteredFields.concat(staticFormulaFields)
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
import { writable, derived } from "svelte/store"
|
||||
|
||||
// DEPRECATED - Use the yup based validators for future validation
|
||||
|
||||
export function createValidationStore(initialValue, ...validators) {
|
||||
let touched = false
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
// TODO: Convert to yup based validators
|
||||
|
||||
export function emailValidator(value) {
|
||||
return (
|
||||
(value &&
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
import { string, mixed } from "yup"
|
||||
import { APP_NAME_REGEX, APP_URL_REGEX } from "constants"
|
||||
|
||||
export const name = (validation, { apps, currentApp } = { apps: [] }) => {
|
||||
validation.addValidator(
|
||||
"name",
|
||||
string()
|
||||
.trim()
|
||||
.required("Your application must have a name")
|
||||
.matches(
|
||||
APP_NAME_REGEX,
|
||||
"App name must be letters, numbers and spaces only"
|
||||
)
|
||||
.test(
|
||||
"non-existing-app-name",
|
||||
"Another app with the same name already exists",
|
||||
value => {
|
||||
if (!value) {
|
||||
// exit early, above validator will fail
|
||||
return true
|
||||
}
|
||||
if (currentApp) {
|
||||
// filter out the current app if present
|
||||
apps = apps.filter(app => app.appId !== currentApp.appId)
|
||||
}
|
||||
return !apps
|
||||
.map(app => app.name)
|
||||
.some(appName => appName.toLowerCase() === value.toLowerCase())
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const url = (validation, { apps, currentApp } = { apps: [] }) => {
|
||||
validation.addValidator(
|
||||
"url",
|
||||
string()
|
||||
.nullable()
|
||||
.matches(APP_URL_REGEX, "App URL must not contain spaces")
|
||||
.test(
|
||||
"non-existing-app-url",
|
||||
"Another app with the same URL already exists",
|
||||
value => {
|
||||
// url is nullable
|
||||
if (!value) {
|
||||
return true
|
||||
}
|
||||
if (currentApp) {
|
||||
// filter out the current app if present
|
||||
apps = apps.filter(app => app.appId !== currentApp.appId)
|
||||
}
|
||||
return !apps
|
||||
.map(app => app.url)
|
||||
.some(appUrl => appUrl?.toLowerCase() === value.toLowerCase())
|
||||
}
|
||||
)
|
||||
.test("valid-url", "Not a valid URL", value => {
|
||||
// url is nullable
|
||||
if (!value) {
|
||||
return true
|
||||
}
|
||||
// make it clear that this is a url path and cannot be a full url
|
||||
return (
|
||||
value.startsWith("/") &&
|
||||
!value.includes("http") &&
|
||||
!value.includes("www") &&
|
||||
!value.includes(".") &&
|
||||
value.length > 1 // just '/' is not valid
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const file = (validation, { template } = {}) => {
|
||||
const templateToUse =
|
||||
template && Object.keys(template).length === 0 ? null : template
|
||||
validation.addValidator(
|
||||
"file",
|
||||
templateToUse?.fromFile
|
||||
? mixed().required("Please choose a file to import")
|
||||
: null
|
||||
)
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import { capitalise } from "helpers"
|
||||
import { object } from "yup"
|
||||
import { writable, get } from "svelte/store"
|
||||
import { notifications } from "@budibase/bbui"
|
||||
|
||||
export const createValidationStore = () => {
|
||||
const DEFAULT = {
|
||||
errors: {},
|
||||
touched: {},
|
||||
valid: false,
|
||||
}
|
||||
|
||||
const validator = {}
|
||||
const validation = writable(DEFAULT)
|
||||
|
||||
const addValidator = (propertyName, propertyValidator) => {
|
||||
if (!propertyValidator || !propertyName) {
|
||||
return
|
||||
}
|
||||
validator[propertyName] = propertyValidator
|
||||
}
|
||||
|
||||
const check = async values => {
|
||||
const obj = object().shape(validator)
|
||||
// clear the previous errors
|
||||
const properties = Object.keys(validator)
|
||||
properties.forEach(property => (get(validation).errors[property] = null))
|
||||
|
||||
let validationError = false
|
||||
try {
|
||||
await obj.validate(values, { abortEarly: false })
|
||||
} catch (error) {
|
||||
if (!error.inner) {
|
||||
notifications.error("Unexpected validation error", error)
|
||||
validationError = true
|
||||
} else {
|
||||
error.inner.forEach(err => {
|
||||
validation.update(store => {
|
||||
store.errors[err.path] = capitalise(err.message)
|
||||
return store
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let valid
|
||||
if (properties.length && !validationError) {
|
||||
valid = await obj.isValid(values)
|
||||
} else {
|
||||
// don't say valid until validators have been loaded
|
||||
valid = false
|
||||
}
|
||||
|
||||
validation.update(store => {
|
||||
store.valid = valid
|
||||
return store
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: validation.subscribe,
|
||||
set: validation.set,
|
||||
check,
|
||||
addValidator,
|
||||
}
|
||||
}
|
|
@ -2,6 +2,12 @@
|
|||
import { isActive, redirect, params } from "@roxi/routify"
|
||||
import { admin, auth } from "stores/portal"
|
||||
import { onMount } from "svelte"
|
||||
import {
|
||||
Cookies,
|
||||
getCookie,
|
||||
removeCookie,
|
||||
setCookie,
|
||||
} from "builderStore/cookies"
|
||||
|
||||
let loaded = false
|
||||
|
||||
|
@ -55,7 +61,7 @@
|
|||
await auth.setInitInfo({ init_template: $params["?template"] })
|
||||
}
|
||||
|
||||
await auth.checkAuth()
|
||||
await auth.getSelf()
|
||||
await admin.init()
|
||||
|
||||
if (useAccountPortal && multiTenancyEnabled) {
|
||||
|
@ -67,6 +73,24 @@
|
|||
|
||||
$: {
|
||||
const apiReady = $admin.loaded && $auth.loaded
|
||||
|
||||
// firstly, set the return url
|
||||
if (
|
||||
loaded &&
|
||||
apiReady &&
|
||||
!$auth.user &&
|
||||
!getCookie(Cookies.ReturnUrl) &&
|
||||
// logout triggers a page refresh, so we don't want to set the return url
|
||||
!$auth.postLogout &&
|
||||
// don't set the return url on pre-login pages
|
||||
!$isActive("./auth") &&
|
||||
!$isActive("./invite") &&
|
||||
!$isActive("./admin")
|
||||
) {
|
||||
const url = window.location.pathname
|
||||
setCookie(Cookies.ReturnUrl, url)
|
||||
}
|
||||
|
||||
// if tenant is not set go to it
|
||||
if (
|
||||
loaded &&
|
||||
|
@ -90,13 +114,20 @@
|
|||
!$isActive("./invite") &&
|
||||
!$isActive("./admin")
|
||||
) {
|
||||
const returnUrl = encodeURIComponent(window.location.pathname)
|
||||
$redirect("./auth?", { returnUrl })
|
||||
$redirect("./auth")
|
||||
}
|
||||
// check if password reset required for user
|
||||
else if ($auth.user?.forceResetPassword) {
|
||||
$redirect("./auth/reset")
|
||||
}
|
||||
// lastly, redirect to the return url if it has been set
|
||||
else if (loaded && apiReady && $auth.user) {
|
||||
const returnUrl = getCookie(Cookies.ReturnUrl)
|
||||
if (returnUrl) {
|
||||
removeCookie(Cookies.ReturnUrl)
|
||||
window.location.href = returnUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -19,8 +19,8 @@
|
|||
import { IntegrationTypes } from "constants/backend"
|
||||
import { isEqual } from "lodash"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
||||
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
|
||||
|
||||
let importQueriesModal
|
||||
|
||||
let changed
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
Modal,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import { apps, organisation, auth, admin } from "stores/portal"
|
||||
import { apps, organisation, auth } from "stores/portal"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { AppStatus } from "constants"
|
||||
import { gradient } from "actions"
|
||||
|
@ -34,7 +34,6 @@
|
|||
const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED
|
||||
|
||||
$: publishedApps = $apps.filter(publishedAppsOnly)
|
||||
$: isCloud = $admin.cloud
|
||||
$: userApps = $auth.user?.builder?.global
|
||||
? publishedApps
|
||||
: publishedApps.filter(app =>
|
||||
|
@ -42,7 +41,11 @@
|
|||
)
|
||||
|
||||
function getUrl(app) {
|
||||
return !isCloud ? `/app/${encodeURIComponent(app.name)}` : `/${app.prodId}`
|
||||
if (app.url) {
|
||||
return `/app${app.url}`
|
||||
} else {
|
||||
return `/${app.prodId}`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
notifications,
|
||||
Link,
|
||||
} from "@budibase/bbui"
|
||||
import { goto, params } from "@roxi/routify"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { auth, organisation, oidc, admin } from "stores/portal"
|
||||
import GoogleButton from "./_components/GoogleButton.svelte"
|
||||
import OIDCButton from "./_components/OIDCButton.svelte"
|
||||
|
@ -34,14 +34,10 @@
|
|||
|
||||
if ($auth?.user?.forceResetPassword) {
|
||||
$goto("./reset")
|
||||
} else {
|
||||
if ($params["?returnUrl"]) {
|
||||
window.location = decodeURIComponent($params["?returnUrl"])
|
||||
} else {
|
||||
notifications.success("Logged in successfully")
|
||||
$goto("../portal")
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
notifications.error(err.message ? err.message : "Invalid Credentials")
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
}
|
||||
|
||||
onMount(async () => {
|
||||
await auth.getSelf()
|
||||
await organisation.init()
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
notifications,
|
||||
Body,
|
||||
Search,
|
||||
Icon,
|
||||
} from "@budibase/bbui"
|
||||
import Spinner from "components/common/Spinner.svelte"
|
||||
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
||||
|
@ -27,6 +28,7 @@
|
|||
import AppRow from "components/start/AppRow.svelte"
|
||||
import { AppStatus } from "constants"
|
||||
import analytics, { Events } from "analytics"
|
||||
import Logo from "assets/bb-space-man.svg"
|
||||
|
||||
let sortBy = "name"
|
||||
let template
|
||||
|
@ -47,7 +49,6 @@
|
|||
$: filteredApps = enrichedApps.filter(app =>
|
||||
app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
$: isCloud = $admin.cloud
|
||||
|
||||
const enrichApps = (apps, user, sortBy) => {
|
||||
const enrichedApps = apps.map(app => ({
|
||||
|
@ -78,6 +79,7 @@
|
|||
}
|
||||
|
||||
const initiateAppCreation = () => {
|
||||
template = null
|
||||
creationModal.show()
|
||||
creatingApp = true
|
||||
}
|
||||
|
@ -159,12 +161,10 @@
|
|||
}
|
||||
|
||||
const viewApp = app => {
|
||||
if (!isCloud && app.deployed) {
|
||||
// special case to use the short form name if self hosted
|
||||
window.open(`/app/${encodeURIComponent(app.name)}`)
|
||||
if (app.url) {
|
||||
window.open(`/app${app.url}`)
|
||||
} else {
|
||||
const id = app.deployed ? app.prodId : app.devId
|
||||
window.open(`/${id}`, "_blank")
|
||||
window.open(`/${app.prodId}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -300,14 +300,26 @@
|
|||
|
||||
<div class="buttons">
|
||||
{#if cloud}
|
||||
<Button icon="Export" quiet secondary on:click={initiateAppsExport}>
|
||||
<Button
|
||||
size="L"
|
||||
icon="Export"
|
||||
quiet
|
||||
secondary
|
||||
on:click={initiateAppsExport}
|
||||
>
|
||||
Export apps
|
||||
</Button>
|
||||
{/if}
|
||||
<Button icon="Import" quiet secondary on:click={initiateAppImport}>
|
||||
<Button
|
||||
icon="Import"
|
||||
size="L"
|
||||
quiet
|
||||
secondary
|
||||
on:click={initiateAppImport}
|
||||
>
|
||||
Import app
|
||||
</Button>
|
||||
<Button icon="Add" cta on:click={initiateAppCreation}>
|
||||
<Button size="L" icon="Add" cta on:click={initiateAppCreation}>
|
||||
Create app
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -325,6 +337,14 @@
|
|||
}}
|
||||
class="template-card"
|
||||
>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
class="external-link"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<Icon name="LinkOut" size="S" />
|
||||
</a>
|
||||
<div class="card-body">
|
||||
<div style="color: {item.background}" class="iconAlign">
|
||||
<svg
|
||||
|
@ -389,9 +409,24 @@
|
|||
|
||||
{#if !enrichedApps.length && !creatingApp && loaded}
|
||||
<div class="empty-wrapper">
|
||||
<Modal inline>
|
||||
<CreateAppModal {template} inline={true} />
|
||||
</Modal>
|
||||
<div class="centered">
|
||||
<div class="main">
|
||||
<Layout gap="S" justifyItems="center">
|
||||
<img class="img-size" alt="logo" src={Logo} />
|
||||
<div class="new-screen-text">
|
||||
<Detail size="M">Create a business app in minutes!</Detail>
|
||||
</div>
|
||||
<Button on:click={() => initiateAppCreation()} size="M" cta>
|
||||
<div class="new-screen-button">
|
||||
<div class="background-icon" style="color: white;">
|
||||
<Icon name="Add" />
|
||||
</div>
|
||||
Create App
|
||||
</div></Button
|
||||
>
|
||||
</Layout>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
@ -412,6 +447,11 @@
|
|||
>
|
||||
<CreateAppModal {template} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={updatingModal} padding={false} width="600px">
|
||||
<UpdateAppModal app={selectedApp} />
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:this={deletionModal}
|
||||
title="Confirm deletion"
|
||||
|
@ -438,7 +478,6 @@
|
|||
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
|
||||
</ConfirmDialog>
|
||||
|
||||
<UpdateAppModal app={selectedApp} bind:this={updatingModal} />
|
||||
<ChooseIconModal app={selectedApp} bind:this={iconModal} />
|
||||
|
||||
<style>
|
||||
|
@ -474,16 +513,20 @@
|
|||
}
|
||||
|
||||
.grid {
|
||||
height: 200px;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
grid-gap: var(--spacing-xl);
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
grid-template-rows: minmax(70px, 1fr) minmax(100px, 1fr) minmax(0px, 0);
|
||||
}
|
||||
.template-card {
|
||||
height: 80px;
|
||||
height: 70px;
|
||||
border-radius: var(--border-radius-s);
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
|
@ -494,6 +537,18 @@
|
|||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.external-link {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
color: var(--spectrum-global-color-gray-300);
|
||||
z-index: 99;
|
||||
}
|
||||
.external-link:hover {
|
||||
color: var(--spectrum-global-color-gray-500);
|
||||
}
|
||||
|
||||
.iconAlign {
|
||||
padding: 0 0 0 var(--spacing-m);
|
||||
display: inline-block;
|
||||
|
@ -533,4 +588,42 @@
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.centered {
|
||||
width: calc(100% - 350px);
|
||||
height: calc(100% - 100px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.new-screen-text {
|
||||
width: 160px;
|
||||
text-align: center;
|
||||
color: #2c2c2c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.new-screen-button {
|
||||
margin-left: 5px;
|
||||
height: 20px;
|
||||
width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.img-size {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.background-icon {
|
||||
margin-top: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -9,6 +9,7 @@ export function createAuthStore() {
|
|||
tenantId: "default",
|
||||
tenantSet: false,
|
||||
loaded: false,
|
||||
postLogout: false,
|
||||
})
|
||||
const store = derived(auth, $store => {
|
||||
let initials = null
|
||||
|
@ -34,6 +35,7 @@ export function createAuthStore() {
|
|||
tenantId: $store.tenantId,
|
||||
tenantSet: $store.tenantSet,
|
||||
loaded: $store.loaded,
|
||||
postLogout: $store.postLogout,
|
||||
initials,
|
||||
isAdmin,
|
||||
isBuilder,
|
||||
|
@ -89,6 +91,13 @@ export function createAuthStore() {
|
|||
return info
|
||||
}
|
||||
|
||||
async function setPostLogout() {
|
||||
auth.update(store => {
|
||||
store.postLogout = true
|
||||
return store
|
||||
})
|
||||
}
|
||||
|
||||
async function getInitInfo() {
|
||||
const response = await api.get(`/api/global/auth/init`)
|
||||
const json = response.json()
|
||||
|
@ -99,11 +108,7 @@ export function createAuthStore() {
|
|||
return json
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
setOrganisation,
|
||||
getInitInfo,
|
||||
setInitInfo,
|
||||
const actions = {
|
||||
checkQueryString: async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
if (urlParams.has("tenantId")) {
|
||||
|
@ -114,7 +119,7 @@ export function createAuthStore() {
|
|||
setOrg: async tenantId => {
|
||||
await setOrganisation(tenantId)
|
||||
},
|
||||
checkAuth: async () => {
|
||||
getSelf: async () => {
|
||||
const response = await api.get("/api/global/users/self")
|
||||
if (response.status !== 200) {
|
||||
setUser(null)
|
||||
|
@ -129,13 +134,12 @@ export function createAuthStore() {
|
|||
`/api/global/auth/${tenantId}/login`,
|
||||
creds
|
||||
)
|
||||
const json = await response.json()
|
||||
if (response.status === 200) {
|
||||
setUser(json.user)
|
||||
await actions.getSelf()
|
||||
} else {
|
||||
const json = await response.json()
|
||||
throw new Error(json.message ? json.message : "Invalid credentials")
|
||||
}
|
||||
return json
|
||||
},
|
||||
logout: async () => {
|
||||
const response = await api.post(`/api/global/auth/logout`)
|
||||
|
@ -145,6 +149,7 @@ export function createAuthStore() {
|
|||
await response.json()
|
||||
await setInitInfo({})
|
||||
setUser(null)
|
||||
setPostLogout()
|
||||
},
|
||||
updateSelf: async fields => {
|
||||
const newUser = { ...get(auth).user, ...fields }
|
||||
|
@ -187,6 +192,14 @@ export function createAuthStore() {
|
|||
await response.json()
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
setOrganisation,
|
||||
getInitInfo,
|
||||
setInitInfo,
|
||||
...actions,
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = createAuthStore()
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/cli",
|
||||
"version": "1.0.43",
|
||||
"version": "1.0.49-alpha.5",
|
||||
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
||||
"main": "src/index.js",
|
||||
"bin": {
|
||||
|
|
|
@ -2065,6 +2065,26 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"label": "Direction",
|
||||
"key": "direction",
|
||||
"defaultValue": "vertical",
|
||||
"options": [
|
||||
{
|
||||
"label": "Horizontal",
|
||||
"value": "horizontal"
|
||||
},
|
||||
{
|
||||
"label": "Vertical",
|
||||
"value": "vertical"
|
||||
}
|
||||
],
|
||||
"dependsOn": {
|
||||
"setting": "optionsType",
|
||||
"value": "radio"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"label": "Default value",
|
||||
|
@ -2419,6 +2439,11 @@
|
|||
"label": "Label",
|
||||
"key": "label"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"label": "Extensions",
|
||||
"key": "extensions"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"label": "Disabled",
|
||||
|
@ -2811,7 +2836,7 @@
|
|||
"key": "dataSource"
|
||||
},
|
||||
{
|
||||
"type": "multifield",
|
||||
"type": "searchfield",
|
||||
"label": "Search Columns",
|
||||
"key": "searchColumns",
|
||||
"placeholder": "Choose search columns"
|
||||
|
@ -2958,7 +2983,7 @@
|
|||
"key": "dataSource"
|
||||
},
|
||||
{
|
||||
"type": "multifield",
|
||||
"type": "searchfield",
|
||||
"label": "Search Columns",
|
||||
"key": "searchColumns",
|
||||
"placeholder": "Choose search columns"
|
||||
|
@ -3315,5 +3340,50 @@
|
|||
"suffix": "repeater"
|
||||
}
|
||||
]
|
||||
},
|
||||
"s3upload": {
|
||||
"name": "S3 File Upload",
|
||||
"info": "This component can't be used with S3 datasources that use custom endpoints.",
|
||||
"icon": "UploadToCloud",
|
||||
"styles": ["size"],
|
||||
"editable": true,
|
||||
"settings": [
|
||||
{
|
||||
"type": "field/attachment",
|
||||
"label": "Field",
|
||||
"key": "field"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"label": "Label",
|
||||
"key": "label"
|
||||
},
|
||||
{
|
||||
"type": "dataSource/s3",
|
||||
"label": "S3 Datasource",
|
||||
"key": "datasourceId"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"label": "Bucket",
|
||||
"key": "bucket"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"label": "File Name",
|
||||
"key": "key"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"label": "Disabled",
|
||||
"key": "disabled",
|
||||
"defaultValue": false
|
||||
},
|
||||
{
|
||||
"type": "validation/attachment",
|
||||
"label": "Validation",
|
||||
"key": "validation"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/client",
|
||||
"version": "1.0.43",
|
||||
"version": "1.0.49-alpha.5",
|
||||
"license": "MPL-2.0",
|
||||
"module": "dist/budibase-client.js",
|
||||
"main": "dist/budibase-client.js",
|
||||
|
@ -19,10 +19,11 @@
|
|||
"dev:builder": "rollup -cw"
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/bbui": "^1.0.43",
|
||||
"@budibase/bbui": "^1.0.49-alpha.5",
|
||||
"@budibase/standard-components": "^0.9.139",
|
||||
"@budibase/string-templates": "^1.0.43",
|
||||
"@budibase/string-templates": "^1.0.49-alpha.5",
|
||||
"regexparam": "^1.3.0",
|
||||
"rollup-plugin-polyfill-node": "^0.8.0",
|
||||
"shortid": "^2.2.15",
|
||||
"svelte-spa-router": "^3.0.5"
|
||||
},
|
||||
|
@ -45,8 +46,6 @@
|
|||
"postcss": "^8.2.10",
|
||||
"rollup": "^2.44.0",
|
||||
"rollup-plugin-json": "^4.0.0",
|
||||
"rollup-plugin-node-builtins": "^2.1.2",
|
||||
"rollup-plugin-node-globals": "^1.4.0",
|
||||
"rollup-plugin-postcss": "^4.0.0",
|
||||
"rollup-plugin-svelte": "^7.1.0",
|
||||
"rollup-plugin-svg": "^2.0.0",
|
||||
|
|
|
@ -6,8 +6,7 @@ import { terser } from "rollup-plugin-terser"
|
|||
import postcss from "rollup-plugin-postcss"
|
||||
import svg from "rollup-plugin-svg"
|
||||
import json from "rollup-plugin-json"
|
||||
import builtins from "rollup-plugin-node-builtins"
|
||||
import globals from "rollup-plugin-node-globals"
|
||||
import nodePolyfills from "rollup-plugin-polyfill-node"
|
||||
import path from "path"
|
||||
|
||||
const production = !process.env.ROLLUP_WATCH
|
||||
|
@ -75,8 +74,7 @@ export default {
|
|||
}),
|
||||
postcss(),
|
||||
commonjs(),
|
||||
globals(),
|
||||
builtins(),
|
||||
nodePolyfills(),
|
||||
resolve({
|
||||
preferBuiltins: true,
|
||||
browser: true,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { notificationStore } from "stores"
|
||||
import { notificationStore, authStore } from "stores"
|
||||
import { get } from "svelte/store"
|
||||
import { ApiVersion } from "constants"
|
||||
|
||||
/**
|
||||
|
@ -28,6 +29,13 @@ const makeApiCall = async ({ method, url, body, json = true }) => {
|
|||
...(json && { "Content-Type": "application/json" }),
|
||||
...(!inBuilder && { "x-budibase-type": "client" }),
|
||||
}
|
||||
|
||||
// add csrf token if authenticated
|
||||
const auth = get(authStore)
|
||||
if (auth && auth.csrfToken) {
|
||||
headers["x-csrf-token"] = auth.csrfToken
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
|
@ -36,7 +44,11 @@ const makeApiCall = async ({ method, url, body, json = true }) => {
|
|||
})
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
return response.json()
|
||||
try {
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
case 401:
|
||||
notificationStore.actions.error("Invalid credentials")
|
||||
return handleError(`Invalid credentials`)
|
||||
|
@ -82,14 +94,15 @@ const makeCachedApiCall = async params => {
|
|||
* Constructs an API call function for a particular HTTP method.
|
||||
*/
|
||||
const requestApiCall = method => async params => {
|
||||
const { url, cache = false } = params
|
||||
const fixedUrl = `/${url}`.replace("//", "/")
|
||||
const { external = false, url, cache = false } = params
|
||||
const fixedUrl = external ? url : `/${url}`.replace("//", "/")
|
||||
const enrichedParams = { ...params, method, url: fixedUrl }
|
||||
return await (cache ? makeCachedApiCall : makeApiCall)(enrichedParams)
|
||||
}
|
||||
|
||||
export default {
|
||||
post: requestApiCall("POST"),
|
||||
put: requestApiCall("PUT"),
|
||||
get: requestApiCall("GET"),
|
||||
patch: requestApiCall("PATCH"),
|
||||
del: requestApiCall("DELETE"),
|
||||
|
|
|
@ -10,3 +10,41 @@ export const uploadAttachment = async (data, tableId = "") => {
|
|||
json: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a signed URL to upload a file to an external datasource.
|
||||
*/
|
||||
export const getSignedDatasourceURL = async (datasourceId, bucket, key) => {
|
||||
if (!datasourceId) {
|
||||
return null
|
||||
}
|
||||
const res = await API.post({
|
||||
url: `/api/attachments/${datasourceId}/url`,
|
||||
body: { bucket, key },
|
||||
})
|
||||
if (res.error) {
|
||||
throw "Could not generate signed upload URL"
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to an external datasource.
|
||||
*/
|
||||
export const externalUpload = async (datasourceId, bucket, key, data) => {
|
||||
const { signedUrl, publicUrl } = await getSignedDatasourceURL(
|
||||
datasourceId,
|
||||
bucket,
|
||||
key
|
||||
)
|
||||
const res = await API.put({
|
||||
url: signedUrl,
|
||||
body: data,
|
||||
json: false,
|
||||
external: true,
|
||||
})
|
||||
if (res?.error) {
|
||||
throw "Could not upload file to signed URL"
|
||||
}
|
||||
return { publicUrl }
|
||||
}
|
||||
|
|
|
@ -18,6 +18,15 @@ export const logIn = async ({ email, password }) => {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the user out and invaidates their session.
|
||||
*/
|
||||
export const logOut = async () => {
|
||||
return await API.post({
|
||||
url: "/api/global/auth/logout",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the currently logged in user object
|
||||
*/
|
||||
|
|
|
@ -63,8 +63,9 @@
|
|||
} else {
|
||||
// The user is not logged in, redirect them to login
|
||||
const returnUrl = `${window.location.pathname}${window.location.hash}`
|
||||
const encodedUrl = encodeURIComponent(returnUrl)
|
||||
window.location = `/builder/auth/login?returnUrl=${encodedUrl}`
|
||||
// TODO: reuse `Cookies` from builder when frontend-core is added
|
||||
window.document.cookie = `budibase:returnurl=${returnUrl}; Path=/`
|
||||
window.location = `/builder/auth/login`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -276,27 +276,29 @@
|
|||
// reactive statements as much as possible.
|
||||
const cacheSettings = (enriched, nested, conditional) => {
|
||||
const allSettings = { ...enriched, ...nested, ...conditional }
|
||||
if (!cachedSettings) {
|
||||
const mounted = ref?.$$set != null
|
||||
if (!cachedSettings || !mounted) {
|
||||
cachedSettings = { ...allSettings }
|
||||
initialSettings = cachedSettings
|
||||
} else {
|
||||
Object.keys(allSettings).forEach(key => {
|
||||
const same = propsAreSame(allSettings[key], cachedSettings[key])
|
||||
if (!same) {
|
||||
// Updated cachedSettings (which is assigned by reference to
|
||||
// initialSettings) so that if we remount the component then the
|
||||
// initial props are up to date. By setting it this way rather than
|
||||
// setting it on initialSettings directly, we avoid a double render.
|
||||
cachedSettings[key] = allSettings[key]
|
||||
assignSetting(key, allSettings[key])
|
||||
|
||||
// Programmatically set the prop to avoid svelte reactive statements
|
||||
// firing inside components. This circumvents the problems caused by
|
||||
// spreading a props object.
|
||||
ref.$$set({ [key]: allSettings[key] })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Assigns a certain setting to this component.
|
||||
// We manually use the svelte $set function to avoid triggering additional
|
||||
// reactive statements.
|
||||
const assignSetting = (key, value) => {
|
||||
ref?.$$set?.({ [key]: value })
|
||||
}
|
||||
|
||||
// Generates a key used to determine when components need to fully remount.
|
||||
// Currently only toggling editing requires remounting.
|
||||
const getRenderKey = (id, editing) => {
|
||||
|
@ -305,7 +307,7 @@
|
|||
</script>
|
||||
|
||||
{#key renderKey}
|
||||
{#if constructor && cachedSettings && (visible || inSelectedPath)}
|
||||
{#if constructor && initialSettings && (visible || inSelectedPath)}
|
||||
<!-- The ID is used as a class because getElementsByClassName is O(1) -->
|
||||
<!-- and the performance matters for the selection indicators -->
|
||||
<div
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
$: dataContext = {
|
||||
rows: $fetch.rows,
|
||||
info: $fetch.info,
|
||||
datasource: dataSource || {},
|
||||
schema: $fetch.schema,
|
||||
rowsLength: $fetch.rows.length,
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
number: "numberfield",
|
||||
datetime: "datetimefield",
|
||||
boolean: "booleanfield",
|
||||
formula: "stringfield",
|
||||
}
|
||||
|
||||
let formId
|
||||
|
@ -71,12 +72,13 @@
|
|||
const enrichFilter = (filter, columns, formId) => {
|
||||
let enrichedFilter = [...(filter || [])]
|
||||
columns?.forEach(column => {
|
||||
const safePath = column.name.split(".").map(safe).join(".")
|
||||
enrichedFilter.push({
|
||||
field: column.name,
|
||||
operator: column.type === "string" ? "string" : "equal",
|
||||
type: column.type === "string" ? "string" : "number",
|
||||
valueType: "Binding",
|
||||
value: `{{ [${formId}].[${column.name}] }}`,
|
||||
value: `{{ ${safe(formId)}.${safePath} }}`,
|
||||
})
|
||||
})
|
||||
return enrichedFilter
|
||||
|
@ -112,7 +114,9 @@
|
|||
// Load the datasource schema so we can determine column types
|
||||
const fetchSchema = async dataSource => {
|
||||
if (dataSource) {
|
||||
schema = await fetchDatasourceSchema(dataSource)
|
||||
schema = await fetchDatasourceSchema(dataSource, {
|
||||
enrichRelationships: true,
|
||||
})
|
||||
}
|
||||
schemaLoaded = true
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
number: "numberfield",
|
||||
datetime: "datetimefield",
|
||||
boolean: "booleanfield",
|
||||
formula: "stringfield",
|
||||
}
|
||||
|
||||
let formId
|
||||
|
@ -59,12 +60,14 @@
|
|||
const enrichFilter = (filter, columns, formId) => {
|
||||
let enrichedFilter = [...(filter || [])]
|
||||
columns?.forEach(column => {
|
||||
const safePath = column.name.split(".").map(safe).join(".")
|
||||
const stringType = column.type === "string" || column.type === "formula"
|
||||
enrichedFilter.push({
|
||||
field: column.name,
|
||||
operator: column.type === "string" ? "string" : "equal",
|
||||
type: column.type === "string" ? "string" : "number",
|
||||
operator: stringType ? "string" : "equal",
|
||||
type: stringType ? "string" : "number",
|
||||
valueType: "Binding",
|
||||
value: `{{ ${safe(formId)}.${safe(column.name)} }}`,
|
||||
value: `{{ ${safe(formId)}.${safePath} }}`,
|
||||
})
|
||||
})
|
||||
return enrichedFilter
|
||||
|
@ -90,7 +93,9 @@
|
|||
// Load the datasource schema so we can determine column types
|
||||
const fetchSchema = async dataSource => {
|
||||
if (dataSource) {
|
||||
schema = await fetchDatasourceSchema(dataSource)
|
||||
schema = await fetchDatasourceSchema(dataSource, {
|
||||
enrichRelationships: true,
|
||||
})
|
||||
}
|
||||
schemaLoaded = true
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue