1
0
Fork 0
mirror of synced 2024-06-02 02:25:17 +12:00
This commit is contained in:
Martin McKeaveney 2022-02-01 11:22:14 +01:00
commit 394eb44b5f
224 changed files with 7610 additions and 3863 deletions

46
.github/workflows/smoke_test.yaml vendored Normal file
View file

@ -0,0 +1,46 @@
name: Budibase Smoke Test
on:
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 14.x
- run: yarn
- run: yarn bootstrap
- run: yarn build
- name: Pull cypress.env.yaml from budibase-infra
run: |
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
-H 'Accept: application/vnd.github.v3.raw' \
-o packages/builder/cypress.env.json \
-L https://api.github.com/repos/budibase/budibase-infra/contents/test/cypress.env.json
wc -l packages/builder/cypress.env.json
- run: yarn test:e2e:ci
env:
CI: true
name: Budibase CI
# TODO: upload recordings to s3
# - name: Configure AWS Credentials
# uses: aws-actions/configure-aws-credentials@v1
# with:
# aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# aws-region: eu-west-1
# TODO look at cypress reporters
# - name: Discord Webhook Action
# uses: tsickert/discord-webhook@v4.0.0
# with:
# webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
# content: "Production Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Cloud."
# embed-title: ${{ env.RELEASE_VERSION }}

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/mslourens"><img src="https://avatars.githubusercontent.com/u/1907152?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maurits Lourens</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Tests">⚠️</a> <a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Rory-Powell"><img src="https://avatars.githubusercontent.com/u/8755148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rory Powell</b></sub></a><br /><a href="#infra-Rory-Powell" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Tests">⚠️</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->

View file

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

View file

@ -93,10 +93,13 @@ globals:
logLevel: info
selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup
multiTenancy: "0" # set to 0 to disable multiple orgs, set to 1 to enable multiple orgs
useQuotas: "0"
excludeQuotasTenants: "" # comma seperated list of tenants to exclude from quotas
accountPortalUrl: ""
accountPortalApiKey: ""
cookieDomain: ""
platformUrl: ""
httpMigrations: "0"
createSecrets: true # creates an internal API key, JWT secrets and redis password for you
@ -239,7 +242,8 @@ couchdb:
hosts:
- chart-example.local
path: /
annotations: []
annotations:
[]
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
tls:

View file

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

View file

@ -36,10 +36,10 @@
"dev:server": "lerna run --parallel dev:builder --concurrency 1 --scope @budibase/worker --scope @budibase/server",
"test": "lerna run test",
"lint:eslint": "eslint packages",
"lint:prettier": "prettier --check \"packages/**/*.{js,svelte}\"",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\"",
"lint": "yarn run lint:eslint && yarn run lint:prettier",
"lint:fix:eslint": "eslint --fix packages",
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,svelte}\"",
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\"",
"lint:fix:ts": "lerna run lint:fix",
"lint:fix": "yarn run lint:fix:ts && yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"test:e2e": "lerna run cy:test",

View file

