Merge branch 'feature/new-app-publish-workflow' of github.com:Budibase/budibase into new-design-ui-dirty

This commit is contained in:
Andrew Kingston 2022-04-25 13:42:23 +01:00
commit d269354d6f
190 changed files with 6083 additions and 7303 deletions

View File

@ -12,6 +12,11 @@ on:
- master - master
- develop - develop
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
BASE_BRANCH: ${{ github.event.pull_request.base.ref}}
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -27,6 +32,10 @@ jobs:
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- name: Install Pro
run: yarn install:pro $BRANCH $BASE_BRANCH
- run: yarn - run: yarn
- run: yarn bootstrap - run: yarn bootstrap
- run: yarn lint - run: yarn lint

View File

@ -19,6 +19,7 @@ env:
POSTHOG_TOKEN: ${{ secrets.POSTHOG_TOKEN }} POSTHOG_TOKEN: ${{ secrets.POSTHOG_TOKEN }}
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }} INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
POSTHOG_URL: ${{ secrets.POSTHOG_URL }} POSTHOG_URL: ${{ secrets.POSTHOG_URL }}
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs: jobs:
release: release:
@ -29,6 +30,10 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 14.x
- name: Install Pro
run: yarn install:pro develop
- run: yarn - run: yarn
- run: yarn bootstrap - run: yarn bootstrap
- run: yarn lint - run: yarn lint
@ -46,9 +51,9 @@ jobs:
env: env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
# setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default # setup the username and email.
git config user.name "Budibase Staging Release Bot" git config --global user.name "Budibase Staging Release Bot"
git config user.email "<>" git config --global user.email "<>"
echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc
yarn release:develop yarn release:develop

View File

@ -20,6 +20,7 @@ env:
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }} INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
POSTHOG_URL: ${{ secrets.POSTHOG_URL }} POSTHOG_URL: ${{ secrets.POSTHOG_URL }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs: jobs:
release: release:
@ -30,6 +31,10 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 14.x
- name: Install Pro
run: yarn install:pro master
- run: yarn - run: yarn
- run: yarn bootstrap - run: yarn bootstrap
- run: yarn lint - run: yarn lint

View File

@ -11,7 +11,7 @@ sources:
- https://github.com/Budibase/budibase - https://github.com/Budibase/budibase
- https://budibase.com - https://budibase.com
type: application type: application
version: 0.2.8 version: 0.2.9
appVersion: 1.0.48 appVersion: 1.0.48
dependencies: dependencies:
- name: couchdb - name: couchdb

View File

@ -98,10 +98,6 @@ spec:
value: http://worker-service:{{ .Values.services.worker.port }} value: http://worker-service:{{ .Values.services.worker.port }}
- name: PLATFORM_URL - name: PLATFORM_URL
value: {{ .Values.globals.platformUrl | quote }} value: {{ .Values.globals.platformUrl | quote }}
- name: USE_QUOTAS
value: {{ .Values.globals.useQuotas | quote }}
- name: EXCLUDE_QUOTAS_TENANTS
value: {{ .Values.globals.excludeQuotasTenants | quote }}
- name: ACCOUNT_PORTAL_URL - name: ACCOUNT_PORTAL_URL
value: {{ .Values.globals.accountPortalUrl | quote }} value: {{ .Values.globals.accountPortalUrl | quote }}
- name: ACCOUNT_PORTAL_API_KEY - name: ACCOUNT_PORTAL_API_KEY
@ -114,12 +110,23 @@ spec:
value: {{ .Values.globals.google.clientId | quote }} value: {{ .Values.globals.google.clientId | quote }}
- name: GOOGLE_CLIENT_SECRET - name: GOOGLE_CLIENT_SECRET
value: {{ .Values.globals.google.secret | quote }} value: {{ .Values.globals.google.secret | quote }}
- name: AUTOMATION_MAX_ITERATIONS
value: {{ .Values.globals.automationMaxIterations | quote }}
image: budibase/apps:{{ .Values.globals.appVersion }} image: budibase/apps:{{ .Values.globals.appVersion }}
imagePullPolicy: Always imagePullPolicy: Always
name: bbapps name: bbapps
ports: ports:
- containerPort: {{ .Values.services.apps.port }} - containerPort: {{ .Values.services.apps.port }}
resources: {} resources: {}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always restartPolicy: Always
serviceAccountName: "" serviceAccountName: ""
status: {} status: {}

View File

@ -39,5 +39,13 @@ spec:
imagePullPolicy: Always imagePullPolicy: Always
name: couchdb-backup name: couchdb-backup
resources: {} resources: {}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
status: {} status: {}
{{- end }} {{- end }}

View File

@ -60,6 +60,14 @@ spec:
volumeMounts: volumeMounts:
- mountPath: /data - mountPath: /data
name: minio-data name: minio-data
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always restartPolicy: Always
serviceAccountName: "" serviceAccountName: ""
volumes: volumes:

View File

@ -32,6 +32,14 @@ spec:
- containerPort: {{ .Values.services.proxy.port }} - containerPort: {{ .Values.services.proxy.port }}
resources: {} resources: {}
volumeMounts: volumeMounts:
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always restartPolicy: Always
serviceAccountName: "" serviceAccountName: ""
volumes: volumes:

View File

@ -39,6 +39,14 @@ spec:
volumeMounts: volumeMounts:
- mountPath: /data - mountPath: /data
name: redis-data name: redis-data
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always restartPolicy: Always
serviceAccountName: "" serviceAccountName: ""
volumes: volumes:

View File

@ -121,6 +121,14 @@ spec:
ports: ports:
- containerPort: {{ .Values.services.worker.port }} - containerPort: {{ .Values.services.worker.port }}
resources: {} resources: {}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always restartPolicy: Always
serviceAccountName: "" serviceAccountName: ""
status: {} status: {}

View File

@ -93,16 +93,15 @@ globals:
logLevel: info logLevel: info
selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup
multiTenancy: "0" # set to 0 to disable multiple orgs, set to 1 to enable multiple orgs multiTenancy: "0" # set to 0 to disable multiple orgs, set to 1 to enable multiple orgs
useQuotas: "0"
excludeQuotasTenants: "" # comma seperated list of tenants to exclude from quotas
accountPortalUrl: "" accountPortalUrl: ""
accountPortalApiKey: "" accountPortalApiKey: ""
cookieDomain: "" cookieDomain: ""
platformUrl: "" platformUrl: ""
httpMigrations: "0" httpMigrations: "0"
google: google:
clientId: "" clientId: ""
secret: "" secret: ""
automationMaxIterations: "500"
createSecrets: true # creates an internal API key, JWT secrets and redis password for you createSecrets: true # creates an internal API key, JWT secrets and redis password for you
@ -230,6 +229,8 @@ couchdb:
## Optional tolerations ## Optional tolerations
tolerations: [] tolerations: []
affinity: {}
service: service:
# annotations: # annotations:
enabled: true enabled: true

View File

@ -1,5 +1,5 @@
{ {
"version": "1.0.105-alpha.20", "version": "1.0.105-alpha.35",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -21,18 +21,17 @@
}, },
"scripts": { "scripts": {
"setup": "node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev", "setup": "node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev",
"bootstrap": "lerna link && lerna bootstrap", "bootstrap": "lerna link && lerna bootstrap && ./scripts/link-dependencies.sh",
"build": "lerna run build", "build": "lerna run build",
"publishdev": "lerna run publishdev", "release": "lerna publish patch --yes --force-publish && yarn release:pro",
"publishnpm": "yarn build && lerna publish --force-publish", "release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop && yarn release:pro:develop",
"release": "lerna publish patch --yes --force-publish", "release:pro": "bash scripts/pro/release.sh",
"release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop", "release:pro:develop": "bash scripts/pro/release.sh develop",
"restore": "yarn run clean && yarn run bootstrap && yarn run build", "restore": "yarn run clean && yarn run bootstrap && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore", "nuke:packages": "yarn run restore",
"nuke:docker": "lerna run --parallel dev:stack:nuke", "nuke:docker": "lerna run --parallel dev:stack:nuke",
"clean": "lerna clean", "clean": "lerna clean",
"kill-port": "kill-port 4001",
"kill-builder": "kill-port 3000", "kill-builder": "kill-port 3000",
"kill-server": "kill-port 4001 4002", "kill-server": "kill-port 4001 4002",
"kill-all": "yarn run kill-builder && yarn run kill-server", "kill-all": "yarn run kill-builder && yarn run kill-server",
@ -74,6 +73,7 @@
"mode:cloud": "yarn env:selfhost:disable && yarn env:multi:enable && yarn env:account:disable", "mode:cloud": "yarn env:selfhost:disable && yarn env:multi:enable && yarn env:account:disable",
"mode:account": "yarn mode:cloud && yarn env:account:enable", "mode:account": "yarn mode:cloud && yarn env:account:enable",
"security:audit": "node scripts/audit.js", "security:audit": "node scripts/audit.js",
"postinstall": "husky install" "postinstall": "husky install",
"install:pro": "bash scripts/pro/install.sh"
} }
} }

View File

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

View File

