Merge branch 'develop' into worker_typescript

This commit is contained in:
Rory Powell 2022-01-31 20:29:20 +00:00
commit 995c36e8c7
183 changed files with 4818 additions and 1894 deletions

View File

@ -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/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> <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>
<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> </table>
<!-- markdownlint-restore --> <!-- markdownlint-restore -->

View File

@ -99,13 +99,17 @@ spec:
- name: PLATFORM_URL - name: PLATFORM_URL
value: {{ .Values.globals.platformUrl | quote }} value: {{ .Values.globals.platformUrl | quote }}
- name: USE_QUOTAS - name: USE_QUOTAS
value: "1" value: {{ .Values.globals.useQuotas | quote }}
- name: EXCLUDE_QUOTAS_TENANTS
value: {{ .Values.globals.excludeQuotasTenants | quote }}
- name: ACCOUNT_PORTAL_URL - name: ACCOUNT_PORTAL_URL
value: {{ .Values.globals.accountPortalUrl | quote }} value: {{ .Values.globals.accountPortalUrl | quote }}
- name: ACCOUNT_PORTAL_API_KEY - name: ACCOUNT_PORTAL_API_KEY
value: {{ .Values.globals.accountPortalApiKey | quote }} value: {{ .Values.globals.accountPortalApiKey | quote }}
- name: COOKIE_DOMAIN - name: COOKIE_DOMAIN
value: {{ .Values.globals.cookieDomain | quote }} value: {{ .Values.globals.cookieDomain | quote }}
- name: HTTP_MIGRATIONS
value: {{ .Values.globals.httpMigrations | quote }}
image: budibase/apps:{{ .Values.globals.appVersion }} image: budibase/apps:{{ .Values.globals.appVersion }}
imagePullPolicy: Always imagePullPolicy: Always
name: bbapps name: bbapps

View File

@ -93,10 +93,13 @@ globals:
logLevel: info logLevel: info
selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup 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 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: "" accountPortalUrl: ""
accountPortalApiKey: "" accountPortalApiKey: ""
cookieDomain: "" cookieDomain: ""
platformUrl: "" platformUrl: ""
httpMigrations: "0"
createSecrets: true # creates an internal API key, JWT secrets and redis password for you createSecrets: true # creates an internal API key, JWT secrets and redis password for you
@ -239,7 +242,8 @@ couchdb:
hosts: hosts:
- chart-example.local - chart-example.local
path: / path: /
annotations: [] annotations:
[]
# kubernetes.io/ingress.class: nginx # kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true" # kubernetes.io/tls-acme: "true"
tls: tls:

View File

@ -1,5 +1,5 @@
{ {
"version": "1.0.44-alpha.9", "version": "1.0.49-alpha.4",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -1,4 +1,5 @@
module.exports = { module.exports = {
...require("./src/db/utils"), ...require("./src/db/utils"),
...require("./src/db/constants"), ...require("./src/db/constants"),
...require("./src/db/views"),
} }

View File

@ -0,0 +1 @@
module.exports = require("./src/migrations")

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "1.0.44-alpha.9", "version": "1.0.49-alpha.4",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "src/index.js", "main": "src/index.js",
"author": "Budibase", "author": "Budibase",

View File

@ -12,6 +12,8 @@ const {
tenancy, tenancy,
appTenancy, appTenancy,
authError, authError,
csrf,
internalApi,
} = require("./middleware") } = require("./middleware")
// Strategies // Strategies
@ -42,4 +44,6 @@ module.exports = {
buildAppTenancyMiddleware: appTenancy, buildAppTenancyMiddleware: appTenancy,
auditLog, auditLog,
authError, authError,
buildCsrfMiddleware: csrf,
internalApi,
} }

View File

@ -7,8 +7,8 @@ exports.Cookies = {
CurrentApp: "budibase:currentapp", CurrentApp: "budibase:currentapp",
Auth: "budibase:auth", Auth: "budibase:auth",
Init: "budibase:init", Init: "budibase:init",
DatasourceAuth: "budibase:datasourceauth",
OIDC_CONFIG: "budibase:oidc:config", OIDC_CONFIG: "budibase:oidc:config",
RETURN_URL: "budibase:returnurl",
} }
exports.Headers = { exports.Headers = {
@ -18,6 +18,7 @@ exports.Headers = {
TYPE: "x-budibase-type", TYPE: "x-budibase-type",
TENANT_ID: "x-budibase-tenant-id", TENANT_ID: "x-budibase-tenant-id",
TOKEN: "x-budibase-token", TOKEN: "x-budibase-token",
CSRF_TOKEN: "x-csrf-token",
} }
exports.GlobalRoles = { exports.GlobalRoles = {

View File

@ -21,6 +21,7 @@ exports.StaticDatabases = {
name: "global-db", name: "global-db",
docs: { docs: {
apiKeys: "apikeys", apiKeys: "apikeys",
usageQuota: "usage_quota",
}, },
}, },
// contains information about tenancy and so on // contains information about tenancy and so on
@ -28,7 +29,6 @@ exports.StaticDatabases = {
name: "global-info", name: "global-info",
docs: { docs: {
tenants: "tenants", tenants: "tenants",
usageQuota: "usage_quota",
}, },
}, },
} }

View File