@ -1,4 +1,5 @@
module.exports = {
...require("./src/db/utils"),
...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",
"version": "1.0.43",
"version": "1.0.49-alpha.5",
"description": "Budibase backend core libraries used in server and worker",
"main": "src/index.js",
"author": "Budibase",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -60,6 +60,7 @@ module.exports = (
} else {
user = await getUser(userId, session.tenantId)
}
user.csrfToken = session.csrfToken
delete user.password
authenticated = true
} 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 tenancy = require("./tenancy")
const appTenancy = require("./appTenancy")
const internalApi = require("./internalApi")
const datasourceGoogle = require("./passport/datasource/google")
const csrf = require("./csrf")
module.exports = {
google,
@ -18,4 +21,9 @@ module.exports = {
tenancy,
appTenancy,
authError,
internalApi,
datasource: {
google: datasourceGoogle,
},
csrf,
}

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 { getGlobalDB } = require("../tenancy")
const { getAllApps } = require("../db/utils")
const environment = require("../environment")
const {
doInTenant,
getTenantIds,
getGlobalDBName,
getTenantId,
} = require("../tenancy")
exports.MIGRATION_DBS = {
GLOBAL_DB: "GLOBAL_DB",
}
exports.MIGRATIONS = {
USER_EMAIL_VIEW_CASING: "user_email_view_casing",
}
const DB_LOOKUP = {
[exports.MIGRATION_DBS.GLOBAL_DB]: [
exports.MIGRATIONS.USER_EMAIL_VIEW_CASING,
],
exports.MIGRATION_TYPES = {
GLOBAL: "global", // run once, recorded in global db, global db is provided as an argument
APP: "app", // run per app, recorded in each app db, app db is provided as an argument
}
exports.getMigrationsDoc = async db => {
@ -26,36 +25,90 @@ exports.getMigrationsDoc = async db => {
}
}
exports.migrateIfRequired = async (migrationDb, migrationName, migrateFn) => {
try {
let db
if (migrationDb === exports.MIGRATION_DBS.GLOBAL_DB) {
db = getGlobalDB()
} else {
throw new Error(`Unrecognised migration db [${migrationDb}]`)
}
const runMigration = async (CouchDB, migration, options = {}) => {
const tenantId = getTenantId()
const migrationType = migration.type
const migrationName = migration.name
if (!DB_LOOKUP[migrationDb].includes(migrationName)) {
throw new Error(
`Unrecognised migration name [${migrationName}] for db [${migrationDb}]`
// 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(
`[Tenant: ${tenantId}] Unrecognised migration type [${migrationType}]`
)
}
// run the migration against each db
for (const dbName of dbNames) {
const db = new CouchDB(dbName)
try {
const doc = await exports.getMigrationsDoc(db)
// exit if the migration has been performed already
if (doc[migrationName]) {
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(
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Running`
)
// run the migration with tenant context
await migration.fn(db)
console.log(
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Complete`
)
// mark as complete
doc[migrationName] = Date.now()
await db.put(doc)
} catch (err) {
console.error(
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Error: `,
err
)
throw err
}
const doc = await exports.getMigrationsDoc(db)
// exit if the migration has been performed
if (doc[migrationName]) {
return
}
console.log(`Performing migration: ${migrationName}`)
await migrateFn()
console.log(`Migration complete: ${migrationName}`)
// mark as complete
doc[migrationName] = Date.now()
await db.put(doc)
} catch (err) {
console.error(`Error performing migration: ${migrationName}: `, 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`] = `
Object {
"_id": "migrations",
"_rev": "1-af6c272fe081efafecd2ea49a8fcbb40",
"user_email_view_casing": 1487076708000,
"_rev": "1-6277abc4e3db950221768e5a2618a059",
"test": 1487076708000,
}
`;

View file

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

View file

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

View file

@ -148,3 +148,15 @@ exports.isUserInAppTenant = (appId, user = null) => {
const tenantId = exports.getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID
return tenantId === userTenantId
}
exports.getTenantIds = async () => {
const db = getDB(PLATFORM_INFO_DB)
let tenants
try {
tenants = await db.get(TENANT_DOC)
} catch (err) {
// if theres an error the doc doesn't exist, no tenants exist
return []
}
return (tenants && tenants.tenantIds) || []
}

View file

@ -20,9 +20,6 @@ const { hash } = require("./hashing")
const userCache = require("./cache/user")
const env = require("./environment")
const { getUserSessions, invalidateSessions } = require("./security/sessions")
const { migrateIfRequired } = require("./migrations")
const { USER_EMAIL_VIEW_CASING } = require("./migrations").MIGRATIONS
const { GLOBAL_DB } = require("./migrations").MIGRATION_DBS
const APP_PREFIX = DocumentTypes.APP + SEPARATOR
@ -144,11 +141,6 @@ exports.getGlobalUserByEmail = async email => {
}
const db = getGlobalDB()
await migrateIfRequired(GLOBAL_DB, USER_EMAIL_VIEW_CASING, async () => {
// re-create the view with latest changes
await createUserEmailView(db)
})
try {
let users = (
await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {

View file

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

View file

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

View file

@ -22,6 +22,7 @@
export let error = null
export let fileTags = []
export let maximum = null
export let extensions = "*"
const dispatch = createEventDispatcher()
const imageExtensions = [
@ -146,7 +147,9 @@
<img alt="preview" src={selectedUrl} />
{:else}
<div class="placeholder">
<div class="extension">{selectedImage.extension}</div>
<div class="extension">
{selectedImage.name || "Unknown file"}
</div>
<div>Preview not supported</div>
</div>
{/if}
@ -207,6 +210,7 @@
{disabled}
type="file"
multiple
accept={extensions}
on:change={handleFile}
/>
<svg
@ -357,18 +361,21 @@
white-space: nowrap;
width: 0;
margin-right: 10px;
user-select: all;
}
.placeholder {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.extension {
color: var(--spectrum-global-color-gray-600);
text-transform: uppercase;
font-weight: 600;
margin-bottom: 5px;
user-select: all;
}
.nav {

View file

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

View file

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

View file

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

View file

@ -1,73 +1,20 @@
<script>
import "@spectrum-css/fieldlabel/dist/index-vars.css"
import Tooltip from "../Tooltip/Tooltip.svelte"
import Icon from "../Icon/Icon.svelte"
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
export let size = "M"
export let tooltip = ""
export let showTooltip = false
</script>
{#if tooltip}
<div class="container">
<label
for=""
class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}
>
<slot />
</label>
<div class="icon-container">
<div
class="icon"
class:icon-small={size === "M" || size === "S"}
on:mouseover={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Icon name="InfoOutline" size="S" disabled={true} />
</div>
{#if showTooltip}
<div class="tooltip">
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} />
</div>
{/if}
</div>
</div>
{:else}
<TooltipWrapper {tooltip} {size}>
<label for="" class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}>
<slot />
</label>
{/if}
</TooltipWrapper>
<style>
label {
padding: 0;
white-space: nowrap;
}
.container {
display: flex;
align-items: center;
}
.icon-container {
position: relative;
display: flex;
justify-content: center;
margin-top: 1px;
margin-left: 5px;
margin-right: 5px;
}
.tooltip {
position: absolute;
display: flex;
justify-content: center;
top: 15px;
z-index: 1;
width: 160px;
}
.icon {
transform: scale(0.75);
}
.icon-small {
margin-top: -2px;
margin-bottom: -5px;
}
</style>

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

@ -40,7 +40,7 @@ async function run() {
// it will cause environment module to be loaded prematurely
const server = require("../../server/dist/app")
process.env.PORT = WORKER_PORT
const worker = require("../../worker/src/index")
const worker = require("../../worker/dist/index")
// reload main port for rest of system
process.env.PORT = MAIN_PORT
server.on("close", () => console.log("Server Closed"))

View file

@ -35,7 +35,13 @@ Cypress.Commands.add("login", () => {
Cypress.Commands.add("createApp", name => {
cy.visit(`localhost:${Cypress.env("PORT")}/builder`)
cy.wait(500)
cy.contains(/Start from scratch/).dblclick()
cy.request(`${Cypress.config().baseUrl}api/applications?status=all`)
.its("body")
.then(body => {
if (body.length > 0) {
cy.get(".spectrum-Button").contains("Create app").click({ force: true })
}
})
cy.get(".spectrum-Modal").within(() => {
cy.get("input").eq(0).type(name).should("have.value", name).blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").click()

View file

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

View file

@ -1,11 +1,20 @@
import { store } from "./index"
import { get as svelteGet } from "svelte/store"
import { removeCookie, Cookies } from "./cookies"
import { auth } from "stores/portal"
const apiCall =
method =>
async (url, body, headers = { "Content-Type": "application/json" }) => {
headers["x-budibase-app-id"] = svelteGet(store).appId
headers["x-budibase-api-version"] = "1"
// add csrf token if authenticated
const user = svelteGet(auth).user
if (user && user.csrfToken) {
headers["x-csrf-token"] = user.csrfToken
}
const json = headers["Content-Type"] === "application/json"
const resp = await fetch(url, {
method: method,

View file

@ -1,16 +1,26 @@
export const Cookies = {
Auth: "budibase:auth",
CurrentApp: "budibase:currentapp",
ReturnUrl: "budibase:returnurl",
}
export function setCookie(name, value) {
if (getCookie(name)) {
removeCookie(name)
}
window.document.cookie = `${name}=${value}; Path=/;`
}
export function getCookie(cookieName) {
return document.cookie.split(";").some(cookie => {
return cookie.trim().startsWith(`${cookieName}=`)
})
const value = `; ${document.cookie}`
const parts = value.split(`; ${cookieName}=`)
if (parts.length === 2) {
return parts[1].split(";").shift()
}
}
export function removeCookie(cookieName) {
if (getCookie(cookieName)) {
document.cookie = `${cookieName}=; Max-Age=-99999999;`
document.cookie = `${cookieName}=; Max-Age=-99999999; Path=/;`
}
}

View file

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

View file

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

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,
ALLOWABLE_STRING_OPTIONS,
ALLOWABLE_NUMBER_OPTIONS,
ALLOWABLE_JSON_OPTIONS,
ALLOWABLE_STRING_TYPES,
ALLOWABLE_NUMBER_TYPES,
ALLOWABLE_JSON_TYPES,
SWITCHABLE_TYPES,
} from "constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils"
@ -126,7 +128,12 @@
}
}
function cancelEdit() {
field.name = originalName
}
function deleteColumn() {
field.name = deleteColName
if (field.name === $tables.selected.primaryDisplay) {
notifications.error("You cannot delete the display column")
} else {
@ -145,6 +152,7 @@
delete field.subtype
delete field.tableId
delete field.relationshipType
delete field.formulaType
// Add in defaults and initial definition
const definition = fieldDefinitions[event.detail?.toUpperCase()]
@ -156,6 +164,9 @@
if (field.type === LINK_TYPE) {
field.relationshipType = RelationshipTypes.MANY_TO_MANY
}
if (field.type === FORMULA_TYPE) {
field.formulaType = "dynamic"
}
}
function onChangeRequired(e) {
@ -236,6 +247,11 @@
ALLOWABLE_NUMBER_TYPES.indexOf(field.type) !== -1
) {
return ALLOWABLE_NUMBER_OPTIONS
} else if (
originalName &&
ALLOWABLE_JSON_TYPES.indexOf(field.type) !== -1
) {
return ALLOWABLE_JSON_OPTIONS
} else if (!external) {
return [
...Object.values(fieldDefinitions),
@ -307,6 +323,7 @@
title={originalName ? "Edit Column" : "Create Column"}
confirmText="Save Column"
onConfirm={saveColumn}
onCancel={cancelEdit}
disabled={invalid}
>
<Input
@ -425,8 +442,22 @@
error={errors.relatedName}
/>
{:else if field.type === FORMULA_TYPE}
{#if !table.sql}
<Select
label="Formula type"
bind:value={field.formulaType}
options={[
{ label: "Dynamic", value: "dynamic" },
{ label: "Static", value: "static" },
]}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered,
while static formula are calculated when the row is saved."
/>
{/if}
<ModalBindableInput
title="Handlebars Formula"
title="Formula"
label="Formula"
value={field.formula}
on:change={e => (field.formula = e.detail)}
@ -435,7 +466,7 @@
/>
{:else if field.type === AUTO_TYPE}
<Select
label="Auto Column Type"
label="Auto column type"
value={field.subtype}
on:change={e => (field.subtype = e.detail)}
options={Object.entries(getAutoColumnInformation())}
@ -470,16 +501,16 @@
onOk={deleteColumn}
onCancel={hideDeleteDialog}
title="Confirm Deletion"
disabled={deleteColName !== field.name}
disabled={deleteColName !== originalName}
>
<p>
Are you sure you wish to delete the column <b>{field.name}?</b>
Are you sure you wish to delete the column <b>{originalName}?</b>
Your data will be deleted and this action cannot be undone - enter the column
name to confirm.
</p>
<Input
dataCy="delete-column-confirm"
bind:value={deleteColName}
placeholder={field.name}
placeholder={originalName}
/>
</ConfirmDialog>

View file

@ -9,6 +9,7 @@
Select,
Body,
Layout,
ActionButton,
} from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte"
import { FIELDS } from "constants/backend"
@ -20,8 +21,8 @@
let dispatcher = createEventDispatcher()
let mode = "Form"
let fieldCount = 0
let fieldKeys = {},
fieldTypes = {}
let fieldKeys = [],
fieldTypes = []
let keyValueOptions = [
{ label: "String", value: FIELDS.STRING.type },
{ label: "Number", value: FIELDS.NUMBER.type },
@ -50,27 +51,48 @@
if (!schema) {
schema = {}
}
let i = 0
for (let [key, value] of Object.entries(schema)) {
fieldKeys[i] = key
fieldTypes[i] = value.type
i++
// find the entries which aren't in the list
const schemaEntries = Object.entries(schema).filter(
([key]) => !fieldKeys.includes(key)
)
for (let [key, value] of schemaEntries) {
fieldKeys.push(key)
fieldTypes.push(value.type)
}
fieldCount = i
fieldCount = fieldKeys.length
}
function saveSchema() {
for (let i of Object.keys(fieldKeys)) {
const key = fieldKeys[i]
const newSchema = {}
for (let [index, key] of fieldKeys.entries()) {
// they were added to schema, rather than generated
if (!schema[key]) {
schema[key] = {
type: fieldTypes[i],
}
newSchema[key] = {
...schema[key],
type: fieldTypes[index],
}
}
dispatcher("save", { schema: newSchema, json })
schema = newSchema
}
dispatcher("save", { schema, json })
function removeKey(index) {
const keyToRemove = fieldKeys[index]
if (fieldKeys[index + 1] != null) {
fieldKeys[index] = fieldKeys[index + 1]
fieldTypes[index] = fieldTypes[index + 1]
}
fieldKeys.splice(index, 1)
fieldTypes.splice(index, 1)
fieldCount--
if (json) {
try {
const parsed = JSON.parse(json)
delete parsed[keyToRemove]
json = JSON.stringify(parsed, null, 2)
} catch (err) {
// json not valid, ignore
}
}
}
onMount(() => {
@ -97,6 +119,7 @@
getOptionValue={field => field.value}
getOptionLabel={field => field.label}
/>
<ActionButton icon="Close" quiet on:click={() => removeKey(i)} />
</div>
{/each}
<div class:add-field-btn={fieldCount !== 0}>
@ -118,9 +141,9 @@
<style>
.horizontal {
display: grid;
grid-template-columns: 30% 1fr;
grid-template-columns: 30% 1fr 40px;
grid-gap: var(--spacing-s);
align-items: center;
align-items: end;
}
.add-field-btn {

View file

@ -6,9 +6,12 @@
export let datasource
let name = ""
let submitted = false
$: valid = name && name.length > 0 && !datasource?.entities[name]
$: error =
name && datasource?.entities[name] ? "Table name already in use." : null
!submitted && name && datasource?.entities[name]
? "Table name already in use."
: null
function buildDefaultTable(tableName, datasourceId) {
return {
@ -26,6 +29,7 @@
}
async function saveTable() {
submitted = true
const table = await tables.save(buildDefaultTable(name, datasource._id))
await datasources.fetch()
$goto(`../../table/${table._id}`)

View file

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

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 Budibase from "./Budibase.svelte"
import Oracle from "./Oracle.svelte"
import GoogleSheets from "./GoogleSheets.svelte"
export default {
BUDIBASE: Budibase,
@ -26,4 +27,5 @@ export default {
ARANGODB: ArangoDB,
REST: Rest,
ORACLE: Oracle,
GOOGLE_SHEETS: GoogleSheets,
}

View file

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

View file

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

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,16 +53,23 @@
}
// Create table
const table = await tables.save(newTable)
notifications.success(`Table ${name} created successfully.`)
analytics.captureEvent(Events.TABLE.CREATED, { name })
let table
try {
table = await tables.save(newTable)
notifications.success(`Table ${name} created successfully.`)
analytics.captureEvent(Events.TABLE.CREATED, { name })
// Navigate to new table
const currentUrl = $url()
const path = currentUrl.endsWith("data")
? `./table/${table._id}`
: `../../table/${table._id}`
$goto(path)
// Navigate to new table
const currentUrl = $url()
const path = currentUrl.endsWith("data")
? `./table/${table._id}`
: `../../table/${table._id}`
$goto(path)
} catch (e) {
notifications.error(e)
// reload in case the table was created
await tables.fetch()
}
}
</script>

View file

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

View file

@ -1,6 +1,7 @@
<script>
import { Select } from "@budibase/bbui"
import { store } from "builderStore"
import { get } from "svelte/store"
const themeOptions = [
{
@ -20,6 +21,17 @@
value: "spectrum--darkest",
},
]
const onChangeTheme = async theme => {
await store.actions.theme.save(theme)
await store.actions.customTheme.save({
...get(store).customTheme,
navBackground:
theme === "spectrum--light"
? "var(--spectrum-global-color-gray-50)"
: "var(--spectrum-global-color-gray-100)",
})
}
</script>
<div>
@ -27,7 +39,7 @@
value={$store.theme}
options={themeOptions}
placeholder={null}
on:change={e => store.actions.theme.save(e.detail)}
on:change={e => onChangeTheme(e.detail)}
/>
</div>

View file

@ -19,7 +19,7 @@
primaryColor: "var(--spectrum-global-color-blue-600)",
primaryColorHover: "var(--spectrum-global-color-blue-500)",
buttonBorderRadius: "16px",
navBackground: "var(--spectrum-global-color-gray-100)",
navBackground: "var(--spectrum-global-color-gray-50)",
navTextColor: "var(--spectrum-global-color-gray-800)",
}
@ -52,7 +52,14 @@
}
const resetTheme = () => {
store.actions.customTheme.save(null)
const theme = get(store).theme
store.actions.customTheme.save({
...defaultTheme,
navBackground:
theme === "spectrum--light"
? "var(--spectrum-global-color-gray-50)"
: "var(--spectrum-global-color-gray-100)",
})
}
</script>

View file

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

View file

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

View file

@ -1,13 +1,38 @@
<script>
import { Body } from "@budibase/bbui"
import { Label, Body, Layout } from "@budibase/bbui"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters
export let bindings = []
</script>
<div class="root">
<Body size="S">This action doesn't require any additional settings.</Body>
<Layout noPadding gap="M">
<Body size="S">
Please enter the URL you would like to be redirected to after logging out.
If you don't enter a value, you'll be redirected to the login screen.
</Body>
<div class="content">
<Label small>Redirect URL</Label>
<DrawerBindableInput
title="Return URL"
value={parameters.redirectUrl}
on:change={value => (parameters.redirectUrl = value.detail)}
{bindings}
/>
</div>
</Layout>
</div>
<style>
.root {
max-width: 400px;
margin: 0 auto;
}
.content {
display: grid;
align-items: center;
gap: var(--spacing-m);
grid-template-columns: auto 1fr;
}
</style>

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 RefreshDataProvider } from "./RefreshDataProvider.svelte"
export { default as DuplicateRow } from "./DuplicateRow.svelte"
export { default as S3Upload } from "./S3Upload.svelte"

View file

@ -70,6 +70,16 @@
"name": "Update State",
"component": "UpdateState",
"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}
<BindingBuilder
bind:customParams={tmpQueryParams}
bindings={getQueryParams(value)}
bind:bindableOptions={bindings}
queryBindings={getQueryParams(value)}
bind:bindings
/>
{/if}
<IntegrationQueryEditor

View file

@ -14,6 +14,7 @@
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { generate } from "shortid"
import { getValidOperatorsForType, OperatorOptions } from "constants/lucene"
import { getFields } from "helpers/searchFields"
export let schemaFields
export let filters = []
@ -21,11 +22,8 @@
export let panel = ClientBindingPanel
export let allowBindings = true
const BannedTypes = ["link", "attachment", "formula", "json", "jsonarray"]
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))
.map(field => field.name)
$: enrichedSchemaFields = getFields(schemaFields || [])
$: fieldOptions = enrichedSchemaFields.map(field => field.name) || []
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
const addFilter = () => {
@ -53,7 +51,7 @@
const onFieldChange = (expression, field) => {
// Update the field type
expression.type = schemaFields.find(x => x.name === field)?.type
expression.type = enrichedSchemaFields.find(x => x.name === field)?.type
// Ensure a valid operator is set
const validOperators = getValidOperatorsForType(expression.type).map(
@ -85,7 +83,7 @@
}
const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field)
const schema = enrichedSchemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
</script>
@ -133,7 +131,7 @@
{bindings}
on:change={event => (filter.value = event.detail)}
/>
{:else if ["string", "longform", "number"].includes(filter.type)}
{:else if ["string", "longform", "number", "formula"].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} />
{:else if ["options", "array"].includes(filter.type)}
<Combobox

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

@ -0,0 +1,47 @@
<script>
import { Multiselect } from "@budibase/bbui"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { tables } from "stores/backend"
import { createEventDispatcher } from "svelte"
import { getFields } from "helpers/searchFields"
export let componentInstance = {}
export let value = ""
export let placeholder
const dispatch = createEventDispatcher()
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource($currentAsset, datasource).schema
$: options = getOptions(datasource, schema || {})
$: boundValue = getSelectedOption(value, options)
function getOptions(ds, dsSchema) {
let base = Object.values(dsSchema)
if (!ds?.tableId) {
return base
}
const currentTable = $tables.list.find(table => table._id === ds.tableId)
return getFields(base, { allowLinks: currentTable.sql }).map(
field => field.name
)
}
function getSelectedOption(selectedOptions, allOptions) {
// Fix the hardcoded default string value
if (!Array.isArray(selectedOptions)) {
selectedOptions = []
}
return selectedOptions.filter(val => allOptions.indexOf(val) !== -1)
}
const setValue = value => {
boundValue = getSelectedOption(value.detail, options)
dispatch("change", boundValue)
}
</script>
<Multiselect {placeholder} value={boundValue} on:change={setValue} {options} />

View file

@ -1,5 +1,6 @@
import { Checkbox, Select, Stepper } from "@budibase/bbui"
import DataSourceSelect from "./DataSourceSelect.svelte"
import S3DataSourceSelect from "./S3DataSourceSelect.svelte"
import DataProviderSelect from "./DataProviderSelect.svelte"
import ButtonActionEditor from "./ButtonActionEditor/ButtonActionEditor.svelte"
import TableSelect from "./TableSelect.svelte"
@ -7,6 +8,7 @@ import ColorPicker from "./ColorPicker.svelte"
import { IconSelect } from "./IconSelect"
import FieldSelect from "./FieldSelect.svelte"
import MultiFieldSelect from "./MultiFieldSelect.svelte"
import SearchFieldSelect from "./SearchFieldSelect.svelte"
import SchemaSelect from "./SchemaSelect.svelte"
import SectionSelect from "./SectionSelect.svelte"
import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte"
@ -21,6 +23,7 @@ const componentMap = {
text: DrawerBindableCombobox,
select: Select,
dataSource: DataSourceSelect,
"dataSource/s3": S3DataSourceSelect,
dataProvider: DataProviderSelect,
boolean: Checkbox,
number: Stepper,
@ -30,6 +33,7 @@ const componentMap = {
icon: IconSelect,
field: FieldSelect,
multifield: MultiFieldSelect,
searchfield: SearchFieldSelect,
options: OptionsEditor,
schema: SchemaSelect,
section: SectionSelect,

View file

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

View file

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

View file

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

View file

@ -1,101 +1,46 @@
<script>
import { writable, get as svelteGet } from "svelte/store"
import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
import { store, automationStore, hostingStore } from "builderStore"
import { admin, auth } from "stores/portal"
import { string, mixed, object } from "yup"
import { store, automationStore } from "builderStore"
import { apps, admin, auth } from "stores/portal"
import api, { get, post } from "builderStore/api"
import analytics, { Events } from "analytics"
import { onMount } from "svelte"
import { capitalise } from "helpers"
import { goto } from "@roxi/routify"
import { APP_NAME_REGEX } from "constants"
import TemplateList from "./TemplateList.svelte"
import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app"
export let template
export let inline
const values = writable({ name: null })
const errors = writable({})
const touched = writable({})
const validator = {
name: string()
.trim()
.required("Your application must have a name")
.matches(
APP_NAME_REGEX,
"App name must be letters, numbers and spaces only"
),
file: template?.fromFile
? mixed().required("Please choose a file to import")
: null,
}
let submitting = false
let valid = false
let initialTemplateInfo = template?.fromFile || template?.key
$: checkValidity($values, validator)
$: showTemplateSelection = !template && !initialTemplateInfo
const values = writable({ name: "", url: null })
const validation = createValidationStore()
$: validation.check($values)
onMount(async () => {
await hostingStore.actions.fetchDeployedApps()
const existingAppNames = svelteGet(hostingStore).deployedAppNames
validator.name = string()
.trim()
.required("Your application must have a name")
.matches(APP_NAME_REGEX, "App name must be letters and numbers only")
.test(
"non-existing-app-name",
"Another app with the same name already exists",
value => {
return !existingAppNames.some(
appName => appName.toLowerCase() === value.toLowerCase()
)
}
)
await setupValidation()
})
const checkValidity = async (values, validator) => {
const obj = object().shape(validator)
Object.keys(validator).forEach(key => ($errors[key] = null))
if (template?.fromFile && values.file == null) {
valid = false
return
}
try {
await obj.validate(values, { abortEarly: false })
} catch (validationErrors) {
validationErrors.inner.forEach(error => {
$errors[error.path] = capitalise(error.message)
})
}
valid = await obj.isValid(values)
const setupValidation = async () => {
const applications = svelteGet(apps)
appValidation.name(validation, { apps: applications })
appValidation.url(validation, { apps: applications })
appValidation.file(validation, { template })
// init validation
validation.check($values)
}
async function createNewApp() {
const templateToUse = Object.keys(template).length === 0 ? null : template
submitting = true
// Check a template exists if we are important
if (templateToUse?.fromFile && !$values.file) {
$errors.file = "Please choose a file to import"
valid = false
submitting = false
return false
}
try {
// Create form data to create app
let data = new FormData()
data.append("name", $values.name.trim())
data.append("useTemplate", templateToUse != null)
if (templateToUse) {
data.append("templateName", templateToUse.name)
data.append("templateKey", templateToUse.key)
if ($values.url) {
data.append("url", $values.url.trim())
}
data.append("useTemplate", template != null)
if (template) {
data.append("templateName", template.name)
data.append("templateKey", template.key)
data.append("templateFile", $values.file)
}
@ -109,7 +54,7 @@
analytics.captureEvent(Events.APP.CREATED, {
name: $values.name,
appId: appJson.instance._id,
templateToUse,
templateToUse: template,
})
// Select Correct Application/DB in prep for creating user
@ -137,68 +82,51 @@
} catch (error) {
console.error(error)
notifications.error(error)
submitting = false
}
}
async function onCancel() {
template = null
await auth.setInitInfo({})
// auto add slash to url
$: {
if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
}
}
</script>
{#if showTemplateSelection}
<ModalContent
title={"Get started quickly"}
showConfirmButton={false}
size="L"
onConfirm={() => {
template = {}
return false
}}
showCancelButton={!inline}
showCloseIcon={!inline}
>
<TemplateList
onSelect={(selected, { useImport } = {}) => {
if (!selected) {
template = useImport ? { fromFile: true } : {}
return
}
template = selected
<ModalContent
title={"Create your app"}
confirmText={template?.fromFile ? "Import app" : "Create app"}
onConfirm={createNewApp}
disabled={!$validation.valid}
>
{#if template?.fromFile}
<Dropzone
error={$validation.touched.file && $validation.errors.file}
gallery={false}
label="File to import"
value={[$values.file]}
on:change={e => {
$values.file = e.detail?.[0]
$validation.touched.file = true
}}
/>
</ModalContent>
{:else}
<ModalContent
title={"Name your app"}
confirmText={template?.fromFile ? "Import app" : "Create app"}
onConfirm={createNewApp}
onCancel={inline ? onCancel : null}
cancelText={inline ? "Back" : undefined}
showCloseIcon={!inline}
disabled={!valid}
>
{#if template?.fromFile}
<Dropzone
error={$touched.file && $errors.file}
gallery={false}
label="File to import"
value={[$values.file]}
on:change={e => {
$values.file = e.detail?.[0]
$touched.file = true
}}
/>
{/if}
<Input
bind:value={$values.name}
error={$touched.name && $errors.name}
on:blur={() => ($touched.name = true)}
label="Name"
placeholder={$auth.user.firstName
? `${$auth.user.firstName}'s app`
: "My app"}
/>
</ModalContent>
{/if}
{/if}
<Input
bind:value={$values.name}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
label="Name"
placeholder={$auth.user.firstName
? `${$auth.user.firstName}s 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>

View file

@ -1,69 +0,0 @@
<script>
import { Heading, Layout, Icon } from "@budibase/bbui"
export let onSelect
</script>
<Layout gap="XS" noPadding>
<div class="template start-from-scratch" on:click={() => onSelect(null)}>
<div
class="background-icon"
style={`background: rgb(50, 50, 50); color: white;`}
>
<Icon name="Add" />
</div>
<Heading size="XS">Start from scratch</Heading>
<p class="detail">BLANK</p>
</div>
<div
class="template import"
on:click={() => onSelect(null, { useImport: true })}
>
<div
class="background-icon"
style={`background: rgb(50, 50, 50); color: white;`}
>
<Icon name="Add" />
</div>
<Heading size="XS">Import an app</Heading>
<p class="detail">BLANK</p>
</div>
</Layout>
<style>
.background-icon {
padding: 10px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
color: white;
}
.template {
min-height: 60px;
display: grid;
grid-gap: var(--layout-s);
grid-template-columns: auto 1fr auto;
border: 1px solid #494949;
align-items: center;
cursor: pointer;
border-radius: 4px;
background: var(--background-alt);
padding: 8px 16px;
}
.detail {
text-align: right;
}
.start-from-scratch {
background: var(--spectrum-global-color-gray-50);
margin-top: 20px;
}
.import {
background: var(--spectrum-global-color-gray-50);
}
</style>

View file

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

View file

@ -148,20 +148,23 @@ export const RelationshipTypes = {
}
export const ALLOWABLE_STRING_OPTIONS = [FIELDS.STRING, FIELDS.OPTIONS]
export const ALLOWABLE_STRING_TYPES = ALLOWABLE_STRING_OPTIONS.map(
opt => opt.type
)
export const ALLOWABLE_NUMBER_OPTIONS = [FIELDS.NUMBER, FIELDS.BOOLEAN]
export const ALLOWABLE_NUMBER_TYPES = ALLOWABLE_NUMBER_OPTIONS.map(
opt => opt.type
)
export const SWITCHABLE_TYPES = ALLOWABLE_NUMBER_TYPES.concat(
ALLOWABLE_STRING_TYPES
)
export const ALLOWABLE_JSON_OPTIONS = [FIELDS.JSON, FIELDS.ARRAY]
export const ALLOWABLE_JSON_TYPES = ALLOWABLE_JSON_OPTIONS.map(opt => opt.type)
export const SWITCHABLE_TYPES = [
...ALLOWABLE_STRING_TYPES,
...ALLOWABLE_NUMBER_TYPES,
...ALLOWABLE_JSON_TYPES,
]
export const IntegrationTypes = {
POSTGRES: "POSTGRES",
@ -177,6 +180,7 @@ export const IntegrationTypes = {
ARANGODB: "ARANGODB",
ORACLE: "ORACLE",
INTERNAL: "INTERNAL",
GOOGLE_SHEETS: "GOOGLE_SHEETS",
}
export const IntegrationNames = {
@ -193,6 +197,7 @@ export const IntegrationNames = {
[IntegrationTypes.ARANGODB]: "ArangoDB",
[IntegrationTypes.ORACLE]: "Oracle",
[IntegrationTypes.INTERNAL]: "Internal",
[IntegrationTypes.GOOGLE_SHEETS]: "Google Sheets",
}
export const SchemaTypeOptions = [
@ -229,3 +234,11 @@ export const PaginationLocations = [
{ label: "Query parameters", value: "query" },
{ label: "Request body", value: "body" },
]
export const BannedSearchTypes = [
"link",
"attachment",
"formula",
"json",
"jsonarray",
]

View file

@ -15,6 +15,22 @@ export const AppStatus = {
DEPLOYED: "published",
}
export const IntegrationNames = {
POSTGRES: "PostgreSQL",
MONGODB: "MongoDB",
COUCHDB: "CouchDB",
S3: "S3",
MYSQL: "MySQL",
REST: "REST",
DYNAMODB: "DynamoDB",
ELASTICSEARCH: "ElasticSearch",
SQL_SERVER: "SQL Server",
AIRTABLE: "Airtable",
ARANGODB: "ArangoDB",
ORACLE: "Oracle",
GOOGLE_SHEETS: "Google Sheets",
}
// fields on the user table that cannot be edited
export const UNEDITABLE_USER_FIELDS = [
"email",
@ -36,4 +52,7 @@ export const LAYOUT_NAMES = {
export const BUDIBASE_INTERNAL_DB = "bb_internal"
// one or more word characters and whitespace
export const APP_NAME_REGEX = /^[\w\s]+$/
// zero or more non-whitespace characters
export const APP_URL_REGEX = /^\S*$/

View file

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

View file

@ -0,0 +1,34 @@
import { tables } from "../stores/backend"
import { BannedSearchTypes } from "../constants/backend"
import { get } from "svelte/store"
export function getTableFields(linkField) {
const table = get(tables).list.find(table => table._id === linkField.tableId)
if (!table || !table.sql) {
return []
}
const linkFields = getFields(Object.values(table.schema), {
allowLinks: false,
})
return linkFields.map(field => ({
...field,
name: `${table.name}.${field.name}`,
}))
}
export function getFields(fields, { allowLinks } = { allowLinks: true }) {
let filteredFields = fields.filter(
field => !BannedSearchTypes.includes(field.type)
)
if (allowLinks) {
const linkFields = fields.filter(field => field.type === "link")
for (let linkField of linkFields) {
// only allow one depth of SQL relationship filtering
filteredFields = filteredFields.concat(getTableFields(linkField))
}
}
const staticFormulaFields = fields.filter(
field => field.type === "formula" && field.formulaType === "static"
)
return filteredFields.concat(staticFormulaFields)
}

View file

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

View file

@ -1,3 +1,5 @@
// TODO: Convert to yup based validators
export function emailValidator(value) {
return (
(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

@ -2,6 +2,12 @@
import { isActive, redirect, params } from "@roxi/routify"
import { admin, auth } from "stores/portal"
import { onMount } from "svelte"
import {
Cookies,
getCookie,
removeCookie,
setCookie,
} from "builderStore/cookies"
let loaded = false
@ -55,7 +61,7 @@
await auth.setInitInfo({ init_template: $params["?template"] })
}
await auth.checkAuth()
await auth.getSelf()
await admin.init()
if (useAccountPortal && multiTenancyEnabled) {
@ -67,6 +73,24 @@
$: {
const apiReady = $admin.loaded && $auth.loaded
// firstly, set the return url
if (
loaded &&
apiReady &&
!$auth.user &&
!getCookie(Cookies.ReturnUrl) &&
// logout triggers a page refresh, so we don't want to set the return url
!$auth.postLogout &&
// don't set the return url on pre-login pages
!$isActive("./auth") &&
!$isActive("./invite") &&
!$isActive("./admin")
) {
const url = window.location.pathname
setCookie(Cookies.ReturnUrl, url)
}
// if tenant is not set go to it
if (
loaded &&
@ -90,13 +114,20 @@
!$isActive("./invite") &&
!$isActive("./admin")
) {
const returnUrl = encodeURIComponent(window.location.pathname)
$redirect("./auth?", { returnUrl })
$redirect("./auth")
}
// check if password reset required for user
else if ($auth.user?.forceResetPassword) {
$redirect("./auth/reset")
}
// lastly, redirect to the return url if it has been set
else if (loaded && apiReady && $auth.user) {
const returnUrl = getCookie(Cookies.ReturnUrl)
if (returnUrl) {
removeCookie(Cookies.ReturnUrl)
window.location.href = returnUrl
}
}
}
</script>

View file

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

View file

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

View file

@ -10,7 +10,7 @@
notifications,
Link,
} from "@budibase/bbui"
import { goto, params } from "@roxi/routify"
import { goto } from "@roxi/routify"
import { auth, organisation, oidc, admin } from "stores/portal"
import GoogleButton from "./_components/GoogleButton.svelte"
import OIDCButton from "./_components/OIDCButton.svelte"
@ -35,12 +35,8 @@
if ($auth?.user?.forceResetPassword) {
$goto("./reset")
} else {
if ($params["?returnUrl"]) {
window.location = decodeURIComponent($params["?returnUrl"])
} else {
notifications.success("Logged in successfully")
$goto("../portal")
}
notifications.success("Logged in successfully")
$goto("../portal")
}
} catch (err) {
console.error(err)

View file

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

View file

@ -11,6 +11,7 @@
notifications,
Body,
Search,
Icon,
} from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte"
@ -27,6 +28,7 @@
import AppRow from "components/start/AppRow.svelte"
import { AppStatus } from "constants"
import analytics, { Events } from "analytics"
import Logo from "assets/bb-space-man.svg"
let sortBy = "name"
let template
@ -47,7 +49,6 @@
$: filteredApps = enrichedApps.filter(app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
)
$: isCloud = $admin.cloud
const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({
@ -78,6 +79,7 @@
}
const initiateAppCreation = () => {
template = null
creationModal.show()
creatingApp = true
}
@ -159,12 +161,10 @@
}
const viewApp = app => {
if (!isCloud && app.deployed) {
// special case to use the short form name if self hosted
window.open(`/app/${encodeURIComponent(app.name)}`)
if (app.url) {
window.open(`/app${app.url}`)
} else {
const id = app.deployed ? app.prodId : app.devId
window.open(`/${id}`, "_blank")
window.open(`/${app.prodId}`)
}
}
@ -300,14 +300,26 @@
<div class="buttons">
{#if cloud}
<Button icon="Export" quiet secondary on:click={initiateAppsExport}>
<Button
size="L"
icon="Export"
quiet
secondary
on:click={initiateAppsExport}
>
Export apps
</Button>
{/if}
<Button icon="Import" quiet secondary on:click={initiateAppImport}>
<Button
icon="Import"
size="L"
quiet
secondary
on:click={initiateAppImport}
>
Import app
</Button>
<Button icon="Add" cta on:click={initiateAppCreation}>
<Button size="L" icon="Add" cta on:click={initiateAppCreation}>
Create app
</Button>
</div>
@ -325,6 +337,14 @@
}}
class="template-card"
>
<a
href={item.url}
target="_blank"
class="external-link"
on:click|stopPropagation
>
<Icon name="LinkOut" size="S" />
</a>
<div class="card-body">
<div style="color: {item.background}" class="iconAlign">
<svg
@ -389,9 +409,24 @@
{#if !enrichedApps.length && !creatingApp && loaded}
<div class="empty-wrapper">
<Modal inline>
<CreateAppModal {template} inline={true} />
</Modal>
<div class="centered">
<div class="main">
<Layout gap="S" justifyItems="center">
<img class="img-size" alt="logo" src={Logo} />
<div class="new-screen-text">
<Detail size="M">Create a business app in minutes!</Detail>
</div>
<Button on:click={() => initiateAppCreation()} size="M" cta>
<div class="new-screen-button">
<div class="background-icon" style="color: white;">
<Icon name="Add" />
</div>
Create App
</div></Button
>
</Layout>
</div>
</div>
</div>
{/if}
@ -412,6 +447,11 @@
>
<CreateAppModal {template} />
</Modal>
<Modal bind:this={updatingModal} padding={false} width="600px">
<UpdateAppModal app={selectedApp} />
</Modal>
<ConfirmDialog
bind:this={deletionModal}
title="Confirm deletion"
@ -438,7 +478,6 @@
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
<UpdateAppModal app={selectedApp} bind:this={updatingModal} />
<ChooseIconModal app={selectedApp} bind:this={iconModal} />
<style>
@ -474,16 +513,20 @@
}
.grid {
height: 200px;
display: grid;
overflow: hidden;
grid-gap: var(--spacing-xl);
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
grid-template-rows: minmax(70px, 1fr) minmax(100px, 1fr) minmax(0px, 0);
}
.template-card {
height: 80px;
height: 70px;
border-radius: var(--border-radius-s);
border: 1px solid var(--spectrum-global-color-gray-300);
cursor: pointer;
display: flex;
position: relative;
}
.template-card:hover {
@ -494,6 +537,18 @@
align-items: center;
padding: 12px;
}
.external-link {
position: absolute;
top: 5px;
right: 5px;
color: var(--spectrum-global-color-gray-300);
z-index: 99;
}
.external-link:hover {
color: var(--spectrum-global-color-gray-500);
}
.iconAlign {
padding: 0 0 0 var(--spacing-m);
display: inline-block;
@ -533,4 +588,42 @@
justify-content: center;
align-items: center;
}
.centered {
width: calc(100% - 350px);
height: calc(100% - 100px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
width: 300px;
}
.new-screen-text {
width: 160px;
text-align: center;
color: #2c2c2c;
font-weight: 600;
}
.new-screen-button {
margin-left: 5px;
height: 20px;
width: 100px;
display: flex;
align-items: center;
}
.img-size {
width: 160px;
height: 160px;
}
.background-icon {
margin-top: 4px;
margin-right: 4px;
}
</style>

View file

@ -9,6 +9,7 @@ export function createAuthStore() {
tenantId: "default",
tenantSet: false,
loaded: false,
postLogout: false,
})
const store = derived(auth, $store => {
let initials = null
@ -34,6 +35,7 @@ export function createAuthStore() {
tenantId: $store.tenantId,
tenantSet: $store.tenantSet,
loaded: $store.loaded,
postLogout: $store.postLogout,
initials,
isAdmin,
isBuilder,
@ -89,6 +91,13 @@ export function createAuthStore() {
return info
}
async function setPostLogout() {
auth.update(store => {
store.postLogout = true
return store
})
}
async function getInitInfo() {
const response = await api.get(`/api/global/auth/init`)
const json = response.json()
@ -99,11 +108,7 @@ export function createAuthStore() {
return json
}
return {
subscribe: store.subscribe,
setOrganisation,
getInitInfo,
setInitInfo,
const actions = {
checkQueryString: async () => {
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has("tenantId")) {
@ -114,7 +119,7 @@ export function createAuthStore() {
setOrg: async tenantId => {
await setOrganisation(tenantId)
},
checkAuth: async () => {
getSelf: async () => {
const response = await api.get("/api/global/users/self")
if (response.status !== 200) {
setUser(null)
@ -129,13 +134,12 @@ export function createAuthStore() {
`/api/global/auth/${tenantId}/login`,
creds
)
const json = await response.json()
if (response.status === 200) {
setUser(json.user)
await actions.getSelf()
} else {
const json = await response.json()
throw new Error(json.message ? json.message : "Invalid credentials")
}
return json
},
logout: async () => {
const response = await api.post(`/api/global/auth/logout`)
@ -145,6 +149,7 @@ export function createAuthStore() {
await response.json()
await setInitInfo({})
setUser(null)
setPostLogout()
},
updateSelf: async fields => {
const newUser = { ...get(auth).user, ...fields }
@ -187,6 +192,14 @@ export function createAuthStore() {
await response.json()
},
}
return {
subscribe: store.subscribe,
setOrganisation,
getInitInfo,
setInitInfo,
...actions,
}
}
export const auth = createAuthStore()

File diff suppressed because it is too large Load diff

View file

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

View file

@ -2065,6 +2065,26 @@
}
]
},
{
"type": "select",
"label": "Direction",
"key": "direction",
"defaultValue": "vertical",
"options": [
{
"label": "Horizontal",
"value": "horizontal"
},
{
"label": "Vertical",
"value": "vertical"
}
],
"dependsOn": {
"setting": "optionsType",
"value": "radio"
}
},
{
"type": "text",
"label": "Default value",
@ -2236,7 +2256,7 @@
"setting": "optionsSource",
"value": "provider"
}
},
},
{
"type": "options",
"key": "customOptions",
@ -2419,6 +2439,11 @@
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Extensions",
"key": "extensions"
},
{
"type": "boolean",
"label": "Disabled",
@ -2811,7 +2836,7 @@
"key": "dataSource"
},
{
"type": "multifield",
"type": "searchfield",
"label": "Search Columns",
"key": "searchColumns",
"placeholder": "Choose search columns"
@ -2958,7 +2983,7 @@
"key": "dataSource"
},
{
"type": "multifield",
"type": "searchfield",
"label": "Search Columns",
"key": "searchColumns",
"placeholder": "Choose search columns"
@ -3315,5 +3340,50 @@
"suffix": "repeater"
}
]
},
"s3upload": {
"name": "S3 File Upload",
"info": "This component can't be used with S3 datasources that use custom endpoints.",
"icon": "UploadToCloud",
"styles": ["size"],
"editable": true,
"settings": [
{
"type": "field/attachment",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "dataSource/s3",
"label": "S3 Datasource",
"key": "datasourceId"
},
{
"type": "text",
"label": "Bucket",
"key": "bucket"
},
{
"type": "text",
"label": "File Name",
"key": "key"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/attachment",
"label": "Validation",
"key": "validation"
}
]
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/client",
"version": "1.0.43",
"version": "1.0.49-alpha.5",
"license": "MPL-2.0",
"module": "dist/budibase-client.js",
"main": "dist/budibase-client.js",
@ -19,10 +19,11 @@
"dev:builder": "rollup -cw"
},
"dependencies": {
"@budibase/bbui": "^1.0.43",
"@budibase/bbui": "^1.0.49-alpha.5",
"@budibase/standard-components": "^0.9.139",
"@budibase/string-templates": "^1.0.43",
"@budibase/string-templates": "^1.0.49-alpha.5",
"regexparam": "^1.3.0",
"rollup-plugin-polyfill-node": "^0.8.0",
"shortid": "^2.2.15",
"svelte-spa-router": "^3.0.5"
},
@ -45,8 +46,6 @@
"postcss": "^8.2.10",
"rollup": "^2.44.0",
"rollup-plugin-json": "^4.0.0",
"rollup-plugin-node-builtins": "^2.1.2",
"rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-postcss": "^4.0.0",
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-svg": "^2.0.0",

View file

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

View file

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

View file

@ -10,3 +10,41 @@ export const uploadAttachment = async (data, tableId = "") => {
json: false,
})
}
/**
* Generates a signed URL to upload a file to an external datasource.
*/
export const getSignedDatasourceURL = async (datasourceId, bucket, key) => {
if (!datasourceId) {
return null
}
const res = await API.post({
url: `/api/attachments/${datasourceId}/url`,
body: { bucket, key },
})
if (res.error) {
throw "Could not generate signed upload URL"
}
return res
}
/**
* Uploads a file to an external datasource.
*/
export const externalUpload = async (datasourceId, bucket, key, data) => {
const { signedUrl, publicUrl } = await getSignedDatasourceURL(
datasourceId,
bucket,
key
)
const res = await API.put({
url: signedUrl,
body: data,
json: false,
external: true,
})
if (res?.error) {
throw "Could not upload file to signed URL"
}
return { publicUrl }
}

View file

@ -18,6 +18,15 @@ export const logIn = async ({ email, password }) => {
})
}
/**
* Logs the user out and invaidates their session.
*/
export const logOut = async () => {
return await API.post({
url: "/api/global/auth/logout",
})
}
/**
* Fetches the currently logged in user object
*/

View file

@ -63,8 +63,9 @@
} else {
// The user is not logged in, redirect them to login
const returnUrl = `${window.location.pathname}${window.location.hash}`
const encodedUrl = encodeURIComponent(returnUrl)
window.location = `/builder/auth/login?returnUrl=${encodedUrl}`
// TODO: reuse `Cookies` from builder when frontend-core is added
window.document.cookie = `budibase:returnurl=${returnUrl}; Path=/`
window.location = `/builder/auth/login`
}
}
}

View file

@ -276,27 +276,29 @@
// reactive statements as much as possible.
const cacheSettings = (enriched, nested, conditional) => {
const allSettings = { ...enriched, ...nested, ...conditional }
if (!cachedSettings) {
const mounted = ref?.$$set != null
if (!cachedSettings || !mounted) {
cachedSettings = { ...allSettings }
initialSettings = cachedSettings
} else {
Object.keys(allSettings).forEach(key => {
const same = propsAreSame(allSettings[key], cachedSettings[key])
if (!same) {
// Updated cachedSettings (which is assigned by reference to
// initialSettings) so that if we remount the component then the
// initial props are up to date. By setting it this way rather than
// setting it on initialSettings directly, we avoid a double render.
cachedSettings[key] = allSettings[key]
assignSetting(key, allSettings[key])
// Programmatically set the prop to avoid svelte reactive statements
// firing inside components. This circumvents the problems caused by
// spreading a props object.
ref.$$set({ [key]: allSettings[key] })
}
})
}
}
// Assigns a certain setting to this component.
// We manually use the svelte $set function to avoid triggering additional
// reactive statements.
const assignSetting = (key, value) => {
ref?.$$set?.({ [key]: value })
}
// Generates a key used to determine when components need to fully remount.
// Currently only toggling editing requires remounting.
const getRenderKey = (id, editing) => {
@ -305,7 +307,7 @@
</script>
{#key renderKey}
{#if constructor && cachedSettings && (visible || inSelectedPath)}
{#if constructor && initialSettings && (visible || inSelectedPath)}
<!-- The ID is used as a class because getElementsByClassName is O(1) -->
<!-- and the performance matters for the selection indicators -->
<div

View file

@ -67,6 +67,7 @@
$: dataContext = {
rows: $fetch.rows,
info: $fetch.info,
datasource: dataSource || {},
schema: $fetch.schema,
rowsLength: $fetch.rows.length,

View file

@ -39,6 +39,7 @@
number: "numberfield",
datetime: "datetimefield",
boolean: "booleanfield",
formula: "stringfield",
}
let formId
@ -71,12 +72,13 @@
const enrichFilter = (filter, columns, formId) => {
let enrichedFilter = [...(filter || [])]
columns?.forEach(column => {
const safePath = column.name.split(".").map(safe).join(".")
enrichedFilter.push({
field: column.name,
operator: column.type === "string" ? "string" : "equal",
type: column.type === "string" ? "string" : "number",
valueType: "Binding",
value: `{{ [${formId}].[${column.name}] }}`,
value: `{{ ${safe(formId)}.${safePath} }}`,
})
})
return enrichedFilter
@ -112,7 +114,9 @@
// Load the datasource schema so we can determine column types
const fetchSchema = async dataSource => {
if (dataSource) {
schema = await fetchDatasourceSchema(dataSource)
schema = await fetchDatasourceSchema(dataSource, {
enrichRelationships: true,
})
}
schemaLoaded = true
}

View file

@ -35,6 +35,7 @@
number: "numberfield",
datetime: "datetimefield",
boolean: "booleanfield",
formula: "stringfield",
}
let formId
@ -59,12 +60,14 @@
const enrichFilter = (filter, columns, formId) => {
let enrichedFilter = [...(filter || [])]
columns?.forEach(column => {
const safePath = column.name.split(".").map(safe).join(".")
const stringType = column.type === "string" || column.type === "formula"
enrichedFilter.push({
field: column.name,
operator: column.type === "string" ? "string" : "equal",
type: column.type === "string" ? "string" : "number",
operator: stringType ? "string" : "equal",
type: stringType ? "string" : "number",
valueType: "Binding",
value: `{{ ${safe(formId)}.${safe(column.name)} }}`,
value: `{{ ${safe(formId)}.${safePath} }}`,
})
})
return enrichedFilter
@ -90,7 +93,9 @@
// Load the datasource schema so we can determine column types
const fetchSchema = async dataSource => {
if (dataSource) {
schema = await fetchDatasourceSchema(dataSource)
schema = await fetchDatasourceSchema(dataSource, {
enrichRelationships: true,
})
}
schemaLoaded = true
}

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