@ -13,6 +13,7 @@ exports.Cookies = {
exports.Headers = { exports.Headers = {
API_KEY: "x-budibase-api-key", API_KEY: "x-budibase-api-key",
LICENSE_KEY: "x-budibase-license-key",
API_VER: "x-budibase-api-version", API_VER: "x-budibase-api-version",
APP_ID: "x-budibase-app-id", APP_ID: "x-budibase-app-id",
TYPE: "x-budibase-type", TYPE: "x-budibase-type",

View File

@ -174,9 +174,11 @@ function getDB(key, opts) {
if (db && isEqual(opts, storedOpts)) { if (db && isEqual(opts, storedOpts)) {
return db return db
} }
const appId = exports.getAppId() const appId = exports.getAppId()
const CouchDB = getCouch() const CouchDB = getCouch()
let toUseAppId let toUseAppId
switch (key) { switch (key) {
case ContextKeys.CURRENT_DB: case ContextKeys.CURRENT_DB:
toUseAppId = appId toUseAppId = appId

View File

@ -23,6 +23,7 @@ exports.StaticDatabases = {
docs: { docs: {
apiKeys: "apikeys", apiKeys: "apikeys",
usageQuota: "usage_quota", usageQuota: "usage_quota",
licenseInfo: "license_info",
}, },
}, },
// contains information about tenancy and so on // contains information about tenancy and so on

View File

@ -27,6 +27,7 @@ const UNICODE_MAX = "\ufff0"
exports.ViewNames = { exports.ViewNames = {
USER_BY_EMAIL: "by_email", USER_BY_EMAIL: "by_email",
BY_API_KEY: "by_api_key", BY_API_KEY: "by_api_key",
USER_BY_BUILDERS: "by_builders",
} }
exports.StaticDatabases = StaticDatabases exports.StaticDatabases = StaticDatabases
@ -429,34 +430,9 @@ async function getScopedConfig(db, params) {
return configDoc && configDoc.config ? configDoc.config : configDoc return configDoc && configDoc.config ? configDoc.config : configDoc
} }
function generateNewUsageQuotaDoc() {
return {
_id: StaticDatabases.GLOBAL.docs.usageQuota,
quotaReset: Date.now() + 2592000000,
usageQuota: {
automationRuns: 0,
rows: 0,
storage: 0,
apps: 0,
users: 0,
views: 0,
emails: 0,
},
usageLimits: {
automationRuns: 1000,
rows: 4000,
apps: 4,
storage: 1000,
users: 10,
emails: 50,
},
}
}
exports.Replication = Replication exports.Replication = Replication
exports.getScopedConfig = getScopedConfig exports.getScopedConfig = getScopedConfig
exports.generateConfigID = generateConfigID exports.generateConfigID = generateConfigID
exports.getConfigParams = getConfigParams exports.getConfigParams = getConfigParams
exports.getScopedFullConfig = getScopedFullConfig exports.getScopedFullConfig = getScopedFullConfig
exports.generateNewUsageQuotaDoc = generateNewUsageQuotaDoc
exports.generateDevInfoID = generateDevInfoID exports.generateDevInfoID = generateDevInfoID

View File

@ -56,10 +56,34 @@ exports.createApiKeyView = async () => {
await db.put(designDoc) await db.put(designDoc)
} }
exports.createUserBuildersView = async () => {
const db = getGlobalDB()
let designDoc
try {
designDoc = await db.get("_design/database")
} catch (err) {
// no design doc, make one
designDoc = DesignDoc()
}
const view = {
map: `function(doc) {
if (doc.builder && doc.builder.global === true) {
emit(doc._id, doc._id)
}
}`,
}
designDoc.views = {
...designDoc.views,
[ViewNames.USER_BY_BUILDERS]: view,
}
await db.put(designDoc)
}
exports.queryGlobalView = async (viewName, params, db = null) => { exports.queryGlobalView = async (viewName, params, db = null) => {
const CreateFuncByName = { const CreateFuncByName = {
[ViewNames.USER_BY_EMAIL]: exports.createUserEmailView, [ViewNames.USER_BY_EMAIL]: exports.createUserEmailView,
[ViewNames.BY_API_KEY]: exports.createApiKeyView, [ViewNames.BY_API_KEY]: exports.createApiKeyView,
[ViewNames.USER_BY_BUILDERS]: exports.createUserBuildersView,
} }
// can pass DB in if working with something specific // can pass DB in if working with something specific
if (!db) { if (!db) {

View File

@ -28,6 +28,7 @@ module.exports = {
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED), SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED),
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
PLATFORM_URL: process.env.PLATFORM_URL, PLATFORM_URL: process.env.PLATFORM_URL,
TENANT_FEATURE_FLAGS: process.env.TENANT_FEATURE_FLAGS,
isTest, isTest,
_set(key, value) { _set(key, value) {
process.env[key] = value process.env[key] = value

View File

@ -0,0 +1,11 @@
class BudibaseError extends Error {
constructor(message, type, code) {
super(message)
this.type = type
this.code = code
}
}
module.exports = {
BudibaseError,
}

View File

@ -0,0 +1,41 @@
const licensing = require("./licensing")
const codes = {
...licensing.codes,
}
const types = {
...licensing.types,
}
const context = {
...licensing.context,
}
const getPublicError = err => {
let error
if (err.code || err.type) {
// add generic error information
error = {
code: err.code,
type: err.type,
}
if (err.code && context[err.code]) {
error = {
...error,
// get any additional context from this error
...context[err.code](err),
}
}
}
return error
}
module.exports = {
codes,
types,
UsageLimitError: licensing.UsageLimitError,
getPublicError,
}

View File

@ -0,0 +1,32 @@
const { BudibaseError } = require("./base")
const types = {
LICENSE_ERROR: "license_error",
}
const codes = {
USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded",
}
const context = {
[codes.USAGE_LIMIT_EXCEEDED]: err => {
return {
limitName: err.limitName,
}
},
}
class UsageLimitError extends BudibaseError {
constructor(message, limitName) {
super(message, types.LICENSE_ERROR, codes.USAGE_LIMIT_EXCEEDED)
this.limitName = limitName
this.status = 400
}
}
module.exports = {
types,
codes,
context,
UsageLimitError,
}

View File

@ -0,0 +1,52 @@
const env = require("../environment")
const tenancy = require("../tenancy")
/**
* Read the TENANT_FEATURE_FLAGS env var and return an array of features flags for each tenant.
* The env var is formatted as:
* tenant1:feature1:feature2,tenant2:feature1
*/
const getFeatureFlags = () => {
if (!env.TENANT_FEATURE_FLAGS) {
return
}
const tenantFeatureFlags = {}
env.TENANT_FEATURE_FLAGS.split(",").forEach(tenantToFeatures => {
const [tenantId, ...features] = tenantToFeatures.split(":")
features.forEach(feature => {
if (!tenantFeatureFlags[tenantId]) {
tenantFeatureFlags[tenantId] = []
}
tenantFeatureFlags[tenantId].push(feature)
})
})
return tenantFeatureFlags
}
const TENANT_FEATURE_FLAGS = getFeatureFlags()
exports.isEnabled = featureFlag => {
const tenantId = tenancy.getTenantId()
return (
TENANT_FEATURE_FLAGS &&
TENANT_FEATURE_FLAGS[tenantId] &&
TENANT_FEATURE_FLAGS[tenantId].includes(featureFlag)
)
}
exports.getTenantFeatureFlags = tenantId => {
if (TENANT_FEATURE_FLAGS && TENANT_FEATURE_FLAGS[tenantId]) {
return TENANT_FEATURE_FLAGS[tenantId]
}
return []
}
exports.FeatureFlag = {
LICENSING: "LICENSING",
}

View File

@ -15,4 +15,9 @@ module.exports = {
auth: require("../auth"), auth: require("../auth"),
constants: require("../constants"), constants: require("../constants"),
migrations: require("../migrations"), migrations: require("../migrations"),
errors: require("./errors"),
env: require("./environment"),
accounts: require("./cloud/accounts"),
tenancy: require("./tenancy"),
featureFlags: require("./featureFlags"),
} }

View File

@ -2,24 +2,27 @@ const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
const { authenticateThirdParty } = require("./third-party-common") const { authenticateThirdParty } = require("./third-party-common")
async function authenticate(accessToken, refreshToken, profile, done) { const buildVerifyFn = async saveUserFn => {
const thirdPartyUser = { return (accessToken, refreshToken, profile, done) => {
provider: profile.provider, // should always be 'google' const thirdPartyUser = {
providerType: "google", provider: profile.provider, // should always be 'google'
userId: profile.id, providerType: "google",
profile: profile, userId: profile.id,
email: profile._json.email, profile: profile,
oauth2: { email: profile._json.email,
accessToken: accessToken, oauth2: {
refreshToken: refreshToken, accessToken: accessToken,
}, refreshToken: refreshToken,
} },
}
return authenticateThirdParty( return authenticateThirdParty(
thirdPartyUser, thirdPartyUser,
true, // require local accounts to exist true, // require local accounts to exist
done done,
) saveUserFn
)
}
} }
/** /**
@ -27,11 +30,7 @@ async function authenticate(accessToken, refreshToken, profile, done) {
* from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport. * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
* @returns Dynamically configured Passport Google Strategy * @returns Dynamically configured Passport Google Strategy
*/ */
exports.strategyFactory = async function ( exports.strategyFactory = async function (config, callbackUrl, saveUserFn) {
config,
callbackUrl,
verify = authenticate
) {
try { try {
const { clientID, clientSecret } = config const { clientID, clientSecret } = config
@ -41,6 +40,7 @@ exports.strategyFactory = async function (
) )
} }
const verify = buildVerifyFn(saveUserFn)
return new GoogleStrategy( return new GoogleStrategy(
{ {
clientID: config.clientID, clientID: config.clientID,
@ -58,4 +58,4 @@ exports.strategyFactory = async function (
} }
} }
// expose for testing // expose for testing
exports.authenticate = authenticate exports.buildVerifyFn = buildVerifyFn

View File

@ -2,46 +2,49 @@ const fetch = require("node-fetch")
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
const { authenticateThirdParty } = require("./third-party-common") const { authenticateThirdParty } = require("./third-party-common")
/** const buildVerifyFn = saveUserFn => {
* @param {*} issuer The identity provider base URL /**
* @param {*} sub The user ID * @param {*} issuer The identity provider base URL
* @param {*} profile The user profile information. Created by passport from the /userinfo response * @param {*} sub The user ID
* @param {*} jwtClaims The parsed id_token claims * @param {*} profile The user profile information. Created by passport from the /userinfo response
* @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT * @param {*} jwtClaims The parsed id_token claims
* @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT * @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT
* @param {*} idToken The id_token - always a JWT * @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT
* @param {*} params The response body from requesting an access_token * @param {*} idToken The id_token - always a JWT
* @param {*} done The passport callback: err, user, info * @param {*} params The response body from requesting an access_token
*/ * @param {*} done The passport callback: err, user, info
async function authenticate( */
issuer, return async (
sub, issuer,
profile, sub,
jwtClaims, profile,
accessToken, jwtClaims,
refreshToken, accessToken,
idToken, refreshToken,
params, idToken,
done params,
) {
const thirdPartyUser = {
// store the issuer info to enable sync in future
provider: issuer,
providerType: "oidc",
userId: profile.id,
profile: profile,
email: getEmail(profile, jwtClaims),
oauth2: {
accessToken: accessToken,
refreshToken: refreshToken,
},
}
return authenticateThirdParty(
thirdPartyUser,
false, // don't require local accounts to exist
done done
) ) => {
const thirdPartyUser = {
// store the issuer info to enable sync in future
provider: issuer,
providerType: "oidc",
userId: profile.id,
profile: profile,
email: getEmail(profile, jwtClaims),
oauth2: {
accessToken: accessToken,
refreshToken: refreshToken,
},
}
return authenticateThirdParty(
thirdPartyUser,
false, // don't require local accounts to exist
done,
saveUserFn
)
}
} }
/** /**
@ -86,7 +89,7 @@ function validEmail(value) {
* from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport. * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
* @returns Dynamically configured Passport OIDC Strategy * @returns Dynamically configured Passport OIDC Strategy
*/ */
exports.strategyFactory = async function (config, callbackUrl) { exports.strategyFactory = async function (config, callbackUrl, saveUserFn) {
try { try {
const { clientID, clientSecret, configUrl } = config const { clientID, clientSecret, configUrl } = config
@ -106,6 +109,7 @@ exports.strategyFactory = async function (config, callbackUrl) {
const body = await response.json() const body = await response.json()
const verify = buildVerifyFn(saveUserFn)
return new OIDCStrategy( return new OIDCStrategy(
{ {
issuer: body.issuer, issuer: body.issuer,
@ -116,7 +120,7 @@ exports.strategyFactory = async function (config, callbackUrl) {
clientSecret: clientSecret, clientSecret: clientSecret,
callbackURL: callbackUrl, callbackURL: callbackUrl,
}, },
authenticate verify
) )
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -125,4 +129,4 @@ exports.strategyFactory = async function (config, callbackUrl) {
} }
// expose for testing // expose for testing
exports.authenticate = authenticate exports.buildVerifyFn = buildVerifyFn

View File

@ -58,8 +58,10 @@ describe("google", () => {
it("delegates authentication to third party common", async () => { it("delegates authentication to third party common", async () => {
const google = require("../google") const google = require("../google")
const mockSaveUserFn = jest.fn()
const authenticate = await google.buildVerifyFn(mockSaveUserFn)
await google.authenticate( await authenticate(
data.accessToken, data.accessToken,
data.refreshToken, data.refreshToken,
profile, profile,
@ -69,7 +71,8 @@ describe("google", () => {
expect(authenticateThirdParty).toHaveBeenCalledWith( expect(authenticateThirdParty).toHaveBeenCalledWith(
user, user,
true, true,
mockDone) mockDone,
mockSaveUserFn)
}) })
}) })
}) })

View File

@ -83,8 +83,10 @@ describe("oidc", () => {
async function doAuthenticate() { async function doAuthenticate() {
const oidc = require("../oidc") const oidc = require("../oidc")
const mockSaveUserFn = jest.fn()
const authenticate = await oidc.buildVerifyFn(mockSaveUserFn)
await oidc.authenticate( await authenticate(
issuer, issuer,
sub, sub,
profile, profile,

View File

@ -1,7 +1,6 @@
const env = require("../../environment") const env = require("../../environment")
const jwt = require("jsonwebtoken") const jwt = require("jsonwebtoken")
const { generateGlobalUserID } = require("../../db/utils") const { generateGlobalUserID } = require("../../db/utils")
const { saveUser } = require("../../utils")
const { authError } = require("./utils") const { authError } = require("./utils")
const { newid } = require("../../hashing") const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions") const { createASession } = require("../../security/sessions")
@ -16,8 +15,11 @@ exports.authenticateThirdParty = async function (
thirdPartyUser, thirdPartyUser,
requireLocalAccount = true, requireLocalAccount = true,
done, done,
saveUserFn = saveUser saveUserFn
) { ) {
if (!saveUserFn) {
throw new Error("Save user function must be provided")
}
if (!thirdPartyUser.provider) { if (!thirdPartyUser.provider) {
return authError(done, "third party user provider required") return authError(done, "third party user provider required")
} }

View File

@ -17,6 +17,7 @@ exports.Databases = {
FLAGS: "flags", FLAGS: "flags",
APP_METADATA: "appMetadata", APP_METADATA: "appMetadata",
QUERY_VARS: "queryVars", QUERY_VARS: "queryVars",
LICENSES: "license",
} }
exports.SEPARATOR = SEPARATOR exports.SEPARATOR = SEPARATOR

View File

@ -176,6 +176,13 @@ exports.getGlobalUserByEmail = async email => {
}) })
} }
exports.getBuildersCount = async () => {
const builders = await queryGlobalView(ViewNames.USER_BY_BUILDERS, {
include_docs: false,
})
return builders ? builders.length : 0
}
exports.saveUser = async ( exports.saveUser = async (
user, user,
tenantId, tenantId,
@ -289,4 +296,5 @@ exports.platformLogout = async ({ ctx, userId, keepActiveSession }) => {
userId, userId,
sessions.map(({ sessionId }) => sessionId) sessions.map(({ sessionId }) => sessionId)
) )
await userCache.invalidateUser(userId)
} }

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "1.0.105-alpha.20", "version": "1.0.105-alpha.35",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
], ],
"dependencies": { "dependencies": {
"@adobe/spectrum-css-workflow-icons": "^1.2.1", "@adobe/spectrum-css-workflow-icons": "^1.2.1",
"@budibase/string-templates": "^1.0.105-alpha.20", "@budibase/string-templates": "^1.0.105-alpha.35",
"@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actionbutton": "^1.0.1",
"@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1",
"@spectrum-css/avatar": "^3.0.2", "@spectrum-css/avatar": "^3.0.2",

View File

@ -80,8 +80,4 @@
.active svg { .active svg {
color: var(--spectrum-global-color-blue-600); color: var(--spectrum-global-color-blue-600);
} }
.spectrum-ActionButton-label {
padding-bottom: 2px;
}
</style> </style>

View File

@ -6,6 +6,7 @@
export let disabled = false export let disabled = false
export let align = "left" export let align = "left"
export let portalTarget export let portalTarget
export let dataCy
let anchor let anchor
let dropdown let dropdown
@ -36,7 +37,7 @@
<div use:getAnchor on:click={openMenu}> <div use:getAnchor on:click={openMenu}>
<slot name="control" /> <slot name="control" />
</div> </div>
<Popover bind:this={dropdown} {anchor} {align} {portalTarget}> <Popover bind:this={dropdown} {anchor} {align} {portalTarget} {dataCy}>
<Menu> <Menu>
<slot /> <slot />
</Menu> </Menu>

View File

@ -25,9 +25,11 @@
</script> </script>
<div <div
class="icon"
on:mouseover={() => (showTooltip = true)} on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)} on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)} on:mouseleave={() => (showTooltip = false)}
on:click={() => (showTooltip = false)}
> >
<svg <svg
on:click on:click
@ -45,13 +47,13 @@
</svg> </svg>
{#if tooltip && showTooltip} {#if tooltip && showTooltip}
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}> <div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} /> <Tooltip textWrapping direction="bottom" text={tooltip} />
</div> </div>
{/if} {/if}
</div> </div>
<style> <style>
div { .icon {
position: relative; position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
@ -75,8 +77,10 @@
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
left: 50%; left: 50%;
top: 100%; top: calc(100% + 4px);
white-space: nowrap; width: 100vw;
max-width: 150px;
transform: translateX(-50%); transform: translateX(-50%);
text-align: center;
} }
</style> </style>

View File

@ -1,8 +1,9 @@
<script> <script>
export let wide = false export let wide = false
export let maxWidth = "80ch"
</script> </script>
<div class:wide> <div style="--max-width: {maxWidth}" class:wide>
<slot /> <slot />
</div> </div>
@ -12,7 +13,7 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
max-width: 80ch; max-width: var(--max-width);
margin: 0 auto; margin: 0 auto;
padding: calc(var(--spacing-xl) * 2); padding: calc(var(--spacing-xl) * 2);
min-height: calc(100% - var(--spacing-xl) * 4); min-height: calc(100% - var(--spacing-xl) * 4);

View File

@ -23,6 +23,7 @@
export let secondaryButtonText = undefined export let secondaryButtonText = undefined
export let secondaryAction = undefined export let secondaryAction = undefined
export let secondaryButtonWarning = false export let secondaryButtonWarning = false
export let dataCy = null
const { hide, cancel } = getContext(Context.Modal) const { hide, cancel } = getContext(Context.Modal)
let loading = false let loading = false
@ -63,21 +64,24 @@
role="dialog" role="dialog"
tabindex="-1" tabindex="-1"
aria-modal="true" aria-modal="true"
data-cy={dataCy}
> >
<div class="spectrum-Dialog-grid"> <div class="spectrum-Dialog-grid">
{#if title} <h1
<h1 class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader"
class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader" class:noDivider={!showDivider}
class:noDivider={!showDivider} class:header-spacing={$$slots.header}
class:header-spacing={$$slots.header} >
> {#if title}
{title} {title}
{:else if $$slots.header}
<slot name="header" /> <slot name="header" />
</h1>
{#if showDivider}
<Divider size="M" />
{/if} {/if}
</h1>
{#if showDivider && (title || $$slots.header)}
<Divider size="M" />
{/if} {/if}
<!-- TODO: Remove content-grid class once Layout components are in bbui --> <!-- TODO: Remove content-grid class once Layout components are in bbui -->
<section class="spectrum-Dialog-content content-grid"> <section class="spectrum-Dialog-content content-grid">
<slot /> <slot />

View File

@ -10,6 +10,10 @@
export let anchor export let anchor
export let align = "right" export let align = "right"
export let portalTarget export let portalTarget
export let dataCy
let clazz
export { clazz as class }
export const show = () => { export const show = () => {
dispatch("open") dispatch("open")
@ -37,8 +41,9 @@
use:positionDropdown={{ anchor, align }} use:positionDropdown={{ anchor, align }}
use:clickOutside={hide} use:clickOutside={hide}
on:keydown={handleEscape} on:keydown={handleEscape}
class="spectrum-Popover is-open" class={"spectrum-Popover is-open " + (clazz || "")}
role="presentation" role="presentation"
data-cy={dataCy}
> >
<slot /> <slot />
</div> </div>

View File

@ -0,0 +1,82 @@
<script>
import { setContext } from "svelte"
import Popover from "../Popover/Popover.svelte"
export let disabled = false
export let align = "left"
export let anchor
export let showTip = true
export let direction = "bottom"
export let dataCy = null
let dropdown
let tipSvg =
'<svg xmlns="http://www.w3.org/svg/2000" width="23" height="12" class="spectrum-Popover-tip" > <path class="spectrum-Popover-tip-triangle" d="M 0.7071067811865476 0 L 11.414213562373096 10.707106781186548 L 22.121320343559645 0" /> </svg>'
// This is needed because display: contents is considered "invisible".
// It should only ever be an action button, so should be fine.
function getAnchor(node) {
if (!anchor) {
anchor = node.firstChild
}
}
//need this for the publish/view behaviours
export const hide = () => {
dropdown.hide()
}
export const show = () => {
dropdown.show()
}
const openMenu = event => {
if (!disabled) {
event.stopPropagation()
show()
}
}
setContext("popoverMenu", { show, hide })
</script>
<div class="popover-menu">
<div use:getAnchor on:click={openMenu}>
<slot name="control" />
</div>
<Popover
bind:this={dropdown}
{anchor}
{align}
class={showTip
? `spectrum-Popover--withTip spectrum-Popover--${direction}`
: ""}
>
{#if showTip}
{@html tipSvg}
{/if}
<div class="popover-container" data-cy={dataCy}>
<div class="popover-menu-wrap">
<slot />
</div>
</div>
</Popover>
</div>
<style>
:global(.spectrum-Popover.is-open.spectrum-Popover--withTip) {
margin-top: var(--spacing-xs);
margin-left: var(--spacing-xl);
}
.popover-menu-wrap {
padding: 10px;
}
.popover-menu :global(.icon) {
display: flex;
}
:global(.spectrum-Popover--bottom .spectrum-Popover-tip) {
left: 90%;
margin-left: calc(var(--spectrum-global-dimension-size-150) * -1);
}
</style>

View File

@ -16,11 +16,11 @@
easing: easing, easing: easing,
}) })
$: if (value) $progress = value $: if (value || value === 0) $progress = value
</script> </script>
<div <div
class:spectrum-ProgressBar--indeterminate={!value} class:spectrum-ProgressBar--indeterminate={!value && value !== 0}
class:spectrum-ProgressBar--sideLabel={sideLabel} class:spectrum-ProgressBar--sideLabel={sideLabel}
class="spectrum-ProgressBar spectrum-ProgressBar--size{size}" class="spectrum-ProgressBar spectrum-ProgressBar--size{size}"
value={$progress} value={$progress}
@ -28,7 +28,7 @@
aria-valuenow={$progress} aria-valuenow={$progress}
aria-valuemin="0" aria-valuemin="0"
aria-valuemax="100" aria-valuemax="100"
style={width ? `width: ${width}px;` : ""} style={width ? `width: ${width};` : ""}
> >
{#if $$slots} {#if $$slots}
<div <div
@ -37,7 +37,7 @@
<slot /> <slot />
</div> </div>
{/if} {/if}
{#if value} {#if value || value === 0}
<div <div
class="spectrum-FieldLabel spectrum-ProgressBar-percentage spectrum-FieldLabel--size{size}" class="spectrum-FieldLabel spectrum-ProgressBar-percentage spectrum-FieldLabel--size{size}"
> >
@ -47,7 +47,7 @@
<div class="spectrum-ProgressBar-track"> <div class="spectrum-ProgressBar-track">
<div <div
class="spectrum-ProgressBar-fill" class="spectrum-ProgressBar-fill"
style={value ? `width: ${$progress}%` : ""} style={value || value === 0 ? `width: ${$progress}%` : ""}
/> />
</div> </div>
<div class="spectrum-ProgressBar-label" hidden="" /> <div class="spectrum-ProgressBar-label" hidden="" />

View File

@ -36,6 +36,7 @@
export let disableSorting = false export let disableSorting = false
export let autoSortColumns = true export let autoSortColumns = true
export let compact = false export let compact = false
export let customPlaceholder = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -387,13 +388,24 @@
</div> </div>
{/each} {/each}
{:else} {:else}
<div class="placeholder" class:placeholder--no-fields={!fields?.length}> <div
<div class="placeholder-content"> class="placeholder"
<svg class="spectrum-Icon spectrum-Icon--sizeXXL" focusable="false"> class:placeholder--custom={customPlaceholder}
<use xlink:href="#spectrum-icon-18-Table" /> class:placeholder--no-fields={!fields?.length}
</svg> >
<div>No rows found</div> {#if customPlaceholder}
</div> <slot name="placeholder" />
{:else}
<div class="placeholder-content">
<svg
class="spectrum-Icon spectrum-Icon--sizeXXL"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Table" />
</svg>
<div>No rows found</div>
</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@ -458,6 +470,13 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
user-select: none; user-select: none;
border-top: var(--table-border);
}
.spectrum-Table-headCell:first-of-type {
border-left: var(--table-border);
}
.spectrum-Table-headCell:last-of-type {
border-right: var(--table-border);
} }
.spectrum-Table-headCell--alignCenter { .spectrum-Table-headCell--alignCenter {
justify-content: center; justify-content: center;
@ -576,16 +595,19 @@
border-top: none; border-top: none;
grid-column: 1 / -1; grid-column: 1 / -1;
background-color: var(--table-bg); background-color: var(--table-bg);
padding: 40px;
} }
.placeholder--no-fields { .placeholder--no-fields {
border-top: var(--table-border); border-top: var(--table-border);
} }
.placeholder--custom {
justify-content: flex-start;
}
.wrapper--quiet .placeholder { .wrapper--quiet .placeholder {
border-left: none; border-left: none;
border-right: none; border-right: none;
} }
.placeholder-content { .placeholder-content {
padding: 40px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;

View File

@ -5,12 +5,14 @@
export let serif = false export let serif = false
export let weight = null export let weight = null
export let textAlign = null export let textAlign = null
export let color = null
</script> </script>
<p <p
style={` style={`
${weight ? `font-weight:${weight};` : ""} ${weight ? `font-weight:${weight};` : ""}
${textAlign ? `text-align:${textAlign};` : ""} ${textAlign ? `text-align:${textAlign};` : ""}
${color ? `color:${color};` : ""}
`} `}
class="spectrum-Body spectrum-Body--size{size}" class="spectrum-Body spectrum-Body--size{size}"
class:spectrum-Body--serif={serif} class:spectrum-Body--serif={serif}

View File

@ -5,12 +5,13 @@
export let size = "M" export let size = "M"
export let textAlign export let textAlign
export let noPadding = false export let noPadding = false
export let weight = "default" // light, heavy, default
</script> </script>
<h1 <h1
style={textAlign ? `text-align:${textAlign}` : ``} style={textAlign ? `text-align:${textAlign}` : ``}
class:noPadding class:noPadding
class="spectrum-Heading spectrum-Heading--size{size}" class="spectrum-Heading spectrum-Heading--size{size} spectrum-Heading--{weight}"
> >
<slot /> <slot />
</h1> </h1>

View File

@ -25,6 +25,7 @@ export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte" export { default as Checkbox } from "./Form/Checkbox.svelte"
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte" export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
export { default as Popover } from "./Popover/Popover.svelte" export { default as Popover } from "./Popover/Popover.svelte"
export { default as PopoverMenu } from "./Popover/PopoverMenu.svelte"
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte" export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"
export { default as ProgressCircle } from "./ProgressCircle/ProgressCircle.svelte" export { default as ProgressCircle } from "./ProgressCircle/ProgressCircle.svelte"
export { default as Label } from "./Label/Label.svelte" export { default as Label } from "./Label/Label.svelte"

View File

@ -0,0 +1,112 @@
import filterTests from "../support/filterTests"
filterTests(['all'], () => {
context("Publish Application Workflow", () => {
before(() => {
cy.login()
cy.createTestApp()
})
it("Should reflect the unpublished status correctly", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.get(".appTable .app-status").eq(0)
.within(() => {
cy.contains("Unpublished")
cy.get("svg[aria-label='GlobeStrike']").should("exist")
})
cy.get(".appTable .app-row-actions").eq(0)
.within(() => {
cy.get(".spectrum-Button").contains("Preview")
cy.get(".spectrum-Button").contains("Edit").click({ force: true })
})
cy.get(".app-status-icon svg[aria-label='GlobeStrike']").should("exist")
cy.get(".app-status-icon svg[aria-label='Globe']").should("not.exist")
})
it("Should publish an application and correctly reflect that", () => {
//Assuming the previous test was run and the unpublished app is open in edit mode.
cy.get(".toprightnav button.spectrum-Button").contains("Publish").click({ force : true })
cy.get(".spectrum-Modal [data-cy='deploy-app-modal']").should("be.visible")
.within(() => {
cy.get(".spectrum-Button").contains("Publish").click({ force : true })
cy.wait(1000)
});
//Verify that the app url is presented correctly to the user
cy.get(".spectrum-Modal [data-cy='deploy-app-success-modal']")
.should("be.visible")
.within(() => {
let appUrl = Cypress.config().baseUrl + '/app/cypress-tests'
cy.get("[data-cy='deployed-app-url'] input").should('have.value', appUrl)
cy.get(".spectrum-Button").contains("Done").click({ force: true })
})
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.get(".appTable .app-status").eq(0)
.within(() => {
cy.contains("Published")
cy.get("svg[aria-label='Globe']").should("exist")
})
cy.get(".appTable .app-row-actions").eq(0)
.within(() => {
cy.get(".spectrum-Button").contains("View app")
cy.get(".spectrum-Button").contains("Edit").click({ force: true })
})
cy.get(".app-status-icon svg[aria-label='Globe']").should("exist").click({ force: true })
cy.get("[data-cy='publish-popover-menu']").should("be.visible")
.within(() => {
cy.get("[data-cy='publish-popover-action']").should("exist")
cy.get("button").contains("View App").should("exist")
cy.get(".publish-popover-message").should("have.text", "Last Published: a few seconds ago")
})
})
it("Should unpublish an application from the top navigation and reflect the status change", () => {
//Assuming the previous test app exists and is published
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .app-status").eq(0)
.within(() => {
cy.contains("Published")
cy.get("svg[aria-label='Globe']").should("exist")
})
cy.get(".appTable .app-row-actions").eq(0)
.within(() => {
cy.get(".spectrum-Button").contains("View app")
cy.get(".spectrum-Button").contains("Edit").click({ force: true })
})
//The published status
cy.get(".app-status-icon svg[aria-label='Globe']").should("exist")
.click({ force: true })
cy.get("[data-cy='publish-popover-menu']").should("be.visible")
cy.get("[data-cy='publish-popover-menu'] [data-cy='publish-popover-action']")
.click({ force : true })
cy.get("[data-cy='unpublish-modal']").should("be.visible")
.within(() => {
cy.get(".confirm-wrap button").click({ force: true }
)})
cy.get(".app-status-icon svg[aria-label='GlobeStrike']").should("exist")
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .app-status").eq(0).contains("Unpublished")
})
})
})

View File

@ -11,7 +11,7 @@ filterTests(['all'], () => {
cy.applicationInAppTable("Cypress Tests") cy.applicationInAppTable("Cypress Tests")
cy.get(".appTable") cy.get(".appTable")
.within(() => { .within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get("[data-cy='app-row-actions-menu']").eq(0).click()
}) })
cy.get(".spectrum-Menu").contains("Edit icon").click() cy.get(".spectrum-Menu").contains("Edit icon").click()
// Select random icon // Select random icon
@ -38,6 +38,7 @@ filterTests(['all'], () => {
cy.get(".title").children().children() cy.get(".title").children().children()
.should('have.attr', 'style').and('contains', 'color') .should('have.attr', 'style').and('contains', 'color')
}) })
cy.deleteAllApps()
}) })
}) })
}) })