@ -450,7 +450,7 @@ async function getScopedConfig(db, params) {
function generateNewUsageQuotaDoc() { function generateNewUsageQuotaDoc() {
return { return {
_id: StaticDatabases.PLATFORM_INFO.docs.usageQuota, _id: StaticDatabases.GLOBAL.docs.usageQuota,
quotaReset: Date.now() + 2592000000, quotaReset: Date.now() + 2592000000,
usageQuota: { usageQuota: {
automationRuns: 0, automationRuns: 0,

View File

@ -14,4 +14,5 @@ module.exports = {
cache: require("../cache"), cache: require("../cache"),
auth: require("../auth"), auth: require("../auth"),
constants: require("../constants"), constants: require("../constants"),
migrations: require("../migrations"),
} }

View File

@ -60,6 +60,7 @@ module.exports = (
} else { } else {
user = await getUser(userId, session.tenantId) user = await getUser(userId, session.tenantId)
} }
user.csrfToken = session.csrfToken
delete user.password delete user.password
authenticated = true authenticated = true
} catch (err) { } catch (err) {

View File

@ -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()
}
}

View File

@ -7,6 +7,9 @@ const authenticated = require("./authenticated")
const auditLog = require("./auditLog") const auditLog = require("./auditLog")
const tenancy = require("./tenancy") const tenancy = require("./tenancy")
const appTenancy = require("./appTenancy") const appTenancy = require("./appTenancy")
const internalApi = require("./internalApi")
const datasourceGoogle = require("./passport/datasource/google")
const csrf = require("./csrf")
module.exports = { module.exports = {
google, google,
@ -18,4 +21,9 @@ module.exports = {
tenancy, tenancy,
appTenancy, appTenancy,
authError, authError,
internalApi,
datasource: {
google: datasourceGoogle,
},
csrf,
} }

View File

@ -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()
}

View File

@ -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

View File

@ -1,18 +1,17 @@
const { DEFAULT_TENANT_ID } = require("../constants")
const { DocumentTypes } = require("../db/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 = { exports.MIGRATION_TYPES = {
GLOBAL_DB: "GLOBAL_DB", 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.MIGRATIONS = {
USER_EMAIL_VIEW_CASING: "user_email_view_casing",
}
const DB_LOOKUP = {
[exports.MIGRATION_DBS.GLOBAL_DB]: [
exports.MIGRATIONS.USER_EMAIL_VIEW_CASING,
],
} }
exports.getMigrationsDoc = async db => { exports.getMigrationsDoc = async db => {
@ -26,36 +25,90 @@ exports.getMigrationsDoc = async db => {
} }
} }
exports.migrateIfRequired = async (migrationDb, migrationName, migrateFn) => { const runMigration = async (CouchDB, migration, options = {}) => {
try { const tenantId = getTenantId()
let db const migrationType = migration.type
if (migrationDb === exports.MIGRATION_DBS.GLOBAL_DB) { const migrationName = migration.name
db = getGlobalDB()
} else {
throw new Error(`Unrecognised migration db [${migrationDb}]`)
}
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( 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) const doc = await exports.getMigrationsDoc(db)
// exit if the migration has been performed
// exit if the migration has been performed already
if (doc[migrationName]) { 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}`) console.log(
await migrateFn() `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Running`
console.log(`Migration complete: ${migrationName}`) )
// run the migration with tenant context
await migration.fn(db)
console.log(
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Complete`
)
// mark as complete // mark as complete
doc[migrationName] = Date.now() doc[migrationName] = Date.now()
await db.put(doc) await db.put(doc)
} catch (err) { } catch (err) {
console.error(`Error performing migration: ${migrationName}: `, err) console.error(
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Error: `,
err
)
throw 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")
}

View File

@ -3,7 +3,7 @@
exports[`migrations should match snapshot 1`] = ` exports[`migrations should match snapshot 1`] = `
Object { Object {
"_id": "migrations", "_id": "migrations",
"_rev": "1-af6c272fe081efafecd2ea49a8fcbb40", "_rev": "1-6277abc4e3db950221768e5a2618a059",
"user_email_view_casing": 1487076708000, "test": 1487076708000,
} }
`; `;

View File

@ -1,7 +1,7 @@
require("../../tests/utilities/dbConfig") require("../../tests/utilities/dbConfig")
const { migrateIfRequired, MIGRATION_DBS, MIGRATIONS, getMigrationsDoc } = require("../index") const { runMigrations, getMigrationsDoc } = require("../index")
const database = require("../../db") const CouchDB = require("../../db").getCouch()
const { const {
StaticDatabases, StaticDatabases,
} = require("../../db/utils") } = require("../../db/utils")
@ -13,8 +13,14 @@ describe("migrations", () => {
const migrationFunction = jest.fn() const migrationFunction = jest.fn()
const MIGRATIONS = [{
type: "global",
name: "test",
fn: migrationFunction
}]
beforeEach(() => { beforeEach(() => {
db = database.getDB(StaticDatabases.GLOBAL.name) db = new CouchDB(StaticDatabases.GLOBAL.name)
}) })
afterEach(async () => { afterEach(async () => {
@ -22,39 +28,29 @@ describe("migrations", () => {
await db.destroy() await db.destroy()
}) })
const validMigration = () => { const migrate = () => {
return migrateIfRequired(MIGRATION_DBS.GLOBAL_DB, MIGRATIONS.USER_EMAIL_VIEW_CASING, migrationFunction) return runMigrations(CouchDB, MIGRATIONS)
} }
it("should run a new migration", async () => { it("should run a new migration", async () => {
await validMigration() await migrate()
expect(migrationFunction).toHaveBeenCalled() expect(migrationFunction).toHaveBeenCalled()
const doc = await getMigrationsDoc(db)
expect(doc.test).toBeDefined()
}) })
it("should match snapshot", async () => { it("should match snapshot", async () => {
await validMigration() await migrate()
const doc = await getMigrationsDoc(db) const doc = await getMigrationsDoc(db)
expect(doc).toMatchSnapshot() expect(doc).toMatchSnapshot()
}) })
it("should skip a previously run migration", async () => { it("should skip a previously run migration", async () => {
await validMigration() await migrate()
await validMigration() const previousMigrationTime = await getMigrationsDoc(db).test
await migrate()
const currentMigrationTime = await getMigrationsDoc(db).test
expect(migrationFunction).toHaveBeenCalledTimes(1) 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()
})
}) })

View File

@ -1,4 +1,5 @@
const redis = require("../redis/authRedis") const redis = require("../redis/authRedis")
const { v4: uuidv4 } = require("uuid")
// a week in seconds // a week in seconds
const EXPIRY_SECONDS = 86400 * 7 const EXPIRY_SECONDS = 86400 * 7
@ -16,6 +17,9 @@ function makeSessionID(userId, sessionId) {
exports.createASession = async (userId, session) => { exports.createASession = async (userId, session) => {
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
const sessionId = session.sessionId const sessionId = session.sessionId
if (!session.csrfToken) {
session.csrfToken = uuidv4()
}
session = { session = {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(), lastAccessedAt: new Date().toISOString(),

View File

@ -148,3 +148,15 @@ exports.isUserInAppTenant = (appId, user = null) => {
const tenantId = exports.getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID const tenantId = exports.getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID
return tenantId === userTenantId 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) || []
}

View File

@ -20,9 +20,6 @@ const { hash } = require("./hashing")
const userCache = require("./cache/user") const userCache = require("./cache/user")
const env = require("./environment") const env = require("./environment")
const { getUserSessions, invalidateSessions } = require("./security/sessions") 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 const APP_PREFIX = DocumentTypes.APP + SEPARATOR
@ -96,12 +93,7 @@ exports.getCookie = (ctx, name) => {
* @param {string|object} value The value of cookie which will be set. * @param {string|object} value The value of cookie which will be set.
* @param {object} opts options like whether to sign. * @param {object} opts options like whether to sign.
*/ */
exports.setCookie = ( exports.setCookie = (ctx, value, name = "builder", opts = { sign: true }) => {
ctx,
value,
name = "builder",
opts = { sign: true, requestDomain: false }
) => {
if (value && opts && opts.sign) { if (value && opts && opts.sign) {
value = jwt.sign(value, options.secretOrKey) value = jwt.sign(value, options.secretOrKey)
} }
@ -113,7 +105,7 @@ exports.setCookie = (
overwrite: true, overwrite: true,
} }
if (environment.COOKIE_DOMAIN && !opts.requestDomain) { if (environment.COOKIE_DOMAIN) {
config.domain = environment.COOKIE_DOMAIN config.domain = environment.COOKIE_DOMAIN
} }
@ -149,11 +141,6 @@ exports.getGlobalUserByEmail = async email => {
} }
const db = getGlobalDB() const db = getGlobalDB()
await migrateIfRequired(GLOBAL_DB, USER_EMAIL_VIEW_CASING, async () => {
// re-create the view with latest changes
await createUserEmailView(db)
})
try { try {
let users = ( let users = (
await db.query(`database/${ViewNames.USER_BY_EMAIL}`, { await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {

View File

@ -3410,9 +3410,9 @@ node-fetch@2.6.0:
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-fetch@^2.6.1: node-fetch@^2.6.1:
version "2.6.6" version "2.6.7"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
integrity sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA== integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
dependencies: dependencies:
whatwg-url "^5.0.0" whatwg-url "^5.0.0"

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "1.0.44-alpha.9", "version": "1.0.49-alpha.4",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",

View File

@ -147,7 +147,9 @@
<img alt="preview" src={selectedUrl} /> <img alt="preview" src={selectedUrl} />
{:else} {:else}
<div class="placeholder"> <div class="placeholder">
<div class="extension">{selectedImage.extension}</div> <div class="extension">
{selectedImage.name || "Unknown file"}
</div>
<div>Preview not supported</div> <div>Preview not supported</div>
</div> </div>
{/if} {/if}
@ -359,18 +361,21 @@
white-space: nowrap; white-space: nowrap;
width: 0; width: 0;
margin-right: 10px; margin-right: 10px;
user-select: all;
} }
.placeholder { .placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center;
} }
.extension { .extension {
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
margin-bottom: 5px; margin-bottom: 5px;
user-select: all;
} }
.nav { .nav {

View File

@ -6,11 +6,12 @@
export let label = null export let label = null
export let labelPosition = "above" export let labelPosition = "above"
export let error = null export let error = null
export let tooltip = ""
</script> </script>
<div class="spectrum-Form-item" class:above={labelPosition === "above"}> <div class="spectrum-Form-item" class:above={labelPosition === "above"}>
{#if label} {#if label}
<FieldLabel forId={id} {label} position={labelPosition} /> <FieldLabel forId={id} {label} position={labelPosition} {tooltip} />
{/if} {/if}
<div class="spectrum-Form-itemField"> <div class="spectrum-Form-itemField">
<slot /> <slot />

View File

@ -1,19 +1,24 @@
<script> <script>
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
import "@spectrum-css/fieldlabel/dist/index-vars.css" import "@spectrum-css/fieldlabel/dist/index-vars.css"
export let forId export let forId
export let label export let label
export let position = "above" export let position = "above"
export let tooltip = ""
$: className = position === "above" ? "" : `spectrum-FieldLabel--${position}` $: className = position === "above" ? "" : `spectrum-FieldLabel--${position}`
</script> </script>
<TooltipWrapper {tooltip} size="S">
<label <label
for={forId} for={forId}
class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${className}`} class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${className}`}
> >
{label || ""} {label || ""}
</label> </label>
</TooltipWrapper>
<style> <style>
label { label {

View File

@ -17,6 +17,7 @@
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
export let sort = false export let sort = false
export let tooltip = ""
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -32,7 +33,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {label} {labelPosition} {error} {tooltip}>
<Select <Select
{quiet} {quiet}
{error} {error}

View File

@ -1,73 +1,20 @@
<script> <script>
import "@spectrum-css/fieldlabel/dist/index-vars.css" import "@spectrum-css/fieldlabel/dist/index-vars.css"
import Tooltip from "../Tooltip/Tooltip.svelte" import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
import Icon from "../Icon/Icon.svelte"
export let size = "M" export let size = "M"
export let tooltip = "" export let tooltip = ""
export let showTooltip = false
</script> </script>
{#if tooltip} <TooltipWrapper {tooltip} {size}>
<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}
<label for="" class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}> <label for="" class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}>
<slot /> <slot />
</label> </label>
{/if} </TooltipWrapper>
<style> <style>
label { label {
padding: 0; padding: 0;
white-space: nowrap; 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> </style>

View File

@ -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>

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "1.0.44-alpha.9", "version": "1.0.49-alpha.4",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -65,10 +65,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.0.44-alpha.9", "@budibase/bbui": "^1.0.49-alpha.4",
"@budibase/client": "^1.0.44-alpha.9", "@budibase/client": "^1.0.49-alpha.4",
"@budibase/colorpicker": "1.1.2", "@budibase/colorpicker": "1.1.2",
"@budibase/string-templates": "^1.0.44-alpha.9", "@budibase/string-templates": "^1.0.49-alpha.4",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View File

@ -1,11 +1,20 @@
import { store } from "./index" import { store } from "./index"
import { get as svelteGet } from "svelte/store" import { get as svelteGet } from "svelte/store"
import { removeCookie, Cookies } from "./cookies" import { removeCookie, Cookies } from "./cookies"
import { auth } from "stores/portal"
const apiCall = const apiCall =
method => method =>
async (url, body, headers = { "Content-Type": "application/json" }) => { async (url, body, headers = { "Content-Type": "application/json" }) => {
headers["x-budibase-app-id"] = svelteGet(store).appId 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 json = headers["Content-Type"] === "application/json"
const resp = await fetch(url, { const resp = await fetch(url, {
method: method, method: method,

View File

@ -1,6 +1,5 @@
import { getFrontendStore } from "./store/frontend" import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation" import { getAutomationStore } from "./store/automation"
import { getHostingStore } from "./store/hosting"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { derived, writable } from "svelte/store" import { derived, writable } from "svelte/store"
import { FrontendTypes, LAYOUT_NAMES } from "../constants" import { FrontendTypes, LAYOUT_NAMES } from "../constants"
@ -9,7 +8,6 @@ import { findComponent } from "./componentUtils"
export const store = getFrontendStore() export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()
export const themeStore = getThemeStore() export const themeStore = getThemeStore()
export const hostingStore = getHostingStore()
export const currentAsset = derived(store, $store => { export const currentAsset = derived(store, $store => {
const type = $store.currentFrontEndType const type = $store.currentFrontEndType

View File

@ -2,7 +2,6 @@ import { get, writable } from "svelte/store"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { import {
allScreens, allScreens,
hostingStore,
currentAsset, currentAsset,
mainLayout, mainLayout,
selectedComponent, selectedComponent,
@ -100,7 +99,6 @@ export const getFrontendStore = () => {
version: application.version, version: application.version,
revertableVersion: application.revertableVersion, revertableVersion: application.revertableVersion,
})) }))
await hostingStore.actions.fetch()
// Initialise backend stores // Initialise backend stores
const [_integrations] = await Promise.all([ const [_integrations] = await Promise.all([

View File

@ -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
}

View File

@ -22,8 +22,10 @@
RelationshipTypes, RelationshipTypes,
ALLOWABLE_STRING_OPTIONS, ALLOWABLE_STRING_OPTIONS,
ALLOWABLE_NUMBER_OPTIONS, ALLOWABLE_NUMBER_OPTIONS,
ALLOWABLE_JSON_OPTIONS,
ALLOWABLE_STRING_TYPES, ALLOWABLE_STRING_TYPES,
ALLOWABLE_NUMBER_TYPES, ALLOWABLE_NUMBER_TYPES,
ALLOWABLE_JSON_TYPES,
SWITCHABLE_TYPES, SWITCHABLE_TYPES,
} from "constants/backend" } from "constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils" import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils"
@ -150,6 +152,7 @@
delete field.subtype delete field.subtype
delete field.tableId delete field.tableId
delete field.relationshipType delete field.relationshipType
delete field.formulaType
// Add in defaults and initial definition // Add in defaults and initial definition
const definition = fieldDefinitions[event.detail?.toUpperCase()] const definition = fieldDefinitions[event.detail?.toUpperCase()]
@ -161,6 +164,9 @@
if (field.type === LINK_TYPE) { if (field.type === LINK_TYPE) {
field.relationshipType = RelationshipTypes.MANY_TO_MANY field.relationshipType = RelationshipTypes.MANY_TO_MANY
} }
if (field.type === FORMULA_TYPE) {
field.formulaType = "dynamic"
}
} }
function onChangeRequired(e) { function onChangeRequired(e) {
@ -241,6 +247,11 @@
ALLOWABLE_NUMBER_TYPES.indexOf(field.type) !== -1 ALLOWABLE_NUMBER_TYPES.indexOf(field.type) !== -1
) { ) {
return ALLOWABLE_NUMBER_OPTIONS return ALLOWABLE_NUMBER_OPTIONS
} else if (
originalName &&
ALLOWABLE_JSON_TYPES.indexOf(field.type) !== -1
) {
return ALLOWABLE_JSON_OPTIONS
} else if (!external) { } else if (!external) {
return [ return [
...Object.values(fieldDefinitions), ...Object.values(fieldDefinitions),
@ -431,8 +442,22 @@
error={errors.relatedName} error={errors.relatedName}
/> />
{:else if field.type === FORMULA_TYPE} {: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 <ModalBindableInput
title="Handlebars Formula" title="Formula"
label="Formula" label="Formula"
value={field.formula} value={field.formula}
on:change={e => (field.formula = e.detail)} on:change={e => (field.formula = e.detail)}
@ -441,7 +466,7 @@
/> />
{:else if field.type === AUTO_TYPE} {:else if field.type === AUTO_TYPE}
<Select <Select
label="Auto Column Type" label="Auto column type"
value={field.subtype} value={field.subtype}
on:change={e => (field.subtype = e.detail)} on:change={e => (field.subtype = e.detail)}
options={Object.entries(getAutoColumnInformation())} options={Object.entries(getAutoColumnInformation())}

View File

@ -188,7 +188,7 @@
{:else} {:else}
<Body size="S"><i>No tables found.</i></Body> <Body size="S"><i>No tables found.</i></Body>
{/if} {/if}
{#if plusTables?.length !== 0} {#if plusTables?.length !== 0 && integration.relationships}
<Divider size="S" /> <Divider size="S" />
<div class="query-header"> <div class="query-header">
<Heading size="S">Relationships</Heading> <Heading size="S">Relationships</Heading>

View File

@ -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>

View File

@ -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>

View File

@ -11,6 +11,7 @@ import ArangoDB from "./ArangoDB.svelte"
import Rest from "./Rest.svelte" import Rest from "./Rest.svelte"
import Budibase from "./Budibase.svelte" import Budibase from "./Budibase.svelte"
import Oracle from "./Oracle.svelte" import Oracle from "./Oracle.svelte"
import GoogleSheets from "./GoogleSheets.svelte"
export default { export default {
BUDIBASE: Budibase, BUDIBASE: Budibase,
@ -26,4 +27,5 @@ export default {
ARANGODB: ArangoDB, ARANGODB: ArangoDB,
REST: Rest, REST: Rest,
ORACLE: Oracle, ORACLE: Oracle,
GOOGLE_SHEETS: GoogleSheets,
} }

View File

@ -6,6 +6,7 @@
import { IntegrationNames, IntegrationTypes } from "constants/backend" import { IntegrationNames, IntegrationTypes } from "constants/backend"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte" import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.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 { createRestDatasource } from "builderStore/datasource"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte" import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte"
@ -38,6 +39,7 @@
plus: selected.plus, plus: selected.plus,
config, config,
schema: selected.datasource, schema: selected.datasource,
auth: selected.auth,
} }
checkShowImport() checkShowImport()
} }
@ -79,7 +81,11 @@
</Modal> </Modal>
<Modal bind:this={externalDatasourceModal}> <Modal bind:this={externalDatasourceModal}>
{#if integration?.auth?.type === "google"}
<GoogleDatasourceConfigModal {integration} {modal} />
{:else}
<DatasourceConfigModal {integration} {modal} /> <DatasourceConfigModal {integration} {modal} />
{/if}
</Modal> </Modal>
<Modal bind:this={importModal}> <Modal bind:this={importModal}>

View File

@ -51,13 +51,9 @@
>Connect your database to Budibase using the config below. >Connect your database to Budibase using the config below.
</Body> </Body>
</Layout> </Layout>
<IntegrationConfigForm <IntegrationConfigForm
schema={datasource.schema} schema={datasource.schema}
bind:datasource bind:datasource
creating={true} creating={true}
/> />
</ModalContent> </ModalContent>
<style>
</style>

View File

@ -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>

View File

@ -53,7 +53,9 @@
} }
// Create table // Create table
const table = await tables.save(newTable) let table
try {
table = await tables.save(newTable)
notifications.success(`Table ${name} created successfully.`) notifications.success(`Table ${name} created successfully.`)
analytics.captureEvent(Events.TABLE.CREATED, { name }) analytics.captureEvent(Events.TABLE.CREATED, { name })
@ -63,6 +65,11 @@
? `./table/${table._id}` ? `./table/${table._id}`
: `../../table/${table._id}` : `../../table/${table._id}`
$goto(path) $goto(path)
} catch (e) {
notifications.error(e)
// reload in case the table was created
await tables.fetch()
}
} }
</script> </script>

View File

@ -6,7 +6,7 @@
import api from "builderStore/api" import api from "builderStore/api"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte" import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte"
import { store, hostingStore } from "builderStore" import { store } from "builderStore"
const DeploymentStatus = { const DeploymentStatus = {
SUCCESS: "SUCCESS", SUCCESS: "SUCCESS",
@ -37,7 +37,7 @@
let poll let poll
let deployments = [] let deployments = []
let urlComponent = $store.url || `/${appId}` let urlComponent = $store.url || `/${appId}`
let deploymentUrl = `${$hostingStore.appUrl}${urlComponent}` let deploymentUrl = `${urlComponent}`
const formatDate = (date, format) => const formatDate = (date, format) =>
Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date) Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date)

View File

@ -1,6 +1,7 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import { get } from "svelte/store"
const themeOptions = [ const themeOptions = [
{ {
@ -20,6 +21,17 @@
value: "spectrum--darkest", 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> </script>
<div> <div>
@ -27,7 +39,7 @@
value={$store.theme} value={$store.theme}
options={themeOptions} options={themeOptions}
placeholder={null} placeholder={null}
on:change={e => store.actions.theme.save(e.detail)} on:change={e => onChangeTheme(e.detail)}
/> />
</div> </div>

View File

@ -19,7 +19,7 @@
primaryColor: "var(--spectrum-global-color-blue-600)", primaryColor: "var(--spectrum-global-color-blue-600)",
primaryColorHover: "var(--spectrum-global-color-blue-500)", primaryColorHover: "var(--spectrum-global-color-blue-500)",
buttonBorderRadius: "16px", buttonBorderRadius: "16px",
navBackground: "var(--spectrum-global-color-gray-100)", navBackground: "var(--spectrum-global-color-gray-50)",
navTextColor: "var(--spectrum-global-color-gray-800)", navTextColor: "var(--spectrum-global-color-gray-800)",
} }
@ -52,7 +52,14 @@
} }
const resetTheme = () => { 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> </script>

View File

@ -44,7 +44,8 @@
"relationshipfield", "relationshipfield",
"daterangepicker", "daterangepicker",
"multifieldselect", "multifieldselect",
"jsonfield" "jsonfield",
"s3upload"
] ]
}, },
{ {

View File

@ -55,8 +55,8 @@
<div> <div>
<BindingBuilder <BindingBuilder
bind:customParams={parameters.queryParams} bind:customParams={parameters.queryParams}
bindings={query.parameters} queryBindings={query.parameters}
bind:bindableOptions={bindings} bind:bindings
/> />
<IntegrationQueryEditor <IntegrationQueryEditor
height={200} height={200}

View File

@ -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>

View File

@ -11,3 +11,4 @@ export { default as ChangeFormStep } from "./ChangeFormStep.svelte"
export { default as UpdateState } from "./UpdateState.svelte" export { default as UpdateState } from "./UpdateState.svelte"
export { default as RefreshDataProvider } from "./RefreshDataProvider.svelte" export { default as RefreshDataProvider } from "./RefreshDataProvider.svelte"
export { default as DuplicateRow } from "./DuplicateRow.svelte" export { default as DuplicateRow } from "./DuplicateRow.svelte"
export { default as S3Upload } from "./S3Upload.svelte"

View File

@ -70,6 +70,16 @@
"name": "Update State", "name": "Update State",
"component": "UpdateState", "component": "UpdateState",
"dependsOnFeature": "state" "dependsOnFeature": "state"
},
{
"name": "Upload File to S3",
"component": "S3Upload",
"context": [
{
"label": "File URL",
"value": "publicUrl"
}
]
} }
] ]
} }

View File

@ -169,8 +169,8 @@
{#if getQueryParams(value).length > 0} {#if getQueryParams(value).length > 0}
<BindingBuilder <BindingBuilder
bind:customParams={tmpQueryParams} bind:customParams={tmpQueryParams}
bindings={getQueryParams(value)} queryBindings={getQueryParams(value)}
bind:bindableOptions={bindings} bind:bindings
/> />
{/if} {/if}
<IntegrationQueryEditor <IntegrationQueryEditor

View File

@ -131,7 +131,7 @@
{bindings} {bindings}
on:change={event => (filter.value = event.detail)} 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} /> <Input disabled={filter.noValue} bind:value={filter.value} />
{:else if ["options", "array"].includes(filter.type)} {:else if ["options", "array"].includes(filter.type)}
<Combobox <Combobox

View File

@ -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 />

View File

@ -1,5 +1,6 @@
import { Checkbox, Select, Stepper } from "@budibase/bbui" import { Checkbox, Select, Stepper } from "@budibase/bbui"
import DataSourceSelect from "./DataSourceSelect.svelte" import DataSourceSelect from "./DataSourceSelect.svelte"
import S3DataSourceSelect from "./S3DataSourceSelect.svelte"
import DataProviderSelect from "./DataProviderSelect.svelte" import DataProviderSelect from "./DataProviderSelect.svelte"
import ButtonActionEditor from "./ButtonActionEditor/ButtonActionEditor.svelte" import ButtonActionEditor from "./ButtonActionEditor/ButtonActionEditor.svelte"
import TableSelect from "./TableSelect.svelte" import TableSelect from "./TableSelect.svelte"
@ -22,6 +23,7 @@ const componentMap = {
text: DrawerBindableCombobox, text: DrawerBindableCombobox,
select: Select, select: Select,
dataSource: DataSourceSelect, dataSource: DataSourceSelect,
"dataSource/s3": S3DataSourceSelect,
dataProvider: DataProviderSelect, dataProvider: DataProviderSelect,
boolean: Checkbox, boolean: Checkbox,
number: Stepper, number: Stepper,

View File

@ -1,16 +1,14 @@
<script> <script>
import { Label, Select } from "@budibase/bbui" import { Label, Select } from "@budibase/bbui"
import { permissions, roles } from "stores/backend" import { permissions, roles } from "stores/backend"
import { onMount } from "svelte"
import { Roles } from "constants/backend" import { Roles } from "constants/backend"
export let query export let query
export let saveId
export let label export let label
$: updateRole(roleId, saveId) $: getPermissions(query)
let roleId, loaded let roleId, loaded, fetched
async function updateRole(role, id) { async function updateRole(role, id) {
roleId = role roleId = role
@ -26,19 +24,23 @@
} }
} }
onMount(async () => { async function getPermissions(queryToFetch) {
if (!query || !query._id) { if (fetched?._id === queryToFetch?._id) {
return
}
fetched = queryToFetch
if (!queryToFetch || !queryToFetch._id) {
roleId = Roles.BASIC roleId = Roles.BASIC
loaded = true loaded = true
return return
} }
try { try {
roleId = (await permissions.forResource(query._id))["read"] roleId = (await permissions.forResource(queryToFetch._id))["read"]
} catch (err) { } catch (err) {
roleId = Roles.BASIC roleId = Roles.BASIC
} }
loaded = true loaded = true
}) }
</script> </script>
{#if loaded} {#if loaded}

View File

@ -7,27 +7,24 @@
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let bindable = true export let bindable = true
export let queryBindings = []
export let bindings = [] export let bindings = []
export let bindableOptions = []
export let customParams = {} export let customParams = {}
function newQueryBinding() { function newQueryBinding() {
bindings = [...bindings, {}] queryBindings = [...queryBindings, {}]
} }
function deleteQueryBinding(idx) { function deleteQueryBinding(idx) {
bindings.splice(idx, 1) queryBindings.splice(idx, 1)
bindings = bindings queryBindings = queryBindings
} }
// This is necessary due to the way readable and writable bindings are stored. // 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 // 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 // for parsing, then converted back so we can display it the readable form in the UI
function onBindingChange(param, valueToParse) { function onBindingChange(param, valueToParse) {
customParams[param] = readableToRuntimeBinding( customParams[param] = readableToRuntimeBinding(bindings, valueToParse)
bindableOptions,
valueToParse
)
} }
</script> </script>
@ -49,7 +46,7 @@
{/if} {/if}
</Body> </Body>
<div class="bindings" class:bindable> <div class="bindings" class:bindable>
{#each bindings as binding, idx} {#each queryBindings as binding, idx}
<Input <Input
placeholder="Binding Name" placeholder="Binding Name"
thin thin
@ -69,10 +66,10 @@
thin thin
on:change={evt => onBindingChange(binding.name, evt.detail)} on:change={evt => onBindingChange(binding.name, evt.detail)}
value={runtimeToReadableBinding( value={runtimeToReadableBinding(
bindableOptions, bindings,
customParams?.[binding.name] customParams?.[binding.name]
)} )}
{bindableOptions} {bindings}
/> />
{:else} {:else}
<Icon hoverable name="Close" on:click={() => deleteQueryBinding(idx)} /> <Icon hoverable name="Close" on:click={() => deleteQueryBinding(idx)} />

View File

@ -120,7 +120,7 @@
config={integrationInfo.extra} config={integrationInfo.extra}
/> />
{/if} {/if}
<BindingBuilder bind:bindings={query.parameters} bindable={false} /> <BindingBuilder bind:queryBindings={query.parameters} bindable={false} />
{/if} {/if}
</div> </div>
{#if shouldShowQueryConfig} {#if shouldShowQueryConfig}

View File

@ -1,100 +1,46 @@
<script> <script>
import { writable, get as svelteGet } from "svelte/store" import { writable, get as svelteGet } from "svelte/store"
import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui" import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
import { store, automationStore, hostingStore } from "builderStore" import { store, automationStore } from "builderStore"
import { admin, auth } from "stores/portal" import { apps, admin, auth } from "stores/portal"
import { string, mixed, object } from "yup"
import api, { get, post } from "builderStore/api" import api, { get, post } from "builderStore/api"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
import { onMount } from "svelte" import { onMount } from "svelte"
import { capitalise } from "helpers"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { APP_NAME_REGEX } from "constants" import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app"
export let template export let template
export let inline
const values = writable({ name: null }) const values = writable({ name: "", url: null })
const errors = writable({}) const validation = createValidationStore()
const touched = writable({}) $: validation.check($values)
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
onMount(async () => { onMount(async () => {
await hostingStore.actions.fetchDeployedApps() await setupValidation()
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()
)
}
)
}) })
const checkValidity = async (values, validator) => { const setupValidation = async () => {
const obj = object().shape(validator) const applications = svelteGet(apps)
Object.keys(validator).forEach(key => ($errors[key] = null)) appValidation.name(validation, { apps: applications })
if (template?.fromFile && values.file == null) { appValidation.url(validation, { apps: applications })
valid = false appValidation.file(validation, { template })
return // init validation
} validation.check($values)
try {
await obj.validate(values, { abortEarly: false })
} catch (validationErrors) {
validationErrors.inner.forEach(error => {
$errors[error.path] = capitalise(error.message)
})
}
valid = await obj.isValid(values)
} }
async function createNewApp() { 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 { try {
// Create form data to create app // Create form data to create app
let data = new FormData() let data = new FormData()
data.append("name", $values.name.trim()) data.append("name", $values.name.trim())
data.append("useTemplate", templateToUse != null) if ($values.url) {
if (templateToUse) { data.append("url", $values.url.trim())
data.append("templateName", templateToUse.name) }
data.append("templateKey", templateToUse.key) data.append("useTemplate", template != null)
if (template) {
data.append("templateName", template.name)
data.append("templateKey", template.key)
data.append("templateFile", $values.file) data.append("templateFile", $values.file)
} }
@ -108,7 +54,7 @@
analytics.captureEvent(Events.APP.CREATED, { analytics.captureEvent(Events.APP.CREATED, {
name: $values.name, name: $values.name,
appId: appJson.instance._id, appId: appJson.instance._id,
templateToUse, templateToUse: template,
}) })
// Select Correct Application/DB in prep for creating user // Select Correct Application/DB in prep for creating user
@ -136,44 +82,51 @@
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error(error) notifications.error(error)
submitting = false
} }
} }
async function onCancel() { // auto add slash to url
template = null $: {
await auth.setInitInfo({}) if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
}
} }
</script> </script>
<ModalContent <ModalContent
title={"Name your app"} title={"Create your app"}
confirmText={template?.fromFile ? "Import app" : "Create app"} confirmText={template?.fromFile ? "Import app" : "Create app"}
onConfirm={createNewApp} onConfirm={createNewApp}
onCancel={inline ? onCancel : null} disabled={!$validation.valid}
cancelText={inline ? "Back" : undefined}
showCloseIcon={!inline}
disabled={!valid}
> >
{#if template?.fromFile} {#if template?.fromFile}
<Dropzone <Dropzone
error={$touched.file && $errors.file} error={$validation.touched.file && $validation.errors.file}
gallery={false} gallery={false}
label="File to import" label="File to import"
value={[$values.file]} value={[$values.file]}
on:change={e => { on:change={e => {
$values.file = e.detail?.[0] $values.file = e.detail?.[0]
$touched.file = true $validation.touched.file = true
}} }}
/> />
{/if} {/if}
<Input <Input
bind:value={$values.name} bind:value={$values.name}
error={$touched.name && $errors.name} error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($touched.name = true)} on:blur={() => ($validation.touched.name = true)}
label="Name" label="Name"
placeholder={$auth.user.firstName placeholder={$auth.user.firstName
? `${$auth.user.firstName}'s app` ? `${$auth.user.firstName}s app`
: "My app"} : "My app"}
/> />
<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> </ModalContent>

View File

@ -1,120 +1,75 @@
<script> <script>
import { writable, get as svelteGet } from "svelte/store" import { writable, get as svelteGet } from "svelte/store"
import { import { notifications, Input, ModalContent, Body } from "@budibase/bbui"
notifications,
Input,
Modal,
ModalContent,
Body,
} from "@budibase/bbui"
import { hostingStore } from "builderStore"
import { apps } from "stores/portal" import { apps } from "stores/portal"
import { string, object } from "yup"
import { onMount } from "svelte" import { onMount } from "svelte"
import { capitalise } from "helpers" import { createValidationStore } from "helpers/validation/yup"
import { APP_NAME_REGEX } from "constants" import * as appValidation from "helpers/validation/yup/app"
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"
),
}
export let app export let app
let modal const values = writable({ name: "", url: null })
let valid = false const validation = createValidationStore()
let dirty = false $: validation.check($values)
$: checkValidity($values, validator)
$: {
// prevent validation by setting name to undefined without an app
if (app) {
$values.name = app?.name
}
}
onMount(async () => { onMount(async () => {
await hostingStore.actions.fetchDeployedApps() $values.name = app.name
const existingAppNames = svelteGet(hostingStore).deployedAppNames $values.url = app.url
validator.name = string() setupValidation()
.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()
)
}
)
}) })
const checkValidity = async (values, validator) => { const setupValidation = async () => {
const obj = object().shape(validator) const applications = svelteGet(apps)
Object.keys(validator).forEach(key => ($errors[key] = null)) appValidation.name(validation, { apps: applications, currentApp: app })
try { appValidation.url(validation, { apps: applications, currentApp: app })
await obj.validate(values, { abortEarly: false }) // init validation
} catch (validationErrors) { validation.check($values)
validationErrors.inner.forEach(error => {
$errors[error.path] = capitalise(error.message)
})
}
valid = await obj.isValid(values)
} }
async function updateApp() { async function updateApp() {
try { try {
// Update App // Update App
await apps.update(app.instance._id, { name: $values.name.trim() }) const body = {
hide() name: $values.name.trim(),
}
if ($values.url) {
body.url = $values.url.trim()
}
await apps.update(app.instance._id, body)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error(error) notifications.error(error)
} }
} }
export const show = () => { // auto add slash to url
modal.show() $: {
if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
} }
export const hide = () => {
modal.hide()
}
const onCancel = () => {
hide()
}
const onShow = () => {
dirty = false
} }
</script> </script>
<Modal bind:this={modal} on:hide={onCancel} on:show={onShow}>
<ModalContent <ModalContent
title={"Edit app"} title={"Edit app"}
confirmText={"Save"} confirmText={"Save"}
onConfirm={updateApp} onConfirm={updateApp}
disabled={!(valid && dirty)} disabled={!$validation.valid}
> >
<Body size="S">Update the name of your app.</Body> <Body size="S">Update the name of your app.</Body>
<Input <Input
bind:value={$values.name} bind:value={$values.name}
error={$touched.name && $errors.name} error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($touched.name = true)} on:blur={() => ($validation.touched.name = true)}
on:change={() => (dirty = true)}
label="Name" label="Name"
/> />
<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> </ModalContent>
</Modal>