View File

@ -20,7 +20,6 @@ filterTests(['smoke', 'all'], () => {
}) })
// Setup trigger // Setup trigger
cy.contains("Setup").click()
cy.get(".spectrum-Picker-label").click() cy.get(".spectrum-Picker-label").click()
cy.wait(500) cy.wait(500)
cy.contains("dog").click() cy.contains("dog").click()
@ -32,12 +31,11 @@ filterTests(['smoke', 'all'], () => {
cy.contains("Create Row").trigger('mouseover').click().click() cy.contains("Create Row").trigger('mouseover').click().click()
cy.get(".spectrum-Button--cta").click() cy.get(".spectrum-Button--cta").click()
}) })
cy.contains("Setup").click()
cy.get(".spectrum-Picker-label").eq(1).click() cy.get(".spectrum-Picker-label").eq(1).click()
cy.contains("dog").click() cy.contains("dog").click()
cy.get(".spectrum-Textfield-input") cy.get(".spectrum-Textfield-input")
.first() .first()
.type("{{ trigger.row.name }}", { parseSpecialCharSequences: false }) .type("{{ trigger.row.name }}", { parseSpecialCharSequences: false })
cy.get(".spectrum-Textfield-input") cy.get(".spectrum-Textfield-input")
.eq(1) .eq(1)
.type("11") .type("11")

View File

@ -99,30 +99,32 @@ filterTests(['all'], () => {
cy.searchForApplication(originalName) cy.searchForApplication(originalName)
cy.get(".appTable") cy.get(".appTable")
.within(() => { .within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get("[data-cy='app-row-actions-menu']").eq(0).click()
}) })
// Check for when an app is published // Check for when an app is published
if (published == true) { if (published == true) {
// Should not have Edit as option, will unpublish app // Should not have Edit as option, will unpublish app
cy.should("not.have.value", "Edit") cy.should("not.have.value", "Edit")
cy.get(".spectrum-Menu").contains("Unpublish").click() cy.get(".spectrum-Menu").contains("Unpublish").click()
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click()
cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click() cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click()
} }
cy.contains("Edit").click() cy.get("[data-cy='app-row-actions-menu-popover']").eq(0).within(() => {
cy.get(".spectrum-Modal") cy.get(".spectrum-Menu-item").contains("Edit").click({ force: true })
.within(() => { })
if (noName == true) { cy.get(".spectrum-Modal")
cy.get("input").clear() .within(() => {
cy.get(".spectrum-Dialog-grid").click() if (noName == true) {
.contains("App name must be letters, numbers and spaces only")
return cy
}
cy.get("input").clear() cy.get("input").clear()
cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur() cy.get(".spectrum-Dialog-grid").click()
cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true }) .contains("App name must be letters, numbers and spaces only")
cy.wait(500) return cy
}) }
} cy.get("input").clear()
cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur()
cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true })
cy.wait(500)
})
}
}) })
}) })

View File