View File

@ -148,20 +148,23 @@ export const RelationshipTypes = {
} }
export const ALLOWABLE_STRING_OPTIONS = [FIELDS.STRING, FIELDS.OPTIONS] export const ALLOWABLE_STRING_OPTIONS = [FIELDS.STRING, FIELDS.OPTIONS]
export const ALLOWABLE_STRING_TYPES = ALLOWABLE_STRING_OPTIONS.map( export const ALLOWABLE_STRING_TYPES = ALLOWABLE_STRING_OPTIONS.map(
opt => opt.type opt => opt.type
) )
export const ALLOWABLE_NUMBER_OPTIONS = [FIELDS.NUMBER, FIELDS.BOOLEAN] export const ALLOWABLE_NUMBER_OPTIONS = [FIELDS.NUMBER, FIELDS.BOOLEAN]
export const ALLOWABLE_NUMBER_TYPES = ALLOWABLE_NUMBER_OPTIONS.map( export const ALLOWABLE_NUMBER_TYPES = ALLOWABLE_NUMBER_OPTIONS.map(
opt => opt.type opt => opt.type
) )
export const SWITCHABLE_TYPES = ALLOWABLE_NUMBER_TYPES.concat( export const ALLOWABLE_JSON_OPTIONS = [FIELDS.JSON, FIELDS.ARRAY]
ALLOWABLE_STRING_TYPES 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 = { export const IntegrationTypes = {
POSTGRES: "POSTGRES", POSTGRES: "POSTGRES",
@ -177,6 +180,7 @@ export const IntegrationTypes = {
ARANGODB: "ARANGODB", ARANGODB: "ARANGODB",
ORACLE: "ORACLE", ORACLE: "ORACLE",
INTERNAL: "INTERNAL", INTERNAL: "INTERNAL",
GOOGLE_SHEETS: "GOOGLE_SHEETS",
} }
export const IntegrationNames = { export const IntegrationNames = {
@ -193,6 +197,7 @@ export const IntegrationNames = {
[IntegrationTypes.ARANGODB]: "ArangoDB", [IntegrationTypes.ARANGODB]: "ArangoDB",
[IntegrationTypes.ORACLE]: "Oracle", [IntegrationTypes.ORACLE]: "Oracle",
[IntegrationTypes.INTERNAL]: "Internal", [IntegrationTypes.INTERNAL]: "Internal",
[IntegrationTypes.GOOGLE_SHEETS]: "Google Sheets",
} }
export const SchemaTypeOptions = [ export const SchemaTypeOptions = [

View File

@ -15,6 +15,22 @@ export const AppStatus = {
DEPLOYED: "published", 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 // fields on the user table that cannot be edited
export const UNEDITABLE_USER_FIELDS = [ export const UNEDITABLE_USER_FIELDS = [
"email", "email",
@ -36,4 +52,7 @@ export const LAYOUT_NAMES = {
export const BUDIBASE_INTERNAL_DB = "bb_internal" export const BUDIBASE_INTERNAL_DB = "bb_internal"
// one or more word characters and whitespace
export const APP_NAME_REGEX = /^[\w\s]+$/ export const APP_NAME_REGEX = /^[\w\s]+$/
// zero or more non-whitespace characters
export const APP_URL_REGEX = /^\S*$/

View File

@ -59,8 +59,7 @@ export const NoEmptyFilterStrings = [
*/ */
export const getValidOperatorsForType = type => { export const getValidOperatorsForType = type => {
const Op = OperatorOptions const Op = OperatorOptions
if (type === "string") { const stringOps = [
return [
Op.Equals, Op.Equals,
Op.NotEquals, Op.NotEquals,
Op.StartsWith, Op.StartsWith,
@ -68,8 +67,7 @@ export const getValidOperatorsForType = type => {
Op.Empty, Op.Empty,
Op.NotEmpty, Op.NotEmpty,
] ]
} else if (type === "number") { const numOps = [
return [
Op.Equals, Op.Equals,
Op.NotEquals, Op.NotEquals,
Op.MoreThan, Op.MoreThan,
@ -77,6 +75,10 @@ export const getValidOperatorsForType = type => {
Op.Empty, Op.Empty,
Op.NotEmpty, Op.NotEmpty,
] ]
if (type === "string") {
return stringOps
} else if (type === "number") {
return numOps
} else if (type === "options") { } else if (type === "options") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty] return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "array") { } else if (type === "array") {
@ -84,23 +86,11 @@ export const getValidOperatorsForType = type => {
} else if (type === "boolean") { } else if (type === "boolean") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty] return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") { } else if (type === "longform") {
return [ return stringOps
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "datetime") { } else if (type === "datetime") {
return [ return numOps
Op.Equals, } else if (type === "formula") {
Op.NotEquals, return stringOps.concat([Op.MoreThan, Op.LessThan])
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} }
return [] return []
} }

View File

@ -27,5 +27,8 @@ export function getFields(fields, { allowLinks } = { allowLinks: true }) {
filteredFields = filteredFields.concat(getTableFields(linkField)) filteredFields = filteredFields.concat(getTableFields(linkField))
} }
} }
return filteredFields const staticFormulaFields = fields.filter(
field => field.type === "formula" && field.formulaType === "static"
)
return filteredFields.concat(staticFormulaFields)
} }

View File

@ -1,5 +1,7 @@
import { writable, derived } from "svelte/store" import { writable, derived } from "svelte/store"
// DEPRECATED - Use the yup based validators for future validation
export function createValidationStore(initialValue, ...validators) { export function createValidationStore(initialValue, ...validators) {
let touched = false let touched = false

View File

@ -1,3 +1,5 @@
// TODO: Convert to yup based validators
export function emailValidator(value) { export function emailValidator(value) {
return ( return (
(value && (value &&

View File

@ -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
)
}

View File

@ -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,
}
}

View File

@ -61,7 +61,7 @@
await auth.setInitInfo({ init_template: $params["?template"] }) await auth.setInitInfo({ init_template: $params["?template"] })
} }
await auth.checkAuth() await auth.getSelf()
await admin.init() await admin.init()
if (useAccountPortal && multiTenancyEnabled) { if (useAccountPortal && multiTenancyEnabled) {

View File

@ -19,8 +19,8 @@
import { IntegrationTypes } from "constants/backend" import { IntegrationTypes } from "constants/backend"
import { isEqual } from "lodash" import { isEqual } from "lodash"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte" import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
let importQueriesModal let importQueriesModal
let changed let changed

View File

@ -12,7 +12,7 @@
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" 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 { goto } from "@roxi/routify"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { gradient } from "actions" import { gradient } from "actions"
@ -34,7 +34,6 @@
const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED
$: publishedApps = $apps.filter(publishedAppsOnly) $: publishedApps = $apps.filter(publishedAppsOnly)
$: isCloud = $admin.cloud
$: userApps = $auth.user?.builder?.global $: userApps = $auth.user?.builder?.global
? publishedApps ? publishedApps
: publishedApps.filter(app => : publishedApps.filter(app =>
@ -42,7 +41,11 @@
) )
function getUrl(app) { function getUrl(app) {
return !isCloud ? `/app/${encodeURIComponent(app.name)}` : `/${app.prodId}` if (app.url) {
return `/app${app.url}`
} else {
return `/${app.prodId}`
}
} }
</script> </script>

View File

@ -31,6 +31,7 @@
} }
onMount(async () => { onMount(async () => {
await auth.getSelf()
await organisation.init() await organisation.init()
}) })
</script> </script>

View File

@ -49,7 +49,6 @@
$: filteredApps = enrichedApps.filter(app => $: filteredApps = enrichedApps.filter(app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
) )
$: isCloud = $admin.cloud
const enrichApps = (apps, user, sortBy) => { const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({ const enrichedApps = apps.map(app => ({
@ -80,7 +79,7 @@
} }
const initiateAppCreation = () => { const initiateAppCreation = () => {
template = {} template = null
creationModal.show() creationModal.show()
creatingApp = true creatingApp = true
} }
@ -162,12 +161,10 @@
} }
const viewApp = app => { const viewApp = app => {
if (!isCloud && app.deployed) { if (app.url) {
// special case to use the short form name if self hosted window.open(`/app${app.url}`)
window.open(`/app/${encodeURIComponent(app.name)}`)
} else { } else {
const id = app.deployed ? app.prodId : app.devId window.open(`/${app.prodId}`)
window.open(`/${id}`, "_blank")
} }
} }
@ -340,6 +337,14 @@
}} }}
class="template-card" 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 class="card-body">
<div style="color: {item.background}" class="iconAlign"> <div style="color: {item.background}" class="iconAlign">
<svg <svg
@ -442,6 +447,11 @@
> >
<CreateAppModal {template} /> <CreateAppModal {template} />
</Modal> </Modal>
<Modal bind:this={updatingModal} padding={false} width="600px">
<UpdateAppModal app={selectedApp} />
</Modal>
<ConfirmDialog <ConfirmDialog
bind:this={deletionModal} bind:this={deletionModal}
title="Confirm deletion" title="Confirm deletion"
@ -468,7 +478,6 @@
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog> </ConfirmDialog>
<UpdateAppModal app={selectedApp} bind:this={updatingModal} />
<ChooseIconModal app={selectedApp} bind:this={iconModal} /> <ChooseIconModal app={selectedApp} bind:this={iconModal} />
<style> <style>
@ -517,6 +526,7 @@
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
position: relative;
} }
.template-card:hover { .template-card:hover {
@ -527,6 +537,18 @@
align-items: center; align-items: center;
padding: 12px; 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 { .iconAlign {
padding: 0 0 0 var(--spacing-m); padding: 0 0 0 var(--spacing-m);
display: inline-block; display: inline-block;

View File

@ -108,11 +108,7 @@ export function createAuthStore() {
return json return json
} }
return { const actions = {
subscribe: store.subscribe,
setOrganisation,
getInitInfo,
setInitInfo,
checkQueryString: async () => { checkQueryString: async () => {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has("tenantId")) { if (urlParams.has("tenantId")) {
@ -123,7 +119,7 @@ export function createAuthStore() {
setOrg: async tenantId => { setOrg: async tenantId => {
await setOrganisation(tenantId) await setOrganisation(tenantId)
}, },
checkAuth: async () => { getSelf: async () => {
const response = await api.get("/api/global/users/self") const response = await api.get("/api/global/users/self")
if (response.status !== 200) { if (response.status !== 200) {
setUser(null) setUser(null)
@ -138,13 +134,12 @@ export function createAuthStore() {
`/api/global/auth/${tenantId}/login`, `/api/global/auth/${tenantId}/login`,
creds creds
) )
const json = await response.json()
if (response.status === 200) { if (response.status === 200) {
setUser(json.user) await actions.getSelf()
} else { } else {
const json = await response.json()
throw new Error(json.message ? json.message : "Invalid credentials") throw new Error(json.message ? json.message : "Invalid credentials")
} }
return json
}, },
logout: async () => { logout: async () => {
const response = await api.post(`/api/global/auth/logout`) const response = await api.post(`/api/global/auth/logout`)
@ -197,6 +192,14 @@ export function createAuthStore() {
await response.json() await response.json()
}, },
} }
return {
subscribe: store.subscribe,
setOrganisation,
getInitInfo,
setInitInfo,
...actions,
}
} }
export const auth = createAuthStore() export const auth = createAuthStore()

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "1.0.44-alpha.9", "version": "1.0.49-alpha.4",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {

View File

@ -3340,5 +3340,50 @@
"suffix": "repeater" "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"
}
]
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "1.0.44-alpha.9", "version": "1.0.49-alpha.4",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,10 +19,11 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.0.44-alpha.9", "@budibase/bbui": "^1.0.49-alpha.4",
"@budibase/standard-components": "^0.9.139", "@budibase/standard-components": "^0.9.139",
"@budibase/string-templates": "^1.0.44-alpha.9", "@budibase/string-templates": "^1.0.49-alpha.4",
"regexparam": "^1.3.0", "regexparam": "^1.3.0",
"rollup-plugin-polyfill-node": "^0.8.0",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"svelte-spa-router": "^3.0.5" "svelte-spa-router": "^3.0.5"
}, },
@ -45,8 +46,6 @@
"postcss": "^8.2.10", "postcss": "^8.2.10",
"rollup": "^2.44.0", "rollup": "^2.44.0",
"rollup-plugin-json": "^4.0.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-postcss": "^4.0.0",
"rollup-plugin-svelte": "^7.1.0", "rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-svg": "^2.0.0", "rollup-plugin-svg": "^2.0.0",

View File

@ -6,8 +6,7 @@ import { terser } from "rollup-plugin-terser"
import postcss from "rollup-plugin-postcss" import postcss from "rollup-plugin-postcss"
import svg from "rollup-plugin-svg" import svg from "rollup-plugin-svg"
import json from "rollup-plugin-json" import json from "rollup-plugin-json"
import builtins from "rollup-plugin-node-builtins" import nodePolyfills from "rollup-plugin-polyfill-node"
import globals from "rollup-plugin-node-globals"
import path from "path" import path from "path"
const production = !process.env.ROLLUP_WATCH const production = !process.env.ROLLUP_WATCH
@ -75,8 +74,7 @@ export default {
}), }),
postcss(), postcss(),
commonjs(), commonjs(),
globals(), nodePolyfills(),
builtins(),
resolve({ resolve({
preferBuiltins: true, preferBuiltins: true,
browser: true, browser: true,

View File

@ -1,4 +1,5 @@
import { notificationStore } from "stores" import { notificationStore, authStore } from "stores"
import { get } from "svelte/store"
import { ApiVersion } from "constants" import { ApiVersion } from "constants"
/** /**
@ -28,6 +29,13 @@ const makeApiCall = async ({ method, url, body, json = true }) => {
...(json && { "Content-Type": "application/json" }), ...(json && { "Content-Type": "application/json" }),
...(!inBuilder && { "x-budibase-type": "client" }), ...(!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, { const response = await fetch(url, {
method, method,
headers, headers,
@ -36,7 +44,11 @@ const makeApiCall = async ({ method, url, body, json = true }) => {
}) })
switch (response.status) { switch (response.status) {
case 200: case 200:
return response.json() try {
return await response.json()
} catch (error) {
return null
}
case 401: case 401:
notificationStore.actions.error("Invalid credentials") notificationStore.actions.error("Invalid credentials")
return handleError(`Invalid credentials`) return handleError(`Invalid credentials`)
@ -82,14 +94,15 @@ const makeCachedApiCall = async params => {
* Constructs an API call function for a particular HTTP method. * Constructs an API call function for a particular HTTP method.
*/ */
const requestApiCall = method => async params => { const requestApiCall = method => async params => {
const { url, cache = false } = params const { external = false, url, cache = false } = params
const fixedUrl = `/${url}`.replace("//", "/") const fixedUrl = external ? url : `/${url}`.replace("//", "/")
const enrichedParams = { ...params, method, url: fixedUrl } const enrichedParams = { ...params, method, url: fixedUrl }
return await (cache ? makeCachedApiCall : makeApiCall)(enrichedParams) return await (cache ? makeCachedApiCall : makeApiCall)(enrichedParams)
} }
export default { export default {
post: requestApiCall("POST"), post: requestApiCall("POST"),
put: requestApiCall("PUT"),
get: requestApiCall("GET"), get: requestApiCall("GET"),
patch: requestApiCall("PATCH"), patch: requestApiCall("PATCH"),
del: requestApiCall("DELETE"), del: requestApiCall("DELETE"),

View File

@ -10,3 +10,41 @@ export const uploadAttachment = async (data, tableId = "") => {
json: false, 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 }
}

View File

@ -276,27 +276,29 @@
// reactive statements as much as possible. // reactive statements as much as possible.
const cacheSettings = (enriched, nested, conditional) => { const cacheSettings = (enriched, nested, conditional) => {
const allSettings = { ...enriched, ...nested, ...conditional } const allSettings = { ...enriched, ...nested, ...conditional }
if (!cachedSettings) { const mounted = ref?.$$set != null
if (!cachedSettings || !mounted) {
cachedSettings = { ...allSettings } cachedSettings = { ...allSettings }
initialSettings = cachedSettings initialSettings = cachedSettings
} else { } else {
Object.keys(allSettings).forEach(key => { Object.keys(allSettings).forEach(key => {
const same = propsAreSame(allSettings[key], cachedSettings[key]) const same = propsAreSame(allSettings[key], cachedSettings[key])
if (!same) { 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] 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. // Generates a key used to determine when components need to fully remount.
// Currently only toggling editing requires remounting. // Currently only toggling editing requires remounting.
const getRenderKey = (id, editing) => { const getRenderKey = (id, editing) => {
@ -305,7 +307,7 @@
</script> </script>
{#key renderKey} {#key renderKey}
{#if constructor && cachedSettings && (visible || inSelectedPath)} {#if constructor && initialSettings && (visible || inSelectedPath)}
<!-- The ID is used as a class because getElementsByClassName is O(1) --> <!-- The ID is used as a class because getElementsByClassName is O(1) -->
<!-- and the performance matters for the selection indicators --> <!-- and the performance matters for the selection indicators -->
<div <div

View File

@ -39,6 +39,7 @@
number: "numberfield", number: "numberfield",
datetime: "datetimefield", datetime: "datetimefield",
boolean: "booleanfield", boolean: "booleanfield",
formula: "stringfield",
} }
let formId let formId

View File

@ -35,6 +35,7 @@
number: "numberfield", number: "numberfield",
datetime: "datetimefield", datetime: "datetimefield",
boolean: "booleanfield", boolean: "booleanfield",
formula: "stringfield",
} }
let formId let formId
@ -60,10 +61,11 @@
let enrichedFilter = [...(filter || [])] let enrichedFilter = [...(filter || [])]
columns?.forEach(column => { columns?.forEach(column => {
const safePath = column.name.split(".").map(safe).join(".") const safePath = column.name.split(".").map(safe).join(".")
const stringType = column.type === "string" || column.type === "formula"
enrichedFilter.push({ enrichedFilter.push({
field: column.name, field: column.name,
operator: column.type === "string" ? "string" : "equal", operator: stringType ? "string" : "equal",
type: column.type === "string" ? "string" : "number", type: stringType ? "string" : "number",
valueType: "Binding", valueType: "Binding",
value: `{{ ${safe(formId)}.${safePath} }}`, value: `{{ ${safe(formId)}.${safePath} }}`,
}) })

View File

@ -19,10 +19,14 @@
export let schemaFields export let schemaFields
export let filters = [] export let filters = []
const BannedTypes = ["link", "attachment", "formula", "json"] const BannedTypes = ["link", "attachment", "json"]
$: fieldOptions = (schemaFields ?? []) $: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type)) .filter(
field =>
!BannedTypes.includes(field.type) ||
(field.type === "formula" && field.formulaType === "static")
)
.map(field => field.name) .map(field => field.name)
const addFilter = () => { const addFilter = () => {
@ -114,7 +118,7 @@
on:change={e => onOperatorChange(filter, e.detail)} on:change={e => onOperatorChange(filter, e.detail)}
placeholder={null} placeholder={null}
/> />
{#if ["string", "longform", "number"].includes(filter.type)} {#if ["string", "longform", "number", "formula"].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} /> <Input disabled={filter.noValue} bind:value={filter.value} />
{:else if ["options", "array"].includes(filter.type)} {:else if ["options", "array"].includes(filter.type)}
<Combobox <Combobox

View File

@ -32,6 +32,7 @@
validation, validation,
formStep formStep
) )
$: schemaType = fieldSchema?.type !== "formula" ? fieldSchema?.type : "string"
// Focus label when editing // Focus label when editing
let labelNode let labelNode
@ -72,7 +73,7 @@
<Placeholder <Placeholder
text="Add the Field setting to start using your component" text="Add the Field setting to start using your component"
/> />
{:else if fieldSchema?.type && fieldSchema?.type !== type && type !== "options"} {:else if schemaType && schemaType !== type && type !== "options"}
<Placeholder <Placeholder
text="This Field setting is the wrong data type for this component" text="This Field setting is the wrong data type for this component"
/> />

View File

@ -0,0 +1,143 @@
<script>
import Field from "./Field.svelte"
import { CoreDropzone, ProgressCircle } from "@budibase/bbui"
import { getContext, onMount, onDestroy } from "svelte"
export let datasourceId
export let bucket
export let key
export let field
export let label
export let disabled = false
export let validation
let fieldState
let fieldApi
const { API, notificationStore, uploadStore } = getContext("sdk")
const component = getContext("component")
// 5GB cap per item sent via S3 REST API
const MaxFileSize = 1000000000 * 5
// Actual file data to upload
let data
let loading = false
const handleFileTooLarge = () => {
notificationStore.actions.warning(
"Files cannot exceed 5GB. Please try again with a smaller file."
)
}
// Process the file input and return a serializable structure expected by
// the dropzone component to display the file
const processFiles = async fileList => {
return await new Promise(resolve => {
if (!fileList?.length) {
return []
}
// Don't read in non-image files
data = fileList[0]
if (!data.type?.startsWith("image")) {
resolve([
{
name: data.name,
type: data.type,
},
])
}
// Read image files and display as preview
const reader = new FileReader()
reader.addEventListener(
"load",
() => {
resolve([
{
url: reader.result,
name: data.name,
type: data.type,
},
])
},
false
)
reader.readAsDataURL(fileList[0])
})
}
const upload = async () => {
loading = true
try {
const res = await API.externalUpload(datasourceId, bucket, key, data)
notificationStore.actions.success("File uploaded successfully")
loading = false
return res
} catch (error) {
notificationStore.actions.error(`Error uploading file: ${error}`)
}
}
onMount(() => {
uploadStore.actions.registerFileUpload($component.id, upload)
})
onDestroy(() => {
uploadStore.actions.unregisterFileUpload($component.id)
})
</script>
<Field
{label}
{field}
{disabled}
{validation}
type="s3upload"
bind:fieldState
bind:fieldApi
defaultValue={[]}
>
<div class="content">
{#if fieldState}
<CoreDropzone
value={fieldState.value}
disabled={loading || fieldState.disabled}
error={fieldState.error}
on:change={e => {
fieldApi.setValue(e.detail)
}}
{processFiles}
{handleFileTooLarge}
maximum={1}
fileSizeLimit={MaxFileSize}
/>
{/if}
{#if loading}
<div class="overlay" />
<div class="loading">
<ProgressCircle />
</div>
{/if}
</div>
</Field>
<style>
.content {
position: relative;
}
.overlay,
.loading {
position: absolute;
top: 0;
height: 100%;
width: 100%;
display: grid;
place-items: center;
}
.overlay {
background-color: var(--spectrum-global-color-gray-50);
opacity: 0.5;
}
</style>

View File

@ -12,3 +12,4 @@ export { default as relationshipfield } from "./RelationshipField.svelte"
export { default as passwordfield } from "./PasswordField.svelte" export { default as passwordfield } from "./PasswordField.svelte"
export { default as formstep } from "./FormStep.svelte" export { default as formstep } from "./FormStep.svelte"
export { default as jsonfield } from "./JSONField.svelte" export { default as jsonfield } from "./JSONField.svelte"
export { default as s3upload } from "./S3Upload.svelte"

View File

@ -5,6 +5,7 @@ import {
routeStore, routeStore,
screenStore, screenStore,
builderStore, builderStore,
uploadStore,
} from "stores" } from "stores"
import { styleable } from "utils/styleable" import { styleable } from "utils/styleable"
import { linkable } from "utils/linkable" import { linkable } from "utils/linkable"
@ -20,6 +21,7 @@ export default {
routeStore, routeStore,
screenStore, screenStore,
builderStore, builderStore,
uploadStore,
styleable, styleable,
linkable, linkable,
getAction, getAction,

View File

@ -9,6 +9,7 @@ export { confirmationStore } from "./confirmation"
export { peekStore } from "./peek" export { peekStore } from "./peek"
export { stateStore } from "./state" export { stateStore } from "./state"
export { themeStore } from "./theme" export { themeStore } from "./theme"
export { uploadStore } from "./uploads.js"
// Context stores are layered and duplicated, so it is not a singleton // Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context" export { createContextStore } from "./context"

View File

@ -0,0 +1,42 @@
import { writable, get } from "svelte/store"
export const createUploadStore = () => {
const store = writable([])
// Registers a new file upload component
const registerFileUpload = (componentId, callback) => {
if (!componentId || !callback) {
return
}
store.update(state => {
state.push({
componentId,
callback,
})
return state
})
}
// Unregisters a file upload component
const unregisterFileUpload = componentId => {
store.update(state => state.filter(c => c.componentId !== componentId))
}
// Processes a file upload for a given component ID
const processFileUpload = async componentId => {
if (!componentId) {
return
}
const component = get(store).find(c => c.componentId === componentId)
return await component?.callback()
}
return {
subscribe: store.subscribe,
actions: { registerFileUpload, unregisterFileUpload, processFileUpload },
}
}
export const uploadStore = createUploadStore()

View File

@ -5,6 +5,7 @@ import {
confirmationStore, confirmationStore,
authStore, authStore,
stateStore, stateStore,
uploadStore,
} from "stores" } from "stores"
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api" import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api"
import { ActionTypes } from "constants" import { ActionTypes } from "constants"
@ -169,6 +170,17 @@ const updateStateHandler = action => {
} }
} }
const s3UploadHandler = async action => {
const { componentId } = action.parameters
if (!componentId) {
return
}
const res = await uploadStore.actions.processFileUpload(componentId)
return {
publicUrl: res?.publicUrl,
}
}
const handlerMap = { const handlerMap = {
["Save Row"]: saveRowHandler, ["Save Row"]: saveRowHandler,
["Duplicate Row"]: duplicateRowHandler, ["Duplicate Row"]: duplicateRowHandler,
@ -183,6 +195,7 @@ const handlerMap = {
["Close Screen Modal"]: closeScreenModalHandler, ["Close Screen Modal"]: closeScreenModalHandler,
["Change Form Step"]: changeFormStepHandler, ["Change Form Step"]: changeFormStepHandler,
["Update State"]: updateStateHandler, ["Update State"]: updateStateHandler,
["Upload File to S3"]: s3UploadHandler,
} }
const confirmTextMap = { const confirmTextMap = {

View File

@ -110,12 +110,6 @@ export default class DataFetch {
*/ */
async getInitialData() { async getInitialData() {
const { datasource, filter, sortColumn, paginate } = this.options const { datasource, filter, sortColumn, paginate } = this.options
const tableId = datasource?.tableId
// Ensure table ID exists
if (!tableId) {
return
}
// Fetch datasource definition and determine feature flags // Fetch datasource definition and determine feature flags
const definition = await this.constructor.getDefinition(datasource) const definition = await this.constructor.getDefinition(datasource)

View File

@ -16,7 +16,12 @@ export default class QueryFetch extends DataFetch {
if (!datasource?._id) { if (!datasource?._id) {
return null return null
} }
return await fetchQueryDefinition(datasource._id) const definition = await fetchQueryDefinition(datasource._id)
// After getting the definition of query, it loses "fields" attribute because of security reason from the server. However, this attribute needs to be inside of defintion for pagination.
if (!definition.fields) {
definition.fields = datasource.fields
}
return definition
} }
async getData() { async getData() {

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{ {
"watch": ["src", "../auth"], "watch": ["src", "../backend-core"],
"ext": "js,ts,json", "ext": "js,ts,json",
"ignore": ["src/**/*.spec.ts", "src/**/*.spec.js"], "ignore": ["src/**/*.spec.ts", "src/**/*.spec.js"],
"exec": "ts-node src/index.ts" "exec": "ts-node src/index.ts"

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/server", "name": "@budibase/server",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "1.0.44-alpha.9", "version": "1.0.49-alpha.4",
"description": "Budibase Web Server", "description": "Budibase Web Server",
"main": "src/index.ts", "main": "src/index.ts",
"repository": { "repository": {
@ -70,9 +70,9 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^10.0.3", "@apidevtools/swagger-parser": "^10.0.3",
"@budibase/backend-core": "^1.0.44-alpha.9", "@budibase/backend-core": "^1.0.49-alpha.4",
"@budibase/client": "^1.0.44-alpha.9", "@budibase/client": "^1.0.49-alpha.4",
"@budibase/string-templates": "^1.0.44-alpha.9", "@budibase/string-templates": "^1.0.49-alpha.4",
"@bull-board/api": "^3.7.0", "@bull-board/api": "^3.7.0",
"@bull-board/koa": "^3.7.0", "@bull-board/koa": "^3.7.0",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",
@ -92,6 +92,8 @@
"fix-path": "3.0.0", "fix-path": "3.0.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"fs-extra": "8.1.0", "fs-extra": "8.1.0",
"google-auth-library": "^7.11.0",
"google-spreadsheet": "^3.2.0",
"jimp": "0.16.1", "jimp": "0.16.1",
"joi": "17.2.1", "joi": "17.2.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@ -110,7 +112,7 @@
"mongodb": "3.6.3", "mongodb": "3.6.3",
"mssql": "6.2.3", "mssql": "6.2.3",
"mysql2": "^2.3.1", "mysql2": "^2.3.1",
"node-fetch": "2.6.0", "node-fetch": "2.6.7",
"open": "^8.4.0", "open": "^8.4.0",
"pg": "8.5.1", "pg": "8.5.1",
"pino-pretty": "4.0.0", "pino-pretty": "4.0.0",
@ -139,6 +141,7 @@
"@jest/test-sequencer": "^24.8.0", "@jest/test-sequencer": "^24.8.0",
"@types/apidoc": "^0.50.0", "@types/apidoc": "^0.50.0",
"@types/bull": "^3.15.1", "@types/bull": "^3.15.1",
"@types/google-spreadsheet": "^3.1.5",
"@types/jest": "^26.0.23", "@types/jest": "^26.0.23",
"@types/koa": "^2.13.3", "@types/koa": "^2.13.3",
"@types/koa-router": "^7.4.2", "@types/koa-router": "^7.4.2",

View File

@ -33,10 +33,7 @@ const {
Replication, Replication,
} = require("@budibase/backend-core/db") } = require("@budibase/backend-core/db")
const { USERS_TABLE_SCHEMA } = require("../../constants") const { USERS_TABLE_SCHEMA } = require("../../constants")
const { const { removeAppFromUserRoles } = require("../../utilities/workerRequests")
getDeployedApps,
removeAppFromUserRoles,
} = require("../../utilities/workerRequests")
const { clientLibraryPath, stringToReadStream } = require("../../utilities") const { clientLibraryPath, stringToReadStream } = require("../../utilities")
const { getAllLocks } = require("../../utilities/redis") const { getAllLocks } = require("../../utilities/redis")
const { const {
@ -78,29 +75,42 @@ function getUserRoleId(ctx) {
: ctx.user.role._id : ctx.user.role._id
} }
async function getAppUrlIfNotInUse(ctx) { exports.getAppUrl = ctx => {
// construct the url
let url let url
if (ctx.request.body.url) { if (ctx.request.body.url) {
// if the url is provided, use that
url = encodeURI(ctx.request.body.url) url = encodeURI(ctx.request.body.url)
} else if (ctx.request.body.name) { } else if (ctx.request.body.name) {
// otherwise use the name
url = encodeURI(`${ctx.request.body.name}`) url = encodeURI(`${ctx.request.body.name}`)
} }
if (url) { if (url) {
url = `/${url.replace(URL_REGEX_SLASH, "")}`.toLowerCase() url = `/${url.replace(URL_REGEX_SLASH, "")}`.toLowerCase()
} }
if (!env.SELF_HOSTED) {
return url return url
} }
const deployedApps = await getDeployedApps()
if ( const checkAppUrl = (ctx, apps, url, currentAppId) => {
url && if (currentAppId) {
deployedApps[url] != null && apps = apps.filter(app => app.appId !== currentAppId)
ctx.params != null && }
deployedApps[url].appId !== ctx.params.appId if (apps.some(app => app.url === url)) {
) { ctx.throw(400, "App URL is already in use.")
ctx.throw(400, "App name/URL is already in use.") }
}
const checkAppName = (ctx, apps, name, currentAppId) => {
// TODO: Replace with Joi
if (!name) {
ctx.throw(400, "Name is required")
}
if (currentAppId) {
apps = apps.filter(app => app.appId !== currentAppId)
}
if (apps.some(app => app.name === name)) {
ctx.throw(400, "App name is already in use.")
} }
return url
} }
async function createInstance(template) { async function createInstance(template) {
@ -206,6 +216,12 @@ exports.fetchAppPackage = async ctx => {
} }
exports.create = async ctx => { exports.create = async ctx => {
const apps = await getAllApps(CouchDB, { dev: true })
const name = ctx.request.body.name
checkAppName(ctx, apps, name)
const url = exports.getAppUrl(ctx)
checkAppUrl(ctx, apps, url)
const { useTemplate, templateKey, templateString } = ctx.request.body const { useTemplate, templateKey, templateString } = ctx.request.body
const instanceConfig = { const instanceConfig = {
useTemplate, useTemplate,
@ -218,7 +234,6 @@ exports.create = async ctx => {
const instance = await createInstance(instanceConfig) const instance = await createInstance(instanceConfig)
const appId = instance._id const appId = instance._id
const url = await getAppUrlIfNotInUse(ctx)
const db = new CouchDB(appId) const db = new CouchDB(appId)
let _rev let _rev
try { try {
@ -235,7 +250,7 @@ exports.create = async ctx => {
type: "app", type: "app",
version: packageJson.version, version: packageJson.version,
componentLibraries: ["@budibase/standard-components"], componentLibraries: ["@budibase/standard-components"],
name: ctx.request.body.name, name: name,
url: url, url: url,
template: ctx.request.body.template, template: ctx.request.body.template,
instance: instance, instance: instance,
@ -262,8 +277,22 @@ exports.create = async ctx => {
ctx.body = newApplication ctx.body = newApplication
} }
// This endpoint currently operates as a PATCH rather than a PUT
// Thus name and url fields are handled only if present
exports.update = async ctx => { exports.update = async ctx => {
const data = await updateAppPackage(ctx, ctx.request.body, ctx.params.appId) const apps = await getAllApps(CouchDB, { dev: true })
// validation
const name = ctx.request.body.name
if (name) {
checkAppName(ctx, apps, name, ctx.params.appId)
}
const url = await exports.getAppUrl(ctx)
if (url) {
checkAppUrl(ctx, apps, url, ctx.params.appId)
ctx.request.body.url = url
}
const data = await updateAppPackage(ctx.request.body, ctx.params.appId)
ctx.status = 200 ctx.status = 200
ctx.body = data ctx.body = data
} }
@ -285,7 +314,7 @@ exports.updateClient = async ctx => {
version: packageJson.version, version: packageJson.version,
revertableVersion: currentVersion, revertableVersion: currentVersion,
} }
const data = await updateAppPackage(ctx, appPackageUpdates, ctx.params.appId) const data = await updateAppPackage(appPackageUpdates, ctx.params.appId)
ctx.status = 200 ctx.status = 200
ctx.body = data ctx.body = data
} }
@ -308,7 +337,7 @@ exports.revertClient = async ctx => {
version: application.revertableVersion, version: application.revertableVersion,
revertableVersion: null, revertableVersion: null,
} }
const data = await updateAppPackage(ctx, appPackageUpdates, ctx.params.appId) const data = await updateAppPackage(appPackageUpdates, ctx.params.appId)
ctx.status = 200 ctx.status = 200
ctx.body = data ctx.body = data
} }
@ -381,12 +410,11 @@ exports.sync = async (ctx, next) => {
} }
} }
const updateAppPackage = async (ctx, appPackage, appId) => { const updateAppPackage = async (appPackage, appId) => {
const url = await getAppUrlIfNotInUse(ctx)
const db = new CouchDB(appId) const db = new CouchDB(appId)
const application = await db.get(DocumentTypes.APP_METADATA) const application = await db.get(DocumentTypes.APP_METADATA)
const newAppPackage = { ...application, ...appPackage, url } const newAppPackage = { ...application, ...appPackage }
if (appPackage._rev !== application._rev) { if (appPackage._rev !== application._rev) {
newAppPackage._rev = application._rev newAppPackage._rev = application._rev
} }

Some files were not shown because too many files have changed in this diff Show More