@ -10,7 +10,7 @@ filterTests(['smoke', 'all'], () => {
it("should try to revert an unpublished app", () => { it("should try to revert an unpublished app", () => {
// Click revert icon // Click revert icon
cy.get(".toprightnav").within(() => { cy.get(".toprightnav").within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get("[data-cy='revert-application-topnav']").click({ force: true })
}) })
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {
// Enter app name before revert // Enter app name before revert
@ -33,11 +33,15 @@ filterTests(['smoke', 'all'], () => {
cy.get(".spectrum-ButtonGroup").within(() => { cy.get(".spectrum-ButtonGroup").within(() => {
cy.get(".spectrum-Button").contains("Publish").click({ force: true }) cy.get(".spectrum-Button").contains("Publish").click({ force: true })
}) })
cy.wait(1000)
cy.get(".spectrum-ButtonGroup").within(() => {
cy.get(".spectrum-Button").contains("Done").click({ force: true })
})
// Add second component - Button // Add second component - Button
cy.addComponent("Elements", "Button") cy.addComponent("Elements", "Button")
// Click Revert // Click Revert
cy.get(".toprightnav").within(() => { cy.get(".toprightnav").within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get("[data-cy='revert-application-topnav']").click({ force: true })
}) })
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {
// Click Revert // Click Revert
@ -54,7 +58,7 @@ filterTests(['smoke', 'all'], () => {
it("should enter incorrect app name when reverting", () => { it("should enter incorrect app name when reverting", () => {
// Click Revert // Click Revert
cy.get(".toprightnav").within(() => { cy.get(".toprightnav").within(() => {
cy.get(".spectrum-Icon").eq(1).click({ force: true }) cy.get("[data-cy='revert-application-topnav']").click({ force: true })
}) })
// Enter incorrect app name // Enter incorrect app name
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "1.0.105-alpha.20", "version": "1.0.105-alpha.35",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -65,10 +65,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.0.105-alpha.20", "@budibase/bbui": "^1.0.105-alpha.35",
"@budibase/client": "^1.0.105-alpha.20", "@budibase/client": "^1.0.105-alpha.35",
"@budibase/frontend-core": "^1.0.105-alpha.20", "@budibase/frontend-core": "^1.0.105-alpha.35",
"@budibase/string-templates": "^1.0.105-alpha.20", "@budibase/string-templates": "^1.0.105-alpha.35",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View File

@ -35,6 +35,7 @@ export const Events = {
CREATED: "budibase:app_created", CREATED: "budibase:app_created",
PUBLISHED: "budibase:app_published", PUBLISHED: "budibase:app_published",
UNPUBLISHED: "budibase:app_unpublished", UNPUBLISHED: "budibase:app_unpublished",
VIEW_PUBLISHED: "budibase:view_published_app",
}, },
ANALYTICS: { ANALYTICS: {
OPT_IN: "budibase:analytics_opt_in", OPT_IN: "budibase:analytics_opt_in",
@ -50,3 +51,9 @@ export const Events = {
SAVED: "budibase:sso_saved", SAVED: "budibase:sso_saved",
}, },
} }
export const EventSource = {
PORTAL: "portal",
URL: "url",
NOTIFICATION: "notification",
}

View File

@ -2,7 +2,7 @@ import { API } from "api"
import PosthogClient from "./PosthogClient" import PosthogClient from "./PosthogClient"
import IntercomClient from "./IntercomClient" import IntercomClient from "./IntercomClient"
import SentryClient from "./SentryClient" import SentryClient from "./SentryClient"
import { Events } from "./constants" import { Events, EventSource } from "./constants"
const posthog = new PosthogClient( const posthog = new PosthogClient(
process.env.POSTHOG_TOKEN, process.env.POSTHOG_TOKEN,
@ -57,5 +57,5 @@ class AnalyticsHub {
const analytics = new AnalyticsHub() const analytics = new AnalyticsHub()
export { Events } export { Events, EventSource }
export default analytics export default analytics

View File

@ -654,7 +654,7 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
* Builds a form schema given a form component. * Builds a form schema given a form component.
* A form schema is a schema of all the fields nested anywhere within a form. * A form schema is a schema of all the fields nested anywhere within a form.
*/ */
const buildFormSchema = component => { export const buildFormSchema = component => {
let schema = {} let schema = {}
if (!component) { if (!component) {
return schema return schema

View File

@ -39,6 +39,7 @@
if (v.internal) { if (v.internal) {
acc[k] = v acc[k] = v
} }
delete acc.LOOP
return acc return acc
}, {}) }, {})

View File

@ -72,7 +72,9 @@
animate:flip={{ duration: 500 }} animate:flip={{ duration: 500 }}
in:fly|local={{ x: 500, duration: 1500 }} in:fly|local={{ x: 500, duration: 1500 }}
> >
<FlowItem {testDataModal} {block} /> {#if block.stepId !== "LOOP"}
<FlowItem {testDataModal} {block} />
{/if}
</div> </div>
{/each} {/each}
</div> </div>

View File

@ -9,8 +9,8 @@
Modal, Modal,
Button, Button,
StatusLight, StatusLight,
ActionButton,
Select, Select,
ActionButton,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte" import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
@ -25,8 +25,8 @@
let webhookModal let webhookModal
let actionModal let actionModal
let resultsModal let resultsModal
let setupToggled
let blockComplete let blockComplete
let showLooping = false
$: rowControl = $automationStore.selectedAutomation.automation.rowControl $: rowControl = $automationStore.selectedAutomation.automation.rowControl
$: showBindingPicker = $: showBindingPicker =
@ -52,8 +52,21 @@
block.schema?.inputs?.properties || {} block.schema?.inputs?.properties || {}
).every(x => block?.inputs[x]) ).every(x => block?.inputs[x])
$: loopingSelected =
$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)
async function deleteStep() { async function deleteStep() {
let loopBlock =
$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)
try { try {
if (loopBlock) {
automationStore.actions.deleteAutomationBlock(loopBlock)
}
automationStore.actions.deleteAutomationBlock(block) automationStore.actions.deleteAutomationBlock(block)
await automationStore.actions.save( await automationStore.actions.save(
$automationStore.selectedAutomation?.automation $automationStore.selectedAutomation?.automation
@ -76,6 +89,23 @@
) )
} }
async function addLooping() {
loopingSelected = true
const loopDefinition = $automationStore.blockDefinitions.ACTION.LOOP
const loopBlock = $automationStore.selectedAutomation.constructBlock(
"ACTION",
"LOOP",
loopDefinition
)
loopBlock.blockToLoop = block.id
block.loopBlock = loopBlock.id
automationStore.actions.addBlockToAutomation(loopBlock, blockIdx)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
}
async function onSelect(block) { async function onSelect(block) {
await automationStore.update(state => { await automationStore.update(state => {
state.selectedBlock = block state.selectedBlock = block
@ -84,13 +114,68 @@
} }
</script> </script>
<div <div class={`block ${block.type} hoverable`} class:selected on:click={() => {}}>
class={`block ${block.type} hoverable`} {#if loopingSelected}
class:selected <div class="blockSection">
on:click={() => { <div
onSelect(block) on:click={() => {
}} showLooping = !showLooping
> }}
class="splitHeader"
>
<div class="center-items">
<svg
width="28px"
height="28px"
class="spectrum-Icon"
style="color:grey;"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Reuse" />
</svg>
<div class="iconAlign">
<Detail size="S">Looping</Detail>
</div>
</div>
<div class="blockTitle">
<div
style="margin-left: 10px;"
on:click={() => {
onSelect(block)
}}
>
<Icon name={showLooping ? "ChevronDown" : "ChevronUp"} />
</div>
</div>
</div>
</div>
<Divider noMargin />
{#if !showLooping}
<div class="blockSection">
<div class="block-options">
<div class="delete-padding" on:click={() => deleteStep()}>
<Icon name="DeleteOutline" />
</div>
</div>
<Layout noPadding gap="S">
<AutomationBlockSetup
schemaProperties={Object.entries(
$automationStore.blockDefinitions.ACTION.LOOP.schema.inputs
.properties
)}
block={$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)}
{webhookModal}
/>
</Layout>
</div>
<Divider noMargin />
{/if}
{/if}
<div class="blockSection"> <div class="blockSection">
<div <div
on:click={() => { on:click={() => {
@ -127,65 +212,66 @@
<Detail size="S">{block?.name?.toUpperCase() || ""}</Detail> <Detail size="S">{block?.name?.toUpperCase() || ""}</Detail>
</div> </div>
</div> </div>
{#if testResult && testResult[0]} <div class="blockTitle">
<span on:click={() => resultsModal.show()}> {#if testResult && testResult[0]}
<StatusLight <div style="float: right;" on:click={() => resultsModal.show()}>
positive={isTrigger || testResult[0].outputs?.success} <StatusLight
negative={!testResult[0].outputs?.success} positive={isTrigger || testResult[0].outputs?.success}
><Body size="XS">View response</Body></StatusLight negative={!testResult[0].outputs?.success}
> ><Body size="XS">View response</Body></StatusLight
</span> >
{/if} </div>
{/if}
<div
style="margin-left: 10px;"
on:click={() => {
onSelect(block)
}}
>
<Icon name={blockComplete ? "ChevronDown" : "ChevronUp"} />
</div>
</div>
</div> </div>
</div> </div>
{#if !blockComplete} {#if !blockComplete}
<Divider noMargin /> <Divider noMargin />
<div class="blockSection"> <div class="blockSection">
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<div class="splitHeader"> {#if !isTrigger}
<ActionButton <div>
on:click={() => {
onSelect(block)
setupToggled = !setupToggled
}}
quiet
icon={setupToggled ? "ChevronDown" : "ChevronRight"}
>
<Detail size="S">Setup</Detail>
</ActionButton>
{#if !isTrigger}
<div class="block-options"> <div class="block-options">
{#if showBindingPicker} {#if !loopingSelected}
<div> <ActionButton on:click={() => addLooping()} icon="Reuse"
<Select >Add Looping</ActionButton
on:change={toggleFieldControl} >
quiet
defaultValue="Use values"
autoWidth
value={rowControl ? "Use bindings" : "Use values"}
options={["Use values", "Use bindings"]}
placeholder={null}
/>
</div>
{/if} {/if}
<div class="delete-padding" on:click={() => deleteStep()}> {#if showBindingPicker}
<Icon name="DeleteOutline" /> <Select
</div> on:change={toggleFieldControl}
defaultValue="Use values"
autoWidth
value={rowControl ? "Use bindings" : "Use values"}
options={["Use values", "Use bindings"]}
placeholder={null}
/>
{/if}
<ActionButton
on:click={() => deleteStep()}
icon="DeleteOutline"
/>
</div> </div>
{/if} </div>
</div> {/if}
{#if setupToggled} <AutomationBlockSetup
<AutomationBlockSetup schemaProperties={Object.entries(block.schema.inputs.properties)}
schemaProperties={Object.entries(block.schema.inputs.properties)} {block}
{block} {webhookModal}
{webhookModal} />
/> {#if lastStep}
{#if lastStep} <Button on:click={() => testDataModal.show()} cta
<Button on:click={() => testDataModal.show()} cta >Finish and test automation</Button
>Finish and test automation</Button >
>
{/if}
{/if} {/if}
</Layout> </Layout>
</div> </div>
@ -220,8 +306,10 @@
padding-left: 30px; padding-left: 30px;
} }
.block-options { .block-options {
display: flex; justify-content: flex-end;
align-items: center; align-items: center;
display: flex;
gap: var(--spacing-m);
} }
.center-items { .center-items {
display: flex; display: flex;
@ -256,4 +344,9 @@
/* center horizontally */ /* center horizontally */
align-self: center; align-self: center;
} }
.blockTitle {
display: flex;
align-items: center;
}
</style> </style>

View File

@ -1,5 +1,5 @@
<script> <script>
import { ModalContent, Icon, Detail, TextArea } from "@budibase/bbui" import { ModalContent, Icon, Detail, TextArea, Label } from "@budibase/bbui"
export let testResult export let testResult
export let isTrigger export let isTrigger
@ -10,11 +10,11 @@
<ModalContent <ModalContent
showCloseIcon={false} showCloseIcon={false}
showConfirmButton={false} showConfirmButton={false}
title="Test Automation"
cancelText="Close" cancelText="Close"
> >
<div slot="header"> <div slot="header" class="result-modal-header">
<div style="float: right;"> <span>Test Results</span>
<div>
{#if isTrigger || testResult[0].outputs.success} {#if isTrigger || testResult[0].outputs.success}
<div class="iconSuccess"> <div class="iconSuccess">
<Icon size="S" name="CheckmarkCircle" /> <Icon size="S" name="CheckmarkCircle" />
@ -26,7 +26,18 @@
{/if} {/if}
</div> </div>
</div> </div>
<span>
{#if testResult[0].outputs.iterations}
<div style="display: flex;">
<Icon name="Reuse" />
<div style="margin-left: 10px;">
<Label>
This loop ran {testResult[0].outputs.iterations} times.</Label
>
</div>
</div>
{/if}
</span>
<div <div
on:click={() => { on:click={() => {
inputToggled = !inputToggled inputToggled = !inputToggled
@ -89,6 +100,14 @@
</ModalContent> </ModalContent>
<style> <style>
.result-modal-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.iconSuccess { .iconSuccess {
color: var(--spectrum-global-color-green-600); color: var(--spectrum-global-color-green-600);
} }

View File

@ -88,36 +88,65 @@
if (!block || !automation) { if (!block || !automation) {
return [] return []
} }
// Find previous steps to the selected one // Find previous steps to the selected one
let allSteps = [...automation.steps] let allSteps = [...automation.steps]
if (automation.trigger) { if (automation.trigger) {
allSteps = [automation.trigger, ...allSteps] allSteps = [automation.trigger, ...allSteps]
} }
const blockIdx = allSteps.findIndex(step => step.id === block.id) let blockIdx = allSteps.findIndex(step => step.id === block.id)
// Extract all outputs from all previous steps as available bindings // Extract all outputs from all previous steps as available bindins
let bindings = [] let bindings = []
for (let idx = 0; idx < blockIdx; idx++) { for (let idx = 0; idx < blockIdx; idx++) {
const outputs = Object.entries( let wasLoopBlock = allSteps[idx]?.stepId === "LOOP"
allSteps[idx].schema?.outputs?.properties ?? {} let isLoopBlock =
) allSteps[idx]?.stepId === "LOOP" &&
allSteps.find(x => x.blockToLoop === block.id)
// If the previous block was a loop block, decerement the index so the following
// steps are in the correct order
if (wasLoopBlock) {
blockIdx--
}
let schema = allSteps[idx]?.schema?.outputs?.properties ?? {}
// If its a Loop Block, we need to add this custom schema
if (isLoopBlock) {
schema = {
currentItem: {
type: "string",
description: "the item currently being executed",
},
}
}
const outputs = Object.entries(schema)
bindings = bindings.concat( bindings = bindings.concat(
outputs.map(([name, value]) => { outputs.map(([name, value]) => {
const stepsLabel = block.name.startsWith("JS") let runtimeName = isLoopBlock
? `loop.${name}`
: block.name.startsWith("JS")
? `steps[${idx}].${name}` ? `steps[${idx}].${name}`
: `steps.${idx}.${name}` : `steps.${idx}.${name}`
const runtime = idx === 0 ? `trigger.${name}` : stepsLabel const runtime = idx === 0 ? `trigger.${name}` : runtimeName
return { return {
label: runtime, label: runtime,
type: value.type, type: value.type,
description: value.description, description: value.description,
category: idx === 0 ? "Trigger outputs" : `Step ${idx} outputs`, category:
idx === 0
? "Trigger outputs"
: isLoopBlock
? "Loop Outputs"
: `Step ${idx} outputs`,
path: runtime, path: runtime,
} }
}) })
) )
} }
return bindings return bindings
} }
@ -264,6 +293,14 @@
value={inputData[key]} value={inputData[key]}
/> />
</CodeEditorModal> </CodeEditorModal>
{:else if value.customType === "loopOption"}
<Select
on:change={e => onChange(e, key)}
autoWidth
value={inputData[key]}
options={["Array", "String"]}
defaultValue={"Array"}
/>
{:else if value.type === "string" || value.type === "number" || value.type === "integer"} {:else if value.type === "string" || value.type === "number" || value.type === "integer"}
{#if isTestModal} {#if isTestModal}
<ModalBindableInput <ModalBindableInput

View File

@ -14,7 +14,7 @@
import Table from "./Table.svelte" import Table from "./Table.svelte"
import { TableNames } from "constants" import { TableNames } from "constants"
import CreateEditRow from "./modals/CreateEditRow.svelte" import CreateEditRow from "./modals/CreateEditRow.svelte"
import { Pagination } from "@budibase/bbui" import { Pagination, Heading, Body, Layout } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core" import { fetchData } from "@budibase/frontend-core"
import { API } from "api" import { API } from "api"
@ -27,6 +27,8 @@
$: enrichedSchema = enrichSchema($tables.selected?.schema) $: enrichedSchema = enrichSchema($tables.selected?.schema)
$: id = $tables.selected?._id $: id = $tables.selected?._id
$: fetch = createFetch(id) $: fetch = createFetch(id)
$: hasCols = checkHasCols(schema)
$: hasRows = !!$fetch.rows?.length
const enrichSchema = schema => { const enrichSchema = schema => {
let tempSchema = { ...schema } let tempSchema = { ...schema }
@ -47,6 +49,20 @@
return tempSchema return tempSchema
} }
const checkHasCols = schema => {
if (!schema || Object.keys(schema).length === 0) {
return false
}
let fields = Object.values(schema)
for (let field of fields) {
if (!field.autocolumn) {
return true
}
}
return false
}
// Fetches new data whenever the table changes // Fetches new data whenever the table changes
const createFetch = tableId => { const createFetch = tableId => {
return fetchData({ return fetchData({
@ -104,40 +120,73 @@
disableSorting disableSorting
on:updatecolumns={onUpdateColumns} on:updatecolumns={onUpdateColumns}
on:updaterows={onUpdateRows} on:updaterows={onUpdateRows}
customPlaceholder
> >
<CreateColumnButton on:updatecolumns={onUpdateColumns} /> <div class="buttons">
{#if schema && Object.keys(schema).length > 0} <div class="left-buttons">
{#if !isUsersTable} <CreateColumnButton
<CreateRowButton highlighted={$fetch.loaded && (!hasCols || !hasRows)}
on:updaterows={onUpdateRows}
title={"Create row"}
modalContentComponent={CreateEditRow}
/>
{/if}
{#if isInternal}
<CreateViewButton />
{/if}
<ManageAccessButton resourceId={$tables.selected?._id} />
{#if isUsersTable}
<EditRolesButton />
{/if}
{#if !isInternal}
<ExistingRelationshipButton
table={$tables.selected}
on:updatecolumns={onUpdateColumns} on:updatecolumns={onUpdateColumns}
/> />
{/if} {#if !isUsersTable}
<HideAutocolumnButton bind:hideAutocolumns /> <CreateRowButton
<!-- always have the export last --> on:updaterows={onUpdateRows}
<ExportButton view={$tables.selected?._id} /> title={"Create row"}
<ImportButton modalContentComponent={CreateEditRow}
tableId={$tables.selected?._id} disabled={!hasCols}
on:updaterows={onUpdateRows} highlighted={$fetch.loaded && hasCols && !hasRows}
/> />
{#key id} {/if}
<TableFilterButton {schema} on:change={onFilter} /> {#if isInternal}
{/key} <CreateViewButton disabled={!hasCols || !hasRows} />
{/if} {/if}
</div>
<div class="right-buttons">
<ManageAccessButton resourceId={$tables.selected?._id} />
{#if isUsersTable}
<EditRolesButton />
{/if}
{#if !isInternal}
<ExistingRelationshipButton
table={$tables.selected}
on:updatecolumns={onUpdateColumns}
/>
{/if}
<HideAutocolumnButton bind:hideAutocolumns />
<ImportButton
tableId={$tables.selected?._id}
on:updaterows={onUpdateRows}
/>
<ExportButton
disabled={!hasRows || !hasCols}
view={$tables.selected?._id}
/>
{#key id}
<TableFilterButton
{schema}
on:change={onFilter}
disabled={!hasCols || !hasRows}
/>
{/key}
</div>
</div>
<div slot="placeholder">
<Layout gap="S">
{#if !hasCols}
<Heading>Let's create some columns</Heading>
<Body>
Start building out your table structure<br />
by adding some columns
</Body>
{:else}
<Heading>Now let's add a row</Heading>
<Body>
Add some data to your table<br />
by adding some rows
</Body>
{/if}
</Layout>
</div>
</Table> </Table>
{#key id} {#key id}
<div in:fade={{ delay: 200, duration: 100 }}> <div in:fade={{ delay: 200, duration: 100 }}>
@ -162,4 +211,20 @@
align-items: center; align-items: center;
margin-top: var(--spacing-xl); margin-top: var(--spacing-xl);
} }
.buttons {
flex: 1 1 auto;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.left-buttons,
.right-buttons {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
</style> </style>

View File

@ -34,10 +34,10 @@
$: label = meta.name ? capitalise(meta.name) : "" $: label = meta.name ? capitalise(meta.name) : ""
const timeStamp = resolveTimeStamp(value) const timeStamp = resolveTimeStamp(value)
const isTimeStamp = timeStamp ? true : false const isTimeStamp = !!timeStamp
</script> </script>
{#if type === "options"} {#if type === "options" && meta.constraints.inclusion.length !== 0}
<Select <Select
{label} {label}
data-cy="{meta.name}-select" data-cy="{meta.name}-select"
@ -51,7 +51,7 @@
<Dropzone {label} bind:value /> <Dropzone {label} bind:value />
{:else if type === "boolean"} {:else if type === "boolean"}
<Toggle text={label} bind:value data-cy="{meta.name}-input" /> <Toggle text={label} bind:value data-cy="{meta.name}-input" />
{:else if type === "array"} {:else if type === "array" && meta.constraints.inclusion.length !== 0}
<Multiselect bind:value {label} options={meta.constraints.inclusion} /> <Multiselect bind:value {label} options={meta.constraints.inclusion} />
{:else if type === "link"} {:else if type === "link"}
<LinkedRowSelector bind:linkedRows={value} schema={meta} /> <LinkedRowSelector bind:linkedRows={value} schema={meta} />

View File

@ -25,6 +25,7 @@
export let rowCount export let rowCount
export let type export let type
export let disableSorting = false export let disableSorting = false
export let customPlaceholder = false
let selectedRows = [] let selectedRows = []
let editableColumn let editableColumn
@ -117,10 +118,10 @@
</script> </script>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<div> <Layout noPadding gap="XS">
{#if title} {#if title}
<div class="table-title"> <div class="table-title">
<Heading size="S">{title}</Heading> <Heading size="M">{title}</Heading>
{#if loading} {#if loading}
<div transition:fade|local> <div transition:fade|local>
<Spinner size="10" /> <Spinner size="10" />
@ -134,7 +135,7 @@
<DeleteRowsButton on:updaterows {selectedRows} {deleteRows} /> <DeleteRowsButton on:updaterows {selectedRows} {deleteRows} />
{/if} {/if}
</div> </div>
</div> </Layout>
{#key tableId} {#key tableId}
<div class="table-wrapper"> <div class="table-wrapper">
<Table <Table
@ -144,6 +145,7 @@
{customRenderers} {customRenderers}
{rowCount} {rowCount}
{disableSorting} {disableSorting}
{customPlaceholder}
bind:selectedRows bind:selectedRows
allowSelectRows={allowEditing && !isUsersTable} allowSelectRows={allowEditing && !isUsersTable}
allowEditRows={allowEditing} allowEditRows={allowEditing}
@ -153,7 +155,9 @@
on:editrow={e => editRow(e.detail)} on:editrow={e => editRow(e.detail)}
on:clickrelationship={e => selectRelationship(e.detail)} on:clickrelationship={e => selectRelationship(e.detail)}
on:sort on:sort
/> >
<slot slot="placeholder" name="placeholder" />
</Table>
</div> </div>
{/key} {/key}
</Layout> </Layout>
@ -176,6 +180,7 @@
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
margin-top: var(--spacing-m);
} }
.table-title > div { .table-title > div {
margin-left: var(--spacing-xs); margin-left: var(--spacing-xs);

View File

@ -2,10 +2,21 @@
import { ActionButton, Modal } from "@budibase/bbui" import { ActionButton, Modal } from "@budibase/bbui"
import CreateEditColumn from "../modals/CreateEditColumn.svelte" import CreateEditColumn from "../modals/CreateEditColumn.svelte"
export let highlighted = false
export let disabled = false
let modal let modal
</script> </script>
<ActionButton icon="TableColumnAddRight" quiet size="S" on:click={modal.show}> <ActionButton
{disabled}
selected={highlighted}
emphasized={highlighted}
icon="TableColumnAddRight"
quiet
size="S"
on:click={modal.show}
>
Create column Create column
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

@ -4,11 +4,21 @@
export let modalContentComponent = CreateEditRow export let modalContentComponent = CreateEditRow
export let title = "Create row" export let title = "Create row"
export let disabled = false
export let highlighted = false
let modal let modal
</script> </script>
<ActionButton icon="TableRowAddBottom" size="S" quiet on:click={modal.show}> <ActionButton
{disabled}
emphasized={highlighted}
selected={highlighted}
icon="TableRowAddBottom"
size="S"
quiet
on:click={modal.show}
>
{title} {title}
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

@ -2,10 +2,18 @@
import { Modal, ActionButton } from "@budibase/bbui" import { Modal, ActionButton } from "@budibase/bbui"
import CreateViewModal from "../modals/CreateViewModal.svelte" import CreateViewModal from "../modals/CreateViewModal.svelte"
export let disabled = false
let modal let modal
</script> </script>
<ActionButton icon="CollectionAdd" size="S" quiet on:click={modal.show}> <ActionButton
{disabled}
icon="CollectionAdd"
size="S"
quiet
on:click={modal.show}
>
Create view Create view
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

@ -3,11 +3,18 @@
import ExportModal from "../modals/ExportModal.svelte" import ExportModal from "../modals/ExportModal.svelte"
export let view export let view
export let disabled = false
let modal let modal
</script> </script>
<ActionButton icon="DataDownload" size="S" quiet on:click={modal.show}> <ActionButton
{disabled}
icon="DataDownload"
size="S"
quiet
on:click={modal.show}
>
Export Export
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

@ -8,6 +8,12 @@
} }
</script> </script>
<ActionButton icon="MagicWand" primary size="S" quiet on:click={hideOrUnhide}> <ActionButton
{#if hideAutocolumns}Show auto columns{:else}Hide auto columns{/if} icon={hideAutocolumns ? "VisibilityOff" : "Visibility"}
primary
size="S"
quiet
on:click={hideOrUnhide}
>
Auto columns
</ActionButton> </ActionButton>

View File

@ -5,6 +5,7 @@
export let schema export let schema
export let filters export let filters
export let disabled = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let modal let modal
@ -17,6 +18,7 @@
icon="Filter" icon="Filter"
size="S" size="S"
quiet quiet
{disabled}
on:click={modal.show} on:click={modal.show}
active={tempValue?.length > 0} active={tempValue?.length > 0}
> >

View File

@ -9,6 +9,7 @@
export let onCancel = undefined export let onCancel = undefined
export let warning = true export let warning = true
export let disabled export let disabled
export let dataCy = null
let modal let modal
@ -28,6 +29,7 @@
{cancelText} {cancelText}
{warning} {warning}
{disabled} {disabled}
{dataCy}
> >
<Body size="S"> <Body size="S">
{body} {body}

View File

@ -4,6 +4,7 @@
export let label = null export let label = null
export let value export let value
export let copyValue export let copyValue
export let dataCy = null
const copyToClipboard = val => { const copyToClipboard = val => {
const dummy = document.createElement("textarea") const dummy = document.createElement("textarea")
@ -16,7 +17,7 @@
} }
</script> </script>
<div> <div data-cy={dataCy}>
<Input readonly {value} {label} /> <Input readonly {value} {label} />
<div class="icon" on:click={() => copyToClipboard(value || copyValue)}> <div class="icon" on:click={() => copyToClipboard(value || copyValue)}>
<Icon size="S" name="Copy" /> <Icon size="S" name="Copy" />

View File

@ -1,24 +1,61 @@
<script> <script>
import { Button, Modal, notifications, ModalContent } from "@budibase/bbui" import {
Button,
Modal,
notifications,
ModalContent,
Layout,
} from "@budibase/bbui"
import { API } from "api" import { API } from "api"
import analytics, { Events } from "analytics" import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore" import { store } from "builderStore"
import { ProgressCircle } from "@budibase/bbui"
import CopyInput from "components/common/inputs/CopyInput.svelte"
let feedbackModal let feedbackModal
let publishModal let publishModal
let asyncModal
let publishCompleteModal
let published
$: publishedUrl = published ? `${window.origin}/app${published.appUrl}` : ""
export let onOk
async function deployApp() { async function deployApp() {
try { try {
await API.deployAppChanges() //In Progress
asyncModal.show()
publishModal.hide()
published = await API.deployAppChanges()
analytics.captureEvent(Events.APP.PUBLISHED, { analytics.captureEvent(Events.APP.PUBLISHED, {
appId: $store.appId, appId: $store.appId,
}) })
notifications.success("Application published successfully") if (typeof onOk === "function") {
await onOk()
}
//Request completed
asyncModal.hide()
publishCompleteModal.show()
} catch (error) { } catch (error) {
analytics.captureException(error) analytics.captureException(error)
notifications.error("Error publishing app") notifications.error("Error publishing app")
} }
} }
const viewApp = () => {
if (published) {
analytics.captureEvent(Events.APP.VIEW_PUBLISHED, {
appId: $store.appId,
eventSource: EventSource.PORTAL,
})
window.open(publishedUrl, "_blank")
}
}
</script> </script>
<Button secondary on:click={publishModal.show}>Publish</Button> <Button secondary on:click={publishModal.show}>Publish</Button>
@ -30,11 +67,13 @@
showCancelButton={false} showCancelButton={false}
/> />
</Modal> </Modal>
<Modal bind:this={publishModal}> <Modal bind:this={publishModal}>
<ModalContent <ModalContent
title="Publish to Production" title="Publish to Production"
confirmText="Publish" confirmText="Publish"
onConfirm={deployApp} onConfirm={deployApp}
dataCy={"deploy-app-modal"}
> >
<span <span
>The changes you have made will be published to the production version of >The changes you have made will be published to the production version of
@ -42,3 +81,59 @@
> >
</ModalContent> </ModalContent>
</Modal> </Modal>
<!-- Publish in progress -->
<Modal bind:this={asyncModal}>
<ModalContent
showCancelButton={false}
showConfirmButton={false}
showCloseIcon={false}
>
<Layout justifyItems="center">
<ProgressCircle size="XL" />
</Layout>
</ModalContent>
</Modal>
<!-- Publish complete -->
<span class="publish-modal-wrap">
<Modal bind:this={publishCompleteModal}>
<ModalContent
confirmText="Done"
cancelText="View App"
onCancel={viewApp}
dataCy={"deploy-app-success-modal"}
>
<div slot="header" class="app-published-header">
<svg
width="26px"
height="26px"
class="spectrum-Icon success-icon"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-GlobeCheck" />
</svg>
<span class="app-published-header-text">App Published!</span>
</div>
<CopyInput
value={publishedUrl}
label="You can view your app at:"
dataCy="deployed-app-url"
/>
</ModalContent>
</Modal>
</span>
<style>
.app-published-header {
display: flex;
flex-direction: row;
align-items: center;
}
.success-icon {
color: var(--spectrum-global-color-green-600);
}
.app-published-header .app-published-header-text {
padding-left: var(--spacing-l);
}
</style>

View File

@ -7,12 +7,10 @@
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte" import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte"
import { store } from "builderStore" import { store } from "builderStore"
import {
const DeploymentStatus = { checkIncomingDeploymentStatus,
SUCCESS: "SUCCESS", DeploymentStatus,
PENDING: "PENDING", } from "components/deploy/utils"
FAILURE: "FAILURE",
}
const DATE_OPTIONS = { const DATE_OPTIONS = {
fullDate: { fullDate: {
@ -42,30 +40,17 @@
const formatDate = (date, format) => const formatDate = (date, format) =>
Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date) Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date)
// Required to check any updated deployment statuses between polls
function checkIncomingDeploymentStatus(current, incoming) {
for (let incomingDeployment of incoming) {
if (incomingDeployment.status === DeploymentStatus.FAILURE) {
const currentDeployment = current.find(
deployment => deployment._id === incomingDeployment._id
)
// We have just been notified of an ongoing deployments failure
if (
!currentDeployment ||
currentDeployment.status === DeploymentStatus.PENDING
) {
showErrorReasonModal(incomingDeployment.err)
}
}
}
}
async function fetchDeployments() { async function fetchDeployments() {
try { try {
const newDeployments = await API.getAppDeployments() const newDeployments = await API.getAppDeployments()
if (deployments.length > 0) { if (deployments.length > 0) {
checkIncomingDeploymentStatus(deployments, newDeployments) const pendingDeployments = checkIncomingDeploymentStatus(
deployments,
newDeployments
)
if (pendingDeployments.length) {
showErrorReasonModal(pendingDeployments[0].err)
}
} }
deployments = newDeployments deployments = newDeployments
} catch (err) { } catch (err) {

View File

@ -33,6 +33,7 @@
hoverable hoverable
on:click={revertModal.show} on:click={revertModal.show}
tooltip="Revert changes" tooltip="Revert changes"
dataCy="revert-application-topnav"
/> />
<Modal bind:this={revertModal}> <Modal bind:this={revertModal}>
<ModalContent <ModalContent

View File

@ -0,0 +1,25 @@
export const DeploymentStatus = {
SUCCESS: "SUCCESS",
PENDING: "PENDING",
FAILURE: "FAILURE",
}
// Required to check any updated deployment statuses between polls
export function checkIncomingDeploymentStatus(current, incoming) {
return incoming.reduce((acc, incomingDeployment) => {
if (incomingDeployment.status === DeploymentStatus.FAILURE) {
const currentDeployment = current.find(
deployment => deployment._id === incomingDeployment._id
)
//We have just been notified of an ongoing deployments failure
if (
!currentDeployment ||
currentDeployment.status === DeploymentStatus.PENDING
) {
acc.push(incomingDeployment)
}
}
return acc
}, [])
}

View File

@ -26,14 +26,6 @@
on:change={value => (parameters.rowId = value.detail)} on:change={value => (parameters.rowId = value.detail)}
/> />
<Label small>Row Rev</Label>
<DrawerBindableInput
{bindings}
title="Row rev to delete"
value={parameters.revId}
on:change={value => (parameters.revId = value.detail)}
/>
<Label small /> <Label small />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} /> <Checkbox text="Require confirmation" bind:value={parameters.confirm} />

View File

@ -0,0 +1,78 @@
<script>
import { Select, Label, Combobox } from "@budibase/bbui"
import { onMount } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { currentAsset, store } from "builderStore"
import {
getActionProviderComponents,
buildFormSchema,
} from "builderStore/dataBinding"
import { findComponent } from "builderStore/componentUtils"
export let parameters
export let bindings = []
const typeOptions = [
{
label: "Set value",
value: "set",
},
{
label: "Reset to default value",
value: "reset",
},
]
$: formComponent = findComponent($currentAsset.props, parameters.componentId)
$: formSchema = buildFormSchema(formComponent)
$: fieldOptions = Object.keys(formSchema || {})
$: actionProviders = getActionProviderComponents(
$currentAsset,
$store.selectedComponentId,
"ValidateForm"
)
onMount(() => {
if (!parameters.type) {
parameters.type = "set"
}
})
</script>
<div class="root">
<Label small>Form</Label>
<Select
bind:value={parameters.componentId}
options={actionProviders}
getOptionLabel={x => x._instanceName}
getOptionValue={x => x._id}
/>
<Label small>Type</Label>
<Select
placeholder={null}
bind:value={parameters.type}
options={typeOptions}
/>
<Label small>Field</Label>
<Combobox bind:value={parameters.field} options={fieldOptions} />
{#if parameters.type === "set"}
<Label small>Value</Label>
<DrawerBindableInput
{bindings}
value={parameters.value}
on:change={e => (parameters.value = e.detail)}
/>
{/if}
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -14,3 +14,4 @@ export { default as DuplicateRow } from "./DuplicateRow.svelte"
export { default as S3Upload } from "./S3Upload.svelte" export { default as S3Upload } from "./S3Upload.svelte"
export { default as ExportData } from "./ExportData.svelte" export { default as ExportData } from "./ExportData.svelte"
export { default as ContinueIf } from "./ContinueIf.svelte" export { default as ContinueIf } from "./ContinueIf.svelte"
export { default as UpdateFieldValue } from "./UpdateFieldValue.svelte"

View File

@ -42,25 +42,29 @@
"name": "Trigger Automation", "name": "Trigger Automation",
"component": "TriggerAutomation" "component": "TriggerAutomation"
}, },
{
"name": "Update Field Value",
"component": "UpdateFieldValue"
},
{ {
"name": "Validate Form", "name": "Validate Form",
"component": "ValidateForm" "component": "ValidateForm"
}, },
{ {
"name": "Log Out", "name": "Change Form Step",
"component": "LogOut" "component": "ChangeFormStep"
}, },
{ {
"name": "Clear Form", "name": "Clear Form",
"component": "ClearForm" "component": "ClearForm"
}, },
{ {
"name": "Close Screen Modal", "name": "Log Out",
"component": "CloseScreenModal" "component": "LogOut"
}, },
{ {
"name": "Change Form Step", "name": "Close Screen Modal",
"component": "ChangeFormStep" "component": "CloseScreenModal"
}, },
{ {
"name": "Refresh Data Provider", "name": "Refresh Data Provider",

View File

@ -15,6 +15,7 @@
export let editApp export let editApp
export let updateApp export let updateApp
export let deleteApp export let deleteApp
export let previewApp
export let unpublishApp export let unpublishApp
export let releaseLock export let releaseLock
export let editIcon export let editIcon
@ -22,7 +23,7 @@
<div class="title"> <div class="title">
<div style="display: flex;"> <div style="display: flex;">
<div style="color: {app.icon?.color || ''}"> <div class="app-icon" style="color: {app.icon?.color || ''}">
<Icon size="XL" name={app.icon?.name || "Apps"} /> <Icon size="XL" name={app.icon?.name || "Apps"} />
</div> </div>
<div class="name" on:click={() => editApp(app)}> <div class="name" on:click={() => editApp(app)}>
@ -57,26 +58,38 @@
</StatusLight> </StatusLight>
</div> </div>
<div class="desktop"> <div class="desktop">
<StatusLight active={app.deployed} neutral={!app.deployed}> <div class="app-status">
{#if app.deployed}Published{:else}Unpublished{/if} {#if app.deployed}
</StatusLight> <Icon name="Globe" disabled={false} />
Published
{:else}
<Icon name="GlobeStrike" disabled={true} />
<span class="disabled"> Unpublished </span>
{/if}
</div>
</div> </div>
<div data-cy={`row_actions_${app.appId}`}> <div data-cy={`row_actions_${app.appId}`}>
<Button <div class="app-row-actions">
size="S"
disabled={app.lockedOther}
on:click={() => editApp(app)}
secondary
>
Open
</Button>
<ActionMenu align="right">
<Icon hoverable slot="control" name="More" />
{#if app.deployed} {#if app.deployed}
<MenuItem on:click={() => viewApp(app)} icon="GlobeOutline"> <Button size="S" secondary quiet on:click={() => viewApp(app)}
View published app >View app
</MenuItem> </Button>
{:else}
<Button size="S" secondary quiet on:click={() => previewApp(app)}
>Preview
</Button>
{/if} {/if}
<Button
size="S"
cta
disabled={app.lockedOther}
on:click={() => editApp(app)}
>
Edit
</Button>
</div>
<ActionMenu align="right" dataCy="app-row-actions-menu-popover">
<Icon hoverable slot="control" name="More" dataCy="app-row-actions-menu" />
{#if app.lockedYou} {#if app.lockedYou}
<MenuItem on:click={() => releaseLock(app)} icon="LockOpen"> <MenuItem on:click={() => releaseLock(app)} icon="LockOpen">
Release lock Release lock
@ -97,6 +110,18 @@
</div> </div>
<style> <style>
.app-row-actions {
grid-gap: var(--spacing-s);
display: grid;
grid-template-columns: 75px 75px;
}
.app-status {
display: grid;
grid-template-columns: 24px 100px;
}
.app-status span.disabled {
opacity: 0.3;
}
.name { .name {
text-decoration: none; text-decoration: none;
overflow: hidden; overflow: hidden;

View File

@ -0,0 +1,14 @@
import { auth } from "../stores/portal"
import { get } from "svelte/store"
export const FEATURE_FLAGS = {
LICENSING: "LICENSING",
}
export const isEnabled = featureFlag => {
const user = get(auth).user
if (user?.featureFlags?.includes(featureFlag)) {
return true
}
return false
}

View File

@ -6,14 +6,65 @@
import RevertModal from "components/deploy/RevertModal.svelte" import RevertModal from "components/deploy/RevertModal.svelte"
import { API } from "api" import { API } from "api"
import { apps } from "stores/portal" import { apps } from "stores/portal"
import {
Icon,
ActionGroup,
Tabs,
Tab,
notifications,
PopoverMenu,
Layout,
Button,
Heading,
Body,
} from "@budibase/bbui"
import DeployModal from "components/deploy/DeployModal.svelte"
import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { API } from "api"
import { auth, apps } from "stores/portal"
import { isActive, goto, layout, redirect } from "@roxi/routify" import { isActive, goto, layout, redirect } from "@roxi/routify"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import { processStringSync } from "@budibase/string-templates"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
import analytics, { Events, EventSource } from "analytics"
export let application export let application
// Get Package and set store // Get Package and set store
let promise = getPackage() let promise = getPackage()
let unpublishModal
let publishPopover
$: enrichedApps = enrichApps($apps, $auth.user)
const enrichApps = (apps, user) => {
const enrichedApps = apps
.map(app => ({
...app,
deployed: app.status === "published",
lockedYou: app.lockedBy && app.lockedBy.email === user?.email,
lockedOther: app.lockedBy && app.lockedBy.email !== user?.email,
}))
.filter(app => {
return app.devId === application
})
return enrichedApps
}
$: selectedApp = enrichedApps.length > 0 ? enrichedApps[0] : {}
$: deployments = []
$: latestDeployments = deployments
.filter(deployment => deployment.status === "SUCCESS")
.sort((a, b) => a.updatedAt > b.updatedAt)
$: isPublished =
selectedApp.deployed && latestDeployments && latestDeployments?.length
? true
: false
// Sync once when you load the app // Sync once when you load the app
let hasSynced = false let hasSynced = false
@ -24,12 +75,20 @@
$: appInfo = $apps?.find(app => app.devId === application) $: appInfo = $apps?.find(app => app.devId === application)
$: published = appInfo?.status === "published" $: published = appInfo?.status === "published"
const viewPreviewApp = () => { const previewApp = () => {
window.open(`/${application}`) window.open(`/${application}`)
} }
const viewPublishedApp = () => { const viewApp = () => {
window.open(`/app${appInfo?.url}`) analytics.captureEvent(Events.APP.VIEW_PUBLISHED, {
appId: selectedApp.appId,
eventSource: EventSource.PORTAL,
})
if (selectedApp.url) {
window.open(`/app${selectedApp.url}`)
} else {
window.open(`/${selectedApp.prodId}`)
}
} }
async function getPackage() { async function getPackage() {
@ -62,23 +121,74 @@
}) })
} }
const reviewPendingDeployments = (deployments, newDeployments) => {
if (deployments.length > 0) {
const pending = checkIncomingDeploymentStatus(deployments, newDeployments)
if (pending.length) {
notifications.warning(
"Deployment has been queued and will be processed shortly"
)
}
}
}
async function fetchDeployments() {
try {
const newDeployments = await API.getAppDeployments()
reviewPendingDeployments(deployments, newDeployments)
return newDeployments
} catch (err) {
notifications.error("Error fetching deployment history")
}
}
onMount(async () => { onMount(async () => {
if (!hasSynced && application) { if (!hasSynced && application) {
try { try {
await API.syncApp(application) await API.syncApp(application)
await apps.load()
} catch (error) { } catch (error) {
notifications.error("Failed to sync with production database") notifications.error("Failed to sync with production database")
} }
hasSynced = true hasSynced = true
} }
if (!$apps?.length) { deployments = await fetchDeployments()
apps.load()
}
}) })
onDestroy(() => { onDestroy(() => {
store.actions.reset() store.actions.reset()
}) })
const unpublishApp = () => {
publishPopover.hide()
unpublishModal.show()
}
const completePublish = async () => {
try {
await apps.load()
deployments = await fetchDeployments()
} catch (err) {
notifications.error("Error refreshing app")
}
}
const confirmUnpublishApp = async () => {
if (!application || !isPublished) {
//confirm the app has loaded.
return
}
try {
analytics.captureEvent(Events.APP.UNPUBLISHED, {
appId: selectedApp.appId,
})
await API.unpublishApp(selectedApp.prodId)
await apps.load()
notifications.success("App unpublished successfully")
} catch (err) {
notifications.error("Error unpublishing app")
}
}
</script> </script>
{#await promise} {#await promise}
@ -113,35 +223,92 @@
<Icon <Icon
name="Visibility" name="Visibility"
hoverable hoverable
on:click={viewPreviewApp} on:click={previewApp}
tooltip="View app preview" tooltip="View app preview"
/> />
<Icon {#if isPublished}
name={published ? "Globe" : "GlobeStrike"} <PopoverMenu
hoverable bind:this={publishPopover}
disabled={!published} align="right"
on:click={viewPublishedApp} disabled={!isPublished}
tooltip={published dataCy="publish-popover-menu"
? "View published app" >
: "Your app is not published"} <div slot="control" class="icon app-status-icon">
/> <Icon
<DeployModal /> size="M"
hoverable
name="Globe"
disabled={!isPublished}
tooltip="Your published app"
/>
</div>
<Layout gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
{#if isPublished}
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
{/if}
</Body>
<div class="publish-popover-actions">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
dataCy="publish-popover-action"
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</PopoverMenu>
{/if}
{#if !isPublished}
<Icon
size="M"
name="GlobeStrike"
disabled
tooltip="Your app has not been published yet"
/>
{/if}
<DeployModal onOk={completePublish} />
</div> </div>
</div> </div>
<slot /> <slot />
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
dataCy={"unpublish-modal"}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
</div> </div>
{:catch error} {:catch error}
<p>Something went wrong: {error.message}</p> <p>Something went wrong: {error.message}</p>
{/await} {/await}
<style> <style>
.publish-popover-actions :global([data-cy="publish-popover-action"]) {
margin-right: var(--spacing-s);
}
.loading { .loading {
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;
width: 100%; width: 100%;
background: var(--background); background: var(--background);
} }
.root { .root {
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;

View File

@ -14,7 +14,7 @@
notifications.success("Invitation accepted successfully") notifications.success("Invitation accepted successfully")
$goto("../auth/login") $goto("../auth/login")
} catch (error) { } catch (error) {
notifications.error("Error accepting invitation") notifications.error(error.message)
} }
} }
</script> </script>

View File

@ -20,6 +20,7 @@
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte" import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import UpdateAPIKeyModal from "components/settings/UpdateAPIKeyModal.svelte" import UpdateAPIKeyModal from "components/settings/UpdateAPIKeyModal.svelte"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { isEnabled, FEATURE_FLAGS } from "../../../helpers/featureFlags"
let loaded = false let loaded = false
let userInfoModal let userInfoModal
@ -54,10 +55,17 @@
if (!$adminStore.cloud) { if (!$adminStore.cloud) {
menu = menu.concat([ menu = menu.concat([
{ {
title: "Updates", title: "Update",
href: "/builder/portal/settings/update", href: "/builder/portal/settings/update",
}, },
]) ])
if (isEnabled(FEATURE_FLAGS.LICENSING)) {
menu = menu.concat({
title: "Upgrade",
href: "/builder/portal/settings/upgrade",
})
}
} }
} else { } else {
menu = menu.concat([ menu = menu.concat([

View File

@ -75,8 +75,8 @@
<div class="title"> <div class="title">
<div class="welcome"> <div class="welcome">
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Heading size="M">{createAppTitle}</Heading> <Heading size="L">{createAppTitle}</Heading>
<Body size="S"> <Body size="M">
{welcomeBody} {welcomeBody}
</Body> </Body>
</Layout> </Layout>
@ -84,7 +84,7 @@
<div class="buttons"> <div class="buttons">
<Button <Button
dataCy="create-app-btn" dataCy="create-app-btn"
size="L" size="M"
icon="Add" icon="Add"
cta cta
on:click={initiateAppCreation} on:click={initiateAppCreation}
@ -94,7 +94,7 @@
<Button <Button
dataCy="import-app-btn" dataCy="import-app-btn"
icon="Import" icon="Import"
size="L" size="M"
quiet quiet
secondary secondary
on:click={initiateAppImport} on:click={initiateAppImport}

View File

@ -28,7 +28,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import AppRow from "components/start/AppRow.svelte" import AppRow from "components/start/AppRow.svelte"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import analytics, { Events } from "analytics" import analytics, { Events, EventSource } from "analytics"
import Logo from "assets/bb-space-man.svg" import Logo from "assets/bb-space-man.svg"
let sortBy = "name" let sortBy = "name"
@ -167,6 +167,10 @@
} }
const viewApp = app => { const viewApp = app => {
analytics.captureEvent(Events.APP.VIEW_PUBLISHED, {
appId: app.appId,
eventSource: EventSource.PORTAL,
})
if (app.url) { if (app.url) {
window.open(`/app${app.url}`) window.open(`/app${app.url}`)
} else { } else {
@ -174,6 +178,10 @@
} }
} }
const previewApp = app => {
window.open(`/${app.devId}`)
}
const editApp = app => { const editApp = app => {
if (app.lockedOther) { if (app.lockedOther) {
notifications.error( notifications.error(
@ -205,6 +213,9 @@
return return
} }
try { try {
analytics.captureEvent(Events.APP.UNPUBLISHED, {
appId: selectedApp.appId,
})
await API.unpublishApp(selectedApp.prodId) await API.unpublishApp(selectedApp.prodId)
await apps.load() await apps.load()
notifications.success("App unpublished successfully") notifications.success("App unpublished successfully")
@ -392,6 +403,7 @@
{exportApp} {exportApp}
{deleteApp} {deleteApp}
{updateApp} {updateApp}
{previewApp}
/> />
{/each} {/each}
</div> </div>
@ -444,6 +456,7 @@
title="Confirm unpublish" title="Confirm unpublish"
okText="Unpublish app" okText="Unpublish app"
onOk={confirmUnpublishApp} onOk={confirmUnpublishApp}
dataCy={"unpublish-modal"}
> >
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog> </ConfirmDialog>

View File

@ -26,7 +26,7 @@
}) })
notifications.success("Successfully created user") notifications.success("Successfully created user")
} catch (error) { } catch (error) {
notifications.error("Error creating user") notifications.error(error.message)
} }
} }
</script> </script>

View File

@ -0,0 +1,151 @@
<script>
import {
Layout,
Heading,
Body,
Divider,
Link,
Button,
Input,
Label,
notifications,
} from "@budibase/bbui"
import { auth, admin } from "stores/portal"
import { redirect } from "@roxi/routify"
import { processStringSync } from "@budibase/string-templates"
import { API } from "api"
import { onMount } from "svelte"
$: license = $auth.user.license
$: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
$: activateDisabled = !licenseKey || licenseKeyDisabled
let licenseInfo
let licenseKeyDisabled = false
let licenseKeyType = "text"
let licenseKey = ""
// Make sure page can't be visited directly in cloud
$: {
if ($admin.cloud) {
$redirect("../../portal")
}
}
const activate = async () => {
await API.activateLicenseKey({ licenseKey })
await auth.getSelf()
await setLicenseInfo()
notifications.success("Successfully activated")
}
const refresh = async () => {
try {
await API.refreshLicense()
await auth.getSelf()
notifications.success("Refreshed license")
} catch (err) {
console.error(err)
notifications.error("Error refreshing license")
}
}
// deactivate the license key field if there is a license key set
$: {
if (licenseInfo?.licenseKey) {
licenseKey = "**********************************************"
licenseKeyType = "password"
licenseKeyDisabled = true
activateDisabled = true
}
}
const setLicenseInfo = async () => {
licenseInfo = await API.getLicenseInfo()
}
onMount(async () => {
await setLicenseInfo()
})
</script>
{#if $auth.isAdmin}
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading size="M">Upgrade</Heading>
<Body size="M">
{#if license.plan.type === "free"}
Upgrade your budibase installation to unlock additional features. To
subscribe to a plan visit your <Link size="L" href={upgradeUrl}
>Account</Link
>.
{:else}
To manage your plan visit your <Link size="L" href={upgradeUrl}
>Account</Link
>.
{/if}
</Body>
</Layout>
<Divider size="S" />
<Layout gap="XS" noPadding>
<Heading size="S">Activate</Heading>
<Body size="S">Enter your license key below to activate your plan</Body>
</Layout>
<Layout noPadding>
<div class="fields">
<div class="field">
<Label size="L">License Key</Label>
<Input
thin
bind:value={licenseKey}
type={licenseKeyType}
disabled={licenseKeyDisabled}
/>
</div>
</div>
<div>
<Button cta on:click={activate} disabled={activateDisabled}
>Activate</Button
>
</div>
</Layout>
<Divider size="S" />
<Layout gap="L" noPadding>
<Layout gap="S" noPadding>
<Heading size="S">Plan</Heading>
<Layout noPadding gap="XXS">
<Body size="S">You are currently on the {license.plan.type} plan</Body
>
<Body size="XS">
{processStringSync(
"Updated {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(license.refreshedAt).getTime(),
}
)}
</Body>
</Layout>
</Layout>
<div>
<Button secondary on:click={refresh}>Refresh</Button>
</div>
</Layout>
</Layout>
{/if}
<style>
.fields {
display: grid;
grid-gap: var(--spacing-m);
}
.field {
display: grid;
grid-template-columns: 100px 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,14 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Publish Dev",
"program": "${workspaceFolder}/scripts/publishDev.js"
}
]
}

View File

@ -1834,7 +1834,12 @@
"icon": "Form", "icon": "Form",
"hasChildren": true, "hasChildren": true,
"illegalChildren": ["section", "form"], "illegalChildren": ["section", "form"],
"actions": ["ValidateForm", "ClearForm", "ChangeFormStep"], "actions": [
"ValidateForm",
"ClearForm",
"ChangeFormStep",
"UpdateFieldValue"
],
"styles": ["size"], "styles": ["size"],
"settings": [ "settings": [
{ {
@ -1975,6 +1980,17 @@
"label": "Default value", "label": "Default value",
"key": "defaultValue" "key": "defaultValue"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "boolean", "type": "boolean",
"label": "Disabled", "label": "Disabled",
@ -2049,6 +2065,17 @@
"label": "Default value", "label": "Default value",
"key": "defaultValue" "key": "defaultValue"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "boolean", "type": "boolean",
"label": "Disabled", "label": "Disabled",
@ -2089,6 +2116,17 @@
"label": "Default value", "label": "Default value",
"key": "defaultValue" "key": "defaultValue"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "boolean", "type": "boolean",
"label": "Disabled", "label": "Disabled",
@ -2125,6 +2163,17 @@
"key": "placeholder", "key": "placeholder",
"placeholder": "Choose an option" "placeholder": "Choose an option"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "select", "type": "select",
"label": "Type", "label": "Type",
@ -2274,6 +2323,17 @@
"label": "Default value", "label": "Default value",
"key": "defaultValue" "key": "defaultValue"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "boolean", "type": "boolean",
"label": "Autocomplete", "label": "Autocomplete",
@ -2399,6 +2459,17 @@
"label": "Default value", "label": "Default value",
"key": "defaultValue" "key": "defaultValue"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "boolean", "type": "boolean",
"label": "Disabled", "label": "Disabled",
@ -2439,6 +2510,17 @@
"label": "Default value", "label": "Default value",
"key": "defaultValue" "key": "defaultValue"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "select", "type": "select",
"label": "Formatting", "label": "Formatting",
@ -2512,6 +2594,17 @@
"label": "Default value", "label": "Default value",
"key": "defaultValue" "key": "defaultValue"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "boolean", "type": "boolean",
"label": "Disabled", "label": "Disabled",
@ -2657,6 +2750,17 @@
"label": "Extensions", "label": "Extensions",
"key": "extensions" "key": "extensions"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "boolean", "type": "boolean",
"label": "Disabled", "label": "Disabled",
@ -2697,6 +2801,17 @@
"label": "Default value", "label": "Default value",
"key": "defaultValue" "key": "defaultValue"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "boolean", "type": "boolean",
"label": "Autocomplete", "label": "Autocomplete",
@ -2742,6 +2857,17 @@
"label": "Default value", "label": "Default value",
"key": "defaultValue" "key": "defaultValue"
}, },
{
"type": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{ {
"type": "boolean", "type": "boolean",
"label": "Disabled", "label": "Disabled",
@ -2750,6 +2876,62 @@
} }
] ]
}, },
"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": "event",
"label": "On Change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/attachment",
"label": "Validation",
"key": "validation"
}
]
},
"dataprovider": { "dataprovider": {
"name": "Data Provider", "name": "Data Provider",
"info": "Pagination is only available for data stored in tables.", "info": "Pagination is only available for data stored in tables.",
@ -3581,51 +3763,6 @@
} }
] ]
}, },
"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"
}
]
},
"markdownviewer": { "markdownviewer": {
"name": "Markdown Viewer", "name": "Markdown Viewer",
"icon": "TaskList", "icon": "TaskList",

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "1.0.105-alpha.20", "version": "1.0.105-alpha.35",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.0.105-alpha.20", "@budibase/bbui": "^1.0.105-alpha.35",
"@budibase/frontend-core": "^1.0.105-alpha.20", "@budibase/frontend-core": "^1.0.105-alpha.35",
"@budibase/string-templates": "^1.0.105-alpha.20", "@budibase/string-templates": "^1.0.105-alpha.35",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3", "@spectrum-css/divider": "^1.0.3",

View File

@ -8,6 +8,7 @@
export let disabled = false export let disabled = false
export let validation export let validation
export let extensions export let extensions
export let onChange
let fieldState let fieldState
let fieldApi let fieldApi
@ -38,6 +39,13 @@
return [] return []
} }
} }
const handleChange = e => {
fieldApi.setValue(e.detail)
if (onChange) {
onChange({ value: e.detail })
}
}
</script> </script>
<Field <Field
@ -55,9 +63,7 @@
value={fieldState.value} value={fieldState.value}
disabled={fieldState.disabled} disabled={fieldState.disabled}
error={fieldState.error} error={fieldState.error}
on:change={e => { on:change={handleChange}
fieldApi.setValue(e.detail)
}}
{processFiles} {processFiles}
{handleFileTooLarge} {handleFileTooLarge}
{extensions} {extensions}

View File

@ -9,6 +9,7 @@
export let size export let size
export let validation export let validation
export let defaultValue export let defaultValue
export let onChange
let fieldState let fieldState
let fieldApi let fieldApi
@ -25,6 +26,13 @@
} }
return false return false
} }
const handleChange = e => {
fieldApi.setValue(e.detail)
if (onChange) {
onChange({ value: e.detail })
}
}
</script> </script>
<Field <Field
@ -44,8 +52,8 @@
error={fieldState.error} error={fieldState.error}
id={fieldState.fieldId} id={fieldState.fieldId}
{size} {size}
on:change={e => fieldApi.setValue(e.detail)}
{text} {text}
on:change={handleChange}
/> />
{/if} {/if}
</Field> </Field>

View File

@ -10,9 +10,17 @@
export let timeOnly = false export let timeOnly = false
export let validation export let validation
export let defaultValue export let defaultValue
export let onChange
let fieldState let fieldState
let fieldApi let fieldApi
const handleChange = e => {
fieldApi.setValue(e.detail)
if (onChange) {
onChange({ value: e.detail })
}
}
</script> </script>
<Field <Field
@ -28,7 +36,7 @@
{#if fieldState} {#if fieldState}
<CoreDatePicker <CoreDatePicker
value={fieldState.value} value={fieldState.value}
on:change={e => fieldApi.setValue(e.detail)} on:change={handleChange}
disabled={fieldState.disabled} disabled={fieldState.disabled}
error={fieldState.error} error={fieldState.error}
id={fieldState.fieldId} id={fieldState.fieldId}

View File

@ -219,10 +219,10 @@
}) })
return valid return valid
}, },
clear: () => { reset: () => {
// Clear the form by clearing each individual field // Reset the form by resetting each individual field
fields.forEach(field => { fields.forEach(field => {
get(field).fieldApi.clearValue() get(field).fieldApi.reset()
}) })
}, },
changeStep: ({ type, number }) => { changeStep: ({ type, number }) => {
@ -241,6 +241,22 @@
currentStep.set(step) currentStep.set(step)
} }
}, },
setFieldValue: (fieldName, value) => {
const field = getField(fieldName)
if (!field) {
return
}
const { fieldApi } = get(field)
fieldApi.setValue(value)
},
resetField: fieldName => {
const field = getField(fieldName)
if (!field) {
return
}
const { fieldApi } = get(field)
fieldApi.reset()
},
} }
// Creates an API for a specific field // Creates an API for a specific field
@ -268,11 +284,11 @@
return !error return !error
} }
// Clears the value of a certain field back to the initial value // Clears the value of a certain field back to the default value
const clearValue = () => { const reset = () => {
const fieldInfo = getField(field) const fieldInfo = getField(field)
const { fieldState } = get(fieldInfo) const { fieldState } = get(fieldInfo)
const newValue = initialValues[field] ?? fieldState.defaultValue const newValue = fieldState.defaultValue
// Update field state // Update field state
fieldInfo.update(state => { fieldInfo.update(state => {
@ -329,7 +345,7 @@
return { return {
setValue, setValue,
clearValue, reset,
updateValidation, updateValidation,
setDisabled, setDisabled,
validate: () => { validate: () => {
@ -354,11 +370,20 @@
// register their fields to step 1 // register their fields to step 1
setContext("form-step", writable(1)) setContext("form-step", writable(1))
const handleUpdateFieldValue = ({ type, field, value }) => {
if (type === "set") {
formApi.setFieldValue(field, value)
} else {
formApi.resetField(field)
}
}
// Action context to pass to children // Action context to pass to children
const actions = [ const actions = [
{ type: ActionTypes.ValidateForm, callback: formApi.validate }, { type: ActionTypes.ValidateForm, callback: formApi.validate },
{ type: ActionTypes.ClearForm, callback: formApi.clear }, { type: ActionTypes.ClearForm, callback: formApi.reset },
{ type: ActionTypes.ChangeFormStep, callback: formApi.changeStep }, { type: ActionTypes.ChangeFormStep, callback: formApi.changeStep },
{ type: ActionTypes.UpdateFieldValue, callback: handleUpdateFieldValue },
] ]
</script> </script>

View File

@ -8,6 +8,7 @@
export let placeholder export let placeholder
export let disabled = false export let disabled = false
export let defaultValue = "" export let defaultValue = ""
export let onChange
const component = getContext("component") const component = getContext("component")
const validation = [ const validation = [
@ -33,6 +34,14 @@
return value return value
} }
} }
const handleChange = e => {
const value = parseValue(e.detail)
fieldApi.setValue(value)
if (onChange) {
onChange({ value })
}
}
</script> </script>
<Field <Field
@ -49,7 +58,7 @@
<div style="--height: {height};"> <div style="--height: {height};">
<CoreTextArea <CoreTextArea
value={serialiseValue(fieldState.value)} value={serialiseValue(fieldState.value)}
on:change={e => fieldApi.setValue(parseValue(e.detail))} on:change={handleChange}
disabled={fieldState.disabled} disabled={fieldState.disabled}
error={fieldState.error} error={fieldState.error}
id={fieldState.fieldId} id={fieldState.fieldId}

View File

@ -11,6 +11,7 @@
export let validation export let validation
export let defaultValue = "" export let defaultValue = ""
export let format = "auto" export let format = "auto"
export let onChange
let fieldState let fieldState
let fieldApi let fieldApi
@ -44,6 +45,13 @@
}, },
}) })
} }
const handleChange = e => {
fieldApi.setValue(e.detail)
if (onChange) {
onChange({ value: e.detail })
}
}
</script> </script>
<Field <Field
@ -61,7 +69,7 @@
{#if useRichText} {#if useRichText}
<CoreRichTextField <CoreRichTextField
value={fieldState.value} value={fieldState.value}
on:change={e => fieldApi.setValue(e.detail)} on:change={handleChange}
disabled={fieldState.disabled} disabled={fieldState.disabled}
error={fieldState.error} error={fieldState.error}
id={fieldState.fieldId} id={fieldState.fieldId}
@ -78,7 +86,7 @@
{:else} {:else}
<CoreTextArea <CoreTextArea
value={fieldState.value} value={fieldState.value}
on:change={e => fieldApi.setValue(e.detail)} on:change={handleChange}
disabled={fieldState.disabled} disabled={fieldState.disabled}
error={fieldState.error} error={fieldState.error}
id={fieldState.fieldId} id={fieldState.fieldId}

View File

@ -14,6 +14,7 @@
export let valueColumn export let valueColumn
export let customOptions export let customOptions
export let autocomplete = false export let autocomplete = false
export let onChange
let fieldState let fieldState
let fieldApi let fieldApi
@ -34,13 +35,18 @@
if (!values) { if (!values) {
return [] return []
} }
if (Array.isArray(values)) { if (Array.isArray(values)) {
return values return values
} }
return values.split(",").map(value => value.trim()) return values.split(",").map(value => value.trim())
} }
const handleChange = e => {
fieldApi.setValue(e.detail)
if (onChange) {
onChange({ value: e.detail })
}
}
</script> </script>
<Field <Field
@ -62,7 +68,7 @@
getOptionValue={flatOptions ? x => x : x => x.value} getOptionValue={flatOptions ? x => x : x => x.value}
id={fieldState.fieldId} id={fieldState.fieldId}
disabled={fieldState.disabled} disabled={fieldState.disabled}
on:change={e => fieldApi.setValue(e.detail)} on:change={handleChange}
{placeholder} {placeholder}
{options} {options}
{autocomplete} {autocomplete}

View File

@ -16,6 +16,7 @@
export let customOptions export let customOptions
export let autocomplete = false export let autocomplete = false
export let direction = "vertical" export let direction = "vertical"
export let onChange
let fieldState let fieldState
let fieldApi let fieldApi
@ -30,6 +31,13 @@
valueColumn, valueColumn,
customOptions customOptions
) )
const handleChange = e => {
fieldApi.setValue(e.detail)
if (onChange) {
onChange({ value: e.detail })
}
}
</script> </script>
<Field <Field
@ -52,7 +60,7 @@
error={fieldState.error} error={fieldState.error}
{options} {options}
{placeholder} {placeholder}
on:change={e => fieldApi.setValue(e.detail)} on:change={handleChange}
getOptionLabel={flatOptions ? x => x : x => x.label} getOptionLabel={flatOptions ? x => x : x => x.label}
getOptionValue={flatOptions ? x => x : x => x.value} getOptionValue={flatOptions ? x => x : x => x.value}
{autocomplete} {autocomplete}
@ -66,7 +74,7 @@
error={fieldState.error} error={fieldState.error}
{options} {options}
{direction} {direction}
on:change={e => fieldApi.setValue(e.detail)} on:change={handleChange}
getOptionLabel={flatOptions ? x => x : x => x.label} getOptionLabel={flatOptions ? x => x : x => x.label}
getOptionValue={flatOptions ? x => x : x => x.value} getOptionValue={flatOptions ? x => x : x => x.value}
/> />

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