Merge pull request #7017 from Budibase/develop

develop -> master
This commit is contained in:
Martin McKeaveney 2022-08-01 17:57:07 +01:00 committed by GitHub
commit 121e1c5af5
212 changed files with 7905 additions and 1792 deletions

View File

@ -135,13 +135,18 @@ You can learn more about the Budibase API at the following places:
## 🏁 Get started
<a href="https://docs.budibase.com/docs/hosting-methods"><img src="https://res.cloudinary.com/daog6scxm/image/upload/v1634808888/logo/deploy_npl9za.png" /></a>
Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean.
Or use Budibase Cloud if you don't need to self-host, and would like to get started quickly.
### [Get started with self-hosting Budibase](https://docs.budibase.com/docs/hosting-methods)
- [Docker - single ARM compatible image](https://docs.budibase.com/docs/docker)
- [Docker Compose](https://docs.budibase.com/docs/docker-compose)
- [Kubernetes](https://docs.budibase.com/docs/kubernetes-k8s)
- [Digital Ocean](https://docs.budibase.com/docs/digitalocean)
- [Portainer](https://docs.budibase.com/docs/portainer)
### [Get started with Budibase Cloud](https://budibase.com)

View File

@ -151,6 +151,10 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{ if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 6 }}
{{ end }}
restartPolicy: Always
serviceAccountName: ""
status: {}

View File

@ -68,6 +68,10 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{ if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 6 }}
{{ end }}
restartPolicy: Always
serviceAccountName: ""
volumes:
@ -75,4 +79,4 @@ spec:
persistentVolumeClaim:
claimName: minio-data
status: {}
{{- end }}
{{- end }}

View File

@ -40,6 +40,10 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{ if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 6 }}
{{ end }}
restartPolicy: Always
serviceAccountName: ""
volumes:

View File

@ -47,6 +47,10 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{ if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 6 }}
{{ end }}
restartPolicy: Always
serviceAccountName: ""
volumes:
@ -54,4 +58,4 @@ spec:
persistentVolumeClaim:
claimName: redis-data
status: {}
{{- end }}
{{- end }}

View File

@ -145,6 +145,10 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{ if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 6 }}
{{ end }}
restartPolicy: Always
serviceAccountName: ""
status: {}

View File

@ -11,10 +11,11 @@ services:
- minio_data:/data
ports:
- "${MINIO_PORT}:9000"
- "9001:9001"
environment:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
command: server /data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s

View File

@ -63,7 +63,7 @@ services:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_BROWSER: "off"
command: server /data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s

View File

@ -1,5 +1,5 @@
{
"version": "1.1.32",
"version": "1.1.33-alpha.0",
"npmClient": "yarn",
"packages": [
"packages/*"

View File

@ -26,7 +26,7 @@
"build": "lerna run build",
"build:dev": "lerna run prebuild && tsc --build --watch --preserveWatchOutput",
"release": "lerna publish ${RELEASE_VERSION_TYPE:-patch} --yes --force-publish && yarn release:pro",
"release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop && yarn release:pro:develop",
"release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop --exact && yarn release:pro:develop",
"release:pro": "bash scripts/pro/release.sh",
"release:pro:develop": "bash scripts/pro/release.sh develop",
"restore": "yarn run clean && yarn run bootstrap && yarn run build",
@ -85,4 +85,4 @@
"install:pro": "bash scripts/pro/install.sh",
"dep:clean": "yarn clean && yarn bootstrap"
}
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/backend-core",
"version": "1.1.32",
"version": "1.1.33-alpha.0",
"description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
@ -20,7 +20,7 @@
"test:watch": "jest --watchAll"
},
"dependencies": {
"@budibase/types": "^1.1.32",
"@budibase/types": "1.1.33-alpha.0",
"@techpass/passport-openidconnect": "0.3.2",
"aws-sdk": "2.1030.0",
"bcrypt": "5.0.1",

View File

@ -18,6 +18,8 @@ const {
ssoCallbackUrl,
csrf,
internalApi,
adminOnly,
joiValidator,
} = require("./middleware")
const { invalidateUser } = require("./cache/user")
@ -173,4 +175,6 @@ module.exports = {
refreshOAuthToken,
updateUserOAuth,
ssoCallbackUrl,
adminOnly,
joiValidator,
}

View File

@ -11,6 +11,7 @@ export enum AutomationViewModes {
}
export enum ViewNames {
USER_BY_APP = "by_app",
USER_BY_EMAIL = "by_email2",
BY_API_KEY = "by_api_key",
USER_BY_BUILDERS = "by_builders",
@ -28,6 +29,7 @@ export const DeprecatedViews = {
export enum DocumentTypes {
USER = "us",
GROUP = "gr",
WORKSPACE = "workspace",
CONFIG = "config",
TEMPLATE = "template",

View File

@ -50,3 +50,8 @@ exports.getProdAppID = appId => {
const rest = split.join(APP_DEV_PREFIX)
return `${APP_PREFIX}${rest}`
}
exports.extractAppUUID = id => {
const split = id?.split("_") || []
return split.length ? split[split.length - 1] : null
}

View File

@ -102,6 +102,13 @@ exports.getPouch = (opts = {}) => {
}
}
if (opts.onDisk) {
POUCH_DB_DEFAULTS = {
prefix: undefined,
adapter: "leveldb",
}
}
if (opts.replication) {
const replicationStream = require("pouchdb-replication-stream")
PouchDB.plugin(replicationStream.plugin)

View File

@ -8,7 +8,7 @@ import { doWithDB, allDbs } from "./index"
import { getCouchInfo } from "./pouch"
import { getAppMetadata } from "../cache/appMetadata"
import { checkSlashesInUrl } from "../helpers"
import { isDevApp, isDevAppID } from "./conversions"
import { isDevApp, isDevAppID, getProdAppID } from "./conversions"
import { APP_PREFIX } from "./constants"
import * as events from "../events"
@ -107,6 +107,15 @@ export function getGlobalUserParams(globalId: any, otherProps: any = {}) {
}
}
export function getUsersByAppParams(appId: any, otherProps: any = {}) {
const prodAppId = getProdAppID(appId)
return {
...otherProps,
startkey: prodAppId,
endkey: `${prodAppId}${UNICODE_MAX}`,
}
}
/**
* Generates a template ID.
* @param ownerId The owner/user of the template, this could be global or a workspace level.
@ -115,6 +124,10 @@ export function generateTemplateID(ownerId: any) {
return `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}${newid()}`
}
export function generateAppUserID(prodAppId: string, userId: string) {
return `${prodAppId}${SEPARATOR}${userId}`
}
/**
* Gets parameters for retrieving templates. Owner ID must be specified, either global or a workspace level.
*/
@ -442,15 +455,29 @@ export const getPlatformUrl = async (opts = { tenantAware: true }) => {
export function pagination(
data: any[],
pageSize: number,
{ paginate, property } = { paginate: true, property: "_id" }
{
paginate,
property,
getKey,
}: {
paginate: boolean
property: string
getKey?: (doc: any) => string | undefined
} = {
paginate: true,
property: "_id",
}
) {
if (!paginate) {
return { data, hasNextPage: false }
}
const hasNextPage = data.length > pageSize
let nextPage = undefined
if (!getKey) {
getKey = (doc: any) => (property ? doc?.[property] : doc?._id)
}
if (hasNextPage) {
nextPage = property ? data[pageSize]?.[property] : data[pageSize]?._id
nextPage = getKey(data[pageSize])
}
return {
data: data.slice(0, pageSize),

View File

@ -56,6 +56,33 @@ exports.createNewUserEmailView = async () => {
await db.put(designDoc)
}
exports.createUserAppView = async () => {
const db = getGlobalDB()
let designDoc
try {
designDoc = await db.get("_design/database")
} catch (err) {
// no design doc, make one
designDoc = DesignDoc()
}
const view = {
// if using variables in a map function need to inject them before use
map: `function(doc) {
if (doc._id.startsWith("${DocumentTypes.USER}${SEPARATOR}") && doc.roles) {
for (let prodAppId of Object.keys(doc.roles)) {
let emitted = prodAppId + "${SEPARATOR}" + doc._id
emit(emitted, null)
}
}
}`,
}
designDoc.views = {
...designDoc.views,
[ViewNames.USER_BY_APP]: view,
}
await db.put(designDoc)
}
exports.createApiKeyView = async () => {
const db = getGlobalDB()
let designDoc
@ -106,6 +133,7 @@ exports.queryGlobalView = async (viewName, params, db = null) => {
[ViewNames.USER_BY_EMAIL]: exports.createNewUserEmailView,
[ViewNames.BY_API_KEY]: exports.createApiKeyView,
[ViewNames.USER_BY_BUILDERS]: exports.createUserBuildersView,
[ViewNames.USER_BY_APP]: exports.createUserAppView,
}
// can pass DB in if working with something specific
if (!db) {

View File

@ -37,6 +37,7 @@ module.exports = {
types,
errors: {
UsageLimitError: licensing.UsageLimitError,
FeatureDisabledError: licensing.FeatureDisabledError,
HTTPError: http.HTTPError,
},
getPublicError,

View File

@ -4,6 +4,7 @@ const type = "license_error"
const codes = {
USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded",
FEATURE_DISABLED: "feature_disabled",
}
const context = {
@ -12,6 +13,11 @@ const context = {
limitName: err.limitName,
}
},
[codes.FEATURE_DISABLED]: err => {
return {
featureName: err.featureName,
}
},
}
class UsageLimitError extends HTTPError {
@ -21,9 +27,17 @@ class UsageLimitError extends HTTPError {
}
}
class FeatureDisabledError extends HTTPError {
constructor(message, featureName) {
super(message, 400, codes.FEATURE_DISABLED, type)
this.featureName = featureName
}
}
module.exports = {
type,
codes,
context,
UsageLimitError,
FeatureDisabledError,
}

View File

@ -0,0 +1,64 @@
import { publishEvent } from "../events"
import {
Event,
UserGroup,
GroupCreatedEvent,
GroupDeletedEvent,
GroupUpdatedEvent,
GroupUsersAddedEvent,
GroupUsersDeletedEvent,
GroupAddedOnboardingEvent,
UserGroupRoles,
} from "@budibase/types"
export async function created(group: UserGroup, timestamp?: number) {
const properties: GroupCreatedEvent = {
groupId: group._id as string,
}
await publishEvent(Event.USER_GROUP_CREATED, properties, timestamp)
}
export async function updated(group: UserGroup) {
const properties: GroupUpdatedEvent = {
groupId: group._id as string,
}
await publishEvent(Event.USER_GROUP_UPDATED, properties)
}
export async function deleted(group: UserGroup) {
const properties: GroupDeletedEvent = {
groupId: group._id as string,
}
await publishEvent(Event.USER_GROUP_DELETED, properties)
}
export async function usersAdded(count: number, group: UserGroup) {
const properties: GroupUsersAddedEvent = {
count,
groupId: group._id as string,
}
await publishEvent(Event.USER_GROUP_USERS_ADDED, properties)
}
export async function usersDeleted(emails: string[], group: UserGroup) {
const properties: GroupUsersDeletedEvent = {
count: emails.length,
groupId: group._id as string,
}
await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties)
}
export async function createdOnboarding(groupId: string) {
const properties: GroupAddedOnboardingEvent = {
groupId: groupId,
onboarding: true,
}
await publishEvent(Event.USER_GROUP_ONBOARDING, properties)
}
export async function permissionsEdited(roles: UserGroupRoles) {
const properties: UserGroupRoles = {
...roles,
}
await publishEvent(Event.USER_GROUP_PERMISSIONS_EDITED, properties)
}

View File

@ -17,3 +17,4 @@ export * as user from "./user"
export * as view from "./view"
export * as installation from "./installation"
export * as backfill from "./backfill"
export * as group from "./group"

View File

@ -50,4 +50,5 @@ exports.getTenantFeatureFlags = tenantId => {
exports.FeatureFlag = {
LICENSING: "LICENSING",
GOOGLE_SHEETS: "GOOGLE_SHEETS",
USER_GROUPS: "USER_GROUPS",
}

View File

@ -3,6 +3,7 @@ const errorClasses = errors.errors
import * as events from "./events"
import * as migrations from "./migrations"
import * as users from "./users"
import * as roles from "./security/roles"
import * as accounts from "./cloud/accounts"
import * as installation from "./installation"
import env from "./environment"
@ -51,6 +52,7 @@ const core = {
installation,
errors,
logging,
roles,
...errorClasses,
}

View File

@ -15,11 +15,22 @@ export function logAlert(message: string, e?: any) {
console.error(`bb-alert: ${message} ${errorJson}`)
}
export function logAlertWithInfo(
message: string,
db: string,
id: string,
error: any
) {
message = `${message} - db: ${db} - doc: ${id} - error: `
logAlert(message, error)
}
export function logWarn(message: string) {
console.warn(`bb-warn: ${message}`)
}
export default {
logAlert,
logAlertWithInfo,
logWarn,
}

View File

@ -0,0 +1,9 @@
module.exports = async (ctx, next) => {
if (
!ctx.internal &&
(!ctx.user || !ctx.user.admin || !ctx.user.admin.global)
) {
ctx.throw(403, "Admin user only endpoint.")
}
return next()
}

View File

@ -9,7 +9,8 @@ const tenancy = require("./tenancy")
const internalApi = require("./internalApi")
const datasourceGoogle = require("./passport/datasource/google")
const csrf = require("./csrf")
const adminOnly = require("./adminOnly")
const joiValidator = require("./joi-validator")
module.exports = {
google,
oidc,
@ -25,4 +26,6 @@ module.exports = {
google: datasourceGoogle,
},
csrf,
adminOnly,
joiValidator,
}

View File

@ -0,0 +1,28 @@
function validate(schema, property) {
// Return a Koa middleware function
return (ctx, next) => {
if (!schema) {
return next()
}
let params = null
if (ctx[property] != null) {
params = ctx[property]
} else if (ctx.request[property] != null) {
params = ctx.request[property]
}
const { error } = schema.validate(params)
if (error) {
ctx.throw(400, `Invalid ${property} - ${error.message}`)
return
}
return next()
}
}
module.exports.body = schema => {
return validate(schema, "body")
}
module.exports.params = schema => {
return validate(schema, "params")
}

View File

@ -75,9 +75,11 @@ export const ObjectStore = (bucket: any) => {
s3ForcePathStyle: true,
signatureVersion: "v4",
apiVersion: "2006-03-01",
params: {
}
if (bucket) {
config.params = {
Bucket: sanitizeBucket(bucket),
},
}
}
if (env.MINIO_URL) {
config.endpoint = env.MINIO_URL
@ -292,6 +294,7 @@ export const uploadDirectory = async (
}
}
await Promise.all(uploads)
return files
}
exports.downloadTarballDirect = async (url: string, path: string) => {

View File

@ -76,7 +76,7 @@ function isBuiltin(role) {
/**
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
*/
function builtinRoleToNumber(id) {
exports.builtinRoleToNumber = id => {
const builtins = exports.getBuiltinRoles()
const MAX = Object.values(BUILTIN_IDS).length + 1
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
@ -104,7 +104,8 @@ exports.lowerBuiltinRoleID = (roleId1, roleId2) => {
if (!roleId2) {
return roleId1
}
return builtinRoleToNumber(roleId1) > builtinRoleToNumber(roleId2)
return exports.builtinRoleToNumber(roleId1) >
exports.builtinRoleToNumber(roleId2)
? roleId2
: roleId1
}

View File

@ -1,4 +1,9 @@
const { ViewNames } = require("./db/utils")
const {
ViewNames,
getUsersByAppParams,
getProdAppID,
generateAppUserID,
} = require("./db/utils")
const { queryGlobalView } = require("./db/views")
const { UNICODE_MAX } = require("./db/constants")
@ -13,12 +18,32 @@ exports.getGlobalUserByEmail = async email => {
throw "Must supply an email address to view"
}
const response = await queryGlobalView(ViewNames.USER_BY_EMAIL, {
return await queryGlobalView(ViewNames.USER_BY_EMAIL, {
key: email.toLowerCase(),
include_docs: true,
})
}
return response
exports.searchGlobalUsersByApp = async (appId, opts) => {
if (typeof appId !== "string") {
throw new Error("Must provide a string based app ID")
}
const params = getUsersByAppParams(appId, {
include_docs: true,
})
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
let response = await queryGlobalView(ViewNames.USER_BY_APP, params)
if (!response) {
response = []
}
return Array.isArray(response) ? response : [response]
}
exports.getGlobalUserByAppPage = (appId, user) => {
if (!user) {
return
}
return generateAppUserID(getProdAppID(appId), user._id)
}
/**

View File

@ -89,6 +89,14 @@ jest.spyOn(events.user, "passwordUpdated")
jest.spyOn(events.user, "passwordResetRequested")
jest.spyOn(events.user, "passwordReset")
jest.spyOn(events.group, "created")
jest.spyOn(events.group, "updated")
jest.spyOn(events.group, "deleted")
jest.spyOn(events.group, "usersAdded")
jest.spyOn(events.group, "usersDeleted")
jest.spyOn(events.group, "createdOnboarding")
jest.spyOn(events.group, "permissionsEdited")
jest.spyOn(events.serve, "servedBuilder")
jest.spyOn(events.serve, "servedApp")
jest.spyOn(events.serve, "servedAppPreview")

View File

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

View File

@ -4,7 +4,7 @@
["XXS", "--spectrum-alias-avatar-size-50"],
["XS", "--spectrum-alias-avatar-size-75"],
["S", "--spectrum-alias-avatar-size-200"],
["M", "--spectrum-alias-avatar-size-300"],
["M", "--spectrum-alias-avatar-size-400"],
["L", "--spectrum-alias-avatar-size-500"],
["XL", "--spectrum-alias-avatar-size-600"],
["XXL", "--spectrum-alias-avatar-size-700"],
@ -13,6 +13,19 @@
export let url = ""
export let disabled = false
export let initials = "JD"
const DefaultColor = "#3aab87"
$: color = getColor(initials)
const getColor = initials => {
if (!initials?.length) {
return DefaultColor
}
const code = initials[0].toLowerCase().charCodeAt(0)
const hue = ((code % 26) / 26) * 360
return `hsl(${hue}, 50%, 50%)`
}
</script>
{#if url}
@ -25,10 +38,11 @@
/>
{:else}
<div
class="spectrum-Avatar"
class:is-disabled={disabled}
style="width: var({sizes.get(size)}); height: var({sizes.get(
size
)}); font-size: calc(var({sizes.get(size)}) / 2)"
)}); font-size: calc(var({sizes.get(size)}) / 2); background: {color};"
>
{initials || ""}
</div>
@ -40,7 +54,6 @@
display: grid;
place-items: center;
font-weight: 600;
background: #3aab87;
border-radius: 50%;
overflow: hidden;
user-select: none;

View File

@ -0,0 +1,228 @@
<script>
import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css"
import { fly } from "svelte/transition"
import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside"
export let inputValue
export let dropdownValue
export let id = null
export let inputType = "text"
export let placeholder = "Choose an option or type"
export let disabled = false
export let readonly = false
export let updateOnChange = true
export let error = null
export let options = []
export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value")
export let isOptionSelected = () => false
const dispatch = createEventDispatcher()
let open = false
let focus = false
$: fieldText = getFieldText(dropdownValue, options, placeholder)
const getFieldText = (dropdownValue, options, placeholder) => {
// Always use placeholder if no value
if (dropdownValue == null || dropdownValue === "") {
return placeholder || "Choose an option or type"
}
// Wait for options to load if there is a value but no options
if (!options?.length) {
return ""
}
// Render the label if the selected option is found, otherwise raw value
const selected = options.find(
option => getOptionValue(option) === dropdownValue
)
return selected ? getOptionLabel(selected) : dropdownValue
}
const updateValue = newValue => {
if (readonly) {
return
}
dispatch("change", newValue)
}
const onFocus = () => {
if (readonly) {
return
}
focus = true
}
const onBlur = event => {
if (readonly) {
return
}
focus = false
updateValue(event.target.value)
}
const onInput = event => {
if (readonly || !updateOnChange) {
return
}
updateValue(event.target.value)
}
const updateValueOnEnter = event => {
if (readonly) {
return
}
if (event.key === "Enter") {
updateValue(event.target.value)
}
}
const onClick = () => {
dispatch("click")
if (readonly) {
return
}
open = true
}
const onPick = newValue => {
dispatch("pick", newValue)
open = false
}
const extractProperty = (value, property) => {
if (value && typeof value === "object") {
return value[property]
}
return value
}
</script>
<div
class="spectrum-InputGroup"
class:is-invalid={!!error}
class:is-disabled={disabled}
>
<div
class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-invalid={!!error}
class:is-disabled={disabled}
class:is-focused={focus}
>
{#if error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<input
{id}
on:click
on:blur
on:focus
on:input
on:keyup
on:blur={onBlur}
on:focus={onFocus}
on:input={onInput}
on:keyup={updateValueOnEnter}
value={inputValue || ""}
placeholder={placeholder || ""}
{disabled}
{readonly}
{inputType}
class="spectrum-Textfield-input spectrum-InputGroup-input"
/>
</div>
<div style="width: 30%">
<button
{id}
class="spectrum-Picker spectrum-Picker--sizeM override-borders"
{disabled}
class:is-open={open}
aria-haspopup="listbox"
on:mousedown={onClick}
>
<span class="spectrum-Picker-label">
<div>
{fieldText}
</div></span
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
{#if open}
<div
use:clickOutside={() => (open = false)}
transition:fly|local={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
>
<ul class="spectrum-Menu" role="listbox">
{#each options as option, idx}
<li
class="spectrum-Menu-item"
class:is-selected={isOptionSelected(getOptionValue(option, idx))}
role="option"
aria-selected="true"
tabindex="0"
on:click={() => onPick(getOptionValue(option, idx))}
>
<span class="spectrum-Menu-itemLabel">
{getOptionLabel(option, idx)}
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Checkmark100" />
</svg>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>
<style>
.spectrum-InputGroup {
min-width: 0;
width: 100%;
}
.spectrum-InputGroup-input {
border-right-width: 1px;
}
.spectrum-Textfield {
width: 100%;
}
.spectrum-Textfield-input {
width: 0;
}
.override-borders {
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
.spectrum-Popover {
max-height: 240px;
z-index: 999;
top: 100%;
}
</style>

View File

@ -13,6 +13,7 @@
export let readonly = false
export let autocomplete = false
export let sort = false
export let autoWidth = false
const dispatch = createEventDispatcher()
$: selectedLookupMap = getSelectedLookupMap(value)
@ -85,4 +86,5 @@
{getOptionValue}
onSelectOption={toggleOption}
{sort}
{autoWidth}
/>

View File

@ -0,0 +1,436 @@
<script>
import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css"
import { fly } from "svelte/transition"
import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside"
import Icon from "../../Icon/Icon.svelte"
import StatusLight from "../../StatusLight/StatusLight.svelte"
import Detail from "../../Typography/Detail.svelte"
import Search from "./Search.svelte"
export let primaryLabel = ""
export let primaryValue = null
export let id = null
export let placeholder = "Choose an option or type"
export let disabled = false
export let readonly = false
export let updateOnChange = true
export let error = null
export let secondaryOptions = []
export let primaryOptions = []
export let secondaryFieldText = ""
export let secondaryFieldIcon = ""
export let secondaryFieldColour = ""
export let getPrimaryOptionValue = option => option
export let getPrimaryOptionColour = () => null
export let getPrimaryOptionIcon = () => null
export let getSecondaryOptionLabel = option => option
export let getSecondaryOptionValue = option => option
export let getSecondaryOptionColour = () => null
export let onSelectOption = () => {}
export let autoWidth = false
export let autocomplete = false
export let isOptionSelected = () => false
export let isPlaceholder = false
export let placeholderOption = null
const dispatch = createEventDispatcher()
let primaryOpen = false
let secondaryOpen = false
let focus = false
let searchTerm = null
$: groupTitles = Object.keys(primaryOptions)
let iconData
const updateSearch = e => {
dispatch("search", e.detail)
}
const updateValue = newValue => {
if (readonly) {
return
}
dispatch("change", newValue)
}
const onClickSecondary = () => {
dispatch("click")
if (readonly) {
return
}
secondaryOpen = true
}
const onPickPrimary = newValue => {
dispatch("pickprimary", newValue)
primaryOpen = false
}
const onClearPrimary = () => {
dispatch("pickprimary", null)
primaryOpen = false
}
const onPickSecondary = newValue => {
dispatch("picksecondary", newValue)
secondaryOpen = false
}
const onBlur = event => {
if (readonly) {
return
}
focus = false
updateValue(event.target.value)
}
const onInput = event => {
if (readonly || !updateOnChange) {
return
}
updateValue(event.target.value)
}
const updateValueOnEnter = event => {
if (readonly) {
return
}
if (event.key === "Enter") {
updateValue(event.target.value)
}
}
</script>
<div
class="spectrum-InputGroup"
class:is-invalid={!!error}
class:is-disabled={disabled}
>
<div
class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-invalid={!!error}
class:is-disabled={disabled}
class:is-focused={focus}
class:is-full-width={!secondaryOptions.length}
>
{#if iconData}
<svg
width="16px"
height="16px"
class="spectrum-Icon iconPadding"
style="color: {iconData?.color}"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-{iconData?.icon}" />
</svg>
{/if}
<input
{id}
on:click={() => (primaryOpen = true)}
on:blur
on:focus
on:input
on:keyup
on:blur={onBlur}
on:input={onInput}
on:keyup={updateValueOnEnter}
value={primaryLabel || ""}
placeholder={placeholder || ""}
{disabled}
{readonly}
class="spectrum-Textfield-input spectrum-InputGroup-input"
class:labelPadding={iconData}
/>
{#if primaryValue}
<button
on:click={() => onClearPrimary()}
type="reset"
class="spectrum-ClearButton spectrum-Search-clearButton"
>
<svg
class="spectrum-Icon spectrum-UIIcon-Cross75"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Cross75" />
</svg>
</button>
{/if}
</div>
{#if primaryOpen}
<div
use:clickOutside={() => (primaryOpen = false)}
transition:fly|local={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
class:auto-width={autoWidth}
class:is-full-width={!secondaryOptions.length}
>
{#if autocomplete}
<Search
value={searchTerm}
on:change={event => updateSearch(event)}
{disabled}
placeholder="Search"
/>
{/if}
<ul class="spectrum-Menu" role="listbox">
{#if placeholderOption}
<li
class="spectrum-Menu-item placeholder"
class:is-selected={isPlaceholder}
role="option"
aria-selected="true"
tabindex="0"
on:click={() => onSelectOption(null)}
>
<span class="spectrum-Menu-itemLabel">{placeholderOption}</span>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Checkmark100" />
</svg>
</li>
{/if}
{#each groupTitles as title}
<div class="spectrum-Menu-item">
<Detail>{title}</Detail>
</div>
{#if primaryOptions}
{#each primaryOptions[title].data as option, idx}
<li
class="spectrum-Menu-item"
class:is-selected={isOptionSelected(
getPrimaryOptionValue(option, idx)
)}
role="option"
aria-selected="true"
tabindex="0"
on:click={() =>
onPickPrimary({
value: primaryOptions[title].getValue(option),
label: primaryOptions[title].getLabel(option),
})}
>
{#if primaryOptions[title].getIcon(option)}
<div
style="background: {primaryOptions[title].getColour(
option
)};"
class="circle"
>
<div>
<Icon
size="S"
name={primaryOptions[title].getIcon(option)}
/>
</div>
</div>
{:else if getPrimaryOptionColour(option, idx)}
<span class="option-left">
<StatusLight
square
color={getPrimaryOptionColour(option, idx)}
/>
</span>
{/if}
<span class="spectrum-Menu-itemLabel">
<span
class:spacing-group={primaryOptions[title].getIcon(option)}
>
{primaryOptions[title].getLabel(option)}
<span />
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Checkmark100" />
</svg>
{#if getPrimaryOptionIcon(option, idx) && getPrimaryOptionColour(option, idx)}
<span class="option-right">
<StatusLight
square
color={getPrimaryOptionColour(option, idx)}
/>
</span>
{/if}
</span>
</li>
{/each}
{/if}
{/each}
</ul>
</div>
{/if}
{#if secondaryOptions.length}
<div style="width: 30%">
<button
{id}
class="spectrum-Picker spectrum-Picker--sizeM override-borders"
{disabled}
class:is-open={secondaryOpen}
aria-haspopup="listbox"
on:mousedown={onClickSecondary}
>
{#if secondaryFieldIcon}
<span class="option-left">
<Icon name={secondaryFieldIcon} />
</span>
{:else if secondaryFieldColour}
<span class="option-left">
<StatusLight square color={secondaryFieldColour} />
</span>
{/if}
<span class:auto-width={autoWidth} class="spectrum-Picker-label">
{secondaryFieldText}
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
{#if secondaryOpen}
<div
use:clickOutside={() => (secondaryOpen = false)}
transition:fly|local={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
style="width: 30%"
>
<ul class="spectrum-Menu" role="listbox">
{#each secondaryOptions as option, idx}
<li
class="spectrum-Menu-item"
class:is-selected={isOptionSelected(
getSecondaryOptionValue(option, idx)
)}
role="option"
aria-selected="true"
tabindex="0"
on:click={() =>
onPickSecondary(getSecondaryOptionValue(option, idx))}
>
{#if getSecondaryOptionColour(option, idx)}
<span class="option-left">
<StatusLight
square
color={getSecondaryOptionColour(option, idx)}
/>
</span>
{/if}
<span class="spectrum-Menu-itemLabel">
{getSecondaryOptionLabel(option, idx)}
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Checkmark100" />
</svg>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</div>
<style>
.spacing-group {
margin-left: var(--spacing-m);
}
.spectrum-InputGroup {
min-width: 0;
width: 100%;
}
.spectrum-InputGroup :global(.spectrum-Search-input) {
border: none;
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.override-borders {
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
.spectrum-Popover {
max-height: 240px;
z-index: 999;
top: 100%;
}
.option-left {
padding-right: 8px;
}
.option-right {
padding-left: 8px;
}
.circle {
border-radius: 50%;
height: 28px;
color: white;
font-weight: bold;
line-height: 48px;
font-size: 1.2em;
width: 28px;
position: relative;
}
.circle > div {
position: absolute;
text-decoration: none;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
.iconPadding {
position: absolute;
top: 50%;
left: 10px;
transform: translateY(-50%);
color: silver;
margin-right: 10px;
}
.labelPadding {
padding-left: calc(1em + 10px + 8px);
}
.spectrum-Textfield.spectrum-InputGroup-textfield {
width: 70%;
}
.spectrum-Textfield.spectrum-InputGroup-textfield.is-full-width {
width: 100%;
}
.spectrum-Textfield.spectrum-InputGroup-textfield.is-full-width input {
border-right-width: thin;
}
.spectrum-Popover.spectrum-Popover--bottom.spectrum-Picker-popover.is-open {
width: 70%;
}
.spectrum-Popover.spectrum-Popover--bottom.spectrum-Picker-popover.is-open.is-full-width {
width: 100%;
}
.spectrum-Search-clearButton {
position: absolute;
}
</style>

View File

@ -17,7 +17,6 @@
export let autoWidth = false
export let autocomplete = false
export let sort = false
const dispatch = createEventDispatcher()
let open = false
$: fieldText = getFieldText(value, options, placeholder)

View File

@ -0,0 +1,55 @@
<script>
import Field from "./Field.svelte"
import InputDropdown from "./Core/InputDropdown.svelte"
import { createEventDispatcher } from "svelte"
export let inputValue = null
export let dropdownValue = null
export let inputType = "text"
export let label = null
export let labelPosition = "above"
export let placeholder = null
export let disabled = false
export let readonly = false
export let error = null
export let updateOnChange = true
export let quiet = false
export let dataCy
export let autofocus
export let options = []
const dispatch = createEventDispatcher()
const onPick = e => {
dropdownValue = e.detail
dispatch("pick", e.detail)
}
const onChange = e => {
inputValue = e.detail
dispatch("change", e.detail)
}
</script>
<Field {label} {labelPosition} {error}>
<InputDropdown
{dataCy}
{updateOnChange}
{error}
{disabled}
{readonly}
{inputValue}
{dropdownValue}
{placeholder}
{inputType}
{quiet}
{autofocus}
{options}
on:change={onChange}
on:pick={onPick}
on:click
on:input
on:blur
on:focus
on:keyup
/>
</Field>

View File

@ -14,7 +14,7 @@
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let sort = false
export let autoWidth = false
const dispatch = createEventDispatcher()
const onChange = e => {
value = e.detail
@ -33,6 +33,7 @@
{sort}
{getOptionLabel}
{getOptionValue}
{autoWidth}
on:change={onChange}
on:click
/>

View File

@ -0,0 +1,132 @@
<script>
import Field from "./Field.svelte"
import PickerDropdown from "./Core/PickerDropdown.svelte"
import { createEventDispatcher } from "svelte"
export let primaryValue = null
export let secondaryValue = null
export let inputType = "text"
export let label = null
export let labelPosition = "above"
export let secondaryPlaceholder = null
export let autocomplete
export let placeholder = null
export let disabled = false
export let readonly = false
export let error = null
export let updateOnChange = true
export let getSecondaryOptionLabel = option =>
extractProperty(option, "label")
export let getSecondaryOptionValue = option =>
extractProperty(option, "value")
export let getSecondaryOptionColour = () => {}
export let getSecondaryOptionIcon = () => {}
export let quiet = false
export let dataCy
export let autofocus
export let primaryOptions = []
export let secondaryOptions = []
export let searchTerm
let primaryLabel
let secondaryLabel
const dispatch = createEventDispatcher()
$: secondaryFieldText = getSecondaryFieldText(
secondaryValue,
secondaryOptions,
secondaryPlaceholder
)
$: secondaryFieldIcon = getSecondaryFieldAttribute(
getSecondaryOptionIcon,
secondaryValue,
secondaryOptions
)
$: secondaryFieldColour = getSecondaryFieldAttribute(
getSecondaryOptionColour,
secondaryValue,
secondaryOptions
)
const getSecondaryFieldAttribute = (getAttribute, value, options) => {
// Wait for options to load if there is a value but no options
if (!options?.length) {
return ""
}
const index = options.findIndex(
(option, idx) => getSecondaryOptionValue(option, idx) === value
)
return index !== -1 ? getAttribute(options[index], index) : null
}
const getSecondaryFieldText = (value, options, placeholder) => {
// Always use placeholder if no value
if (value == null || value === "") {
return placeholder || "Choose an option"
}
return getSecondaryFieldAttribute(getSecondaryOptionLabel, value, options)
}
const onPickPrimary = e => {
primaryLabel = e?.detail?.label || null
primaryValue = e?.detail?.value || null
dispatch("pickprimary", e?.detail?.value || {})
}
const onPickSecondary = e => {
secondaryValue = e.detail
dispatch("picksecondary", e.detail)
}
const extractProperty = (value, property) => {
if (value && typeof value === "object") {
return value[property]
}
return value
}
const updateSearchTerm = e => {
searchTerm = e.detail
}
</script>
<Field {label} {labelPosition} {error}>
<PickerDropdown
{searchTerm}
{autocomplete}
{dataCy}
{updateOnChange}
{error}
{disabled}
{readonly}
{placeholder}
{inputType}
{quiet}
{autofocus}
{primaryOptions}
{secondaryOptions}
{getSecondaryOptionLabel}
{getSecondaryOptionValue}
{getSecondaryOptionIcon}
{getSecondaryOptionColour}
{secondaryFieldText}
{secondaryFieldIcon}
{secondaryFieldColour}
{primaryValue}
{secondaryValue}
{primaryLabel}
{secondaryLabel}
on:pickprimary={onPickPrimary}
on:picksecondary={onPickSecondary}
on:search={updateSearchTerm}
on:click
on:input
on:blur
on:focus
on:keyup
/>
</Field>

View File

@ -0,0 +1,177 @@
<script>
//import { createEventDispatcher } from "svelte"
import "@spectrum-css/popover/dist/index-vars.css"
import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition"
import Icon from "../Icon/Icon.svelte"
import { createEventDispatcher } from "svelte"
export let value
export let size = "M"
export let alignRight = false
let open = false
const dispatch = createEventDispatcher()
const iconList = [
{
label: "Icons",
icons: [
"Apps",
"Actions",
"ConversionFunnel",
"App",
"Briefcase",
"Money",
"ShoppingCart",
"Form",
"Help",
"Monitoring",
"Sandbox",
"Project",
"Organisations",
"Magnify",
"Launch",
"Car",
"Camera",
"Bug",
"Channel",
"Calculator",
"Calendar",
"GraphDonut",
"GraphBarHorizontal",
"Demographic",
],
},
]
const onChange = value => {
dispatch("change", value)
open = false
}
</script>
<div class="container">
<div class="preview size--{size || 'M'}" on:click={() => (open = true)}>
<div
class="fill"
style={value ? `background: ${value};` : ""}
class:placeholder={!value}
>
<Icon name={value || "UserGroup"} />
</div>
</div>
{#if open}
<div
use:clickOutside={() => (open = false)}
transition:fly={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
class:spectrum-Popover--align-right={alignRight}
>
{#each iconList as icon}
<div class="category">
<div class="heading">{icon.label}</div>
<div class="icons">
{#each icon.icons as icon}
<div
on:click={() => {
onChange(icon)
}}
>
<Icon name={icon} />
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.container {
position: relative;
}
.preview {
width: 32px;
height: 32px;
position: relative;
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-400);
}
.preview:hover {
cursor: pointer;
}
.fill {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: grid;
place-items: center;
}
.size--S {
width: 20px;
height: 20px;
}
.size--M {
width: 32px;
height: 32px;
}
.size--L {
width: 48px;
height: 48px;
}
.spectrum-Popover {
width: 210px;
z-index: 999;
top: 100%;
padding: var(--spacing-l) var(--spacing-xl);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.spectrum-Popover--align-right {
right: 0;
}
.icons {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr;
gap: var(--spacing-m);
}
.heading {
font-size: var(--font-size-s);
font-weight: 600;
letter-spacing: 0.14px;
flex: 1 1 auto;
text-transform: uppercase;
grid-column: 1 / 5;
margin-bottom: var(--spacing-s);
}
.icon {
height: 16px;
width: 16px;
border-radius: 100%;
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300);
position: relative;
}
.icon:hover {
cursor: pointer;
box-shadow: 0 0 2px 2px var(--spectrum-global-color-gray-300);
}
.custom {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--spacing-m);
margin-right: var(--spacing-xs);
}
.spectrum-wrapper {
background-color: transparent;
}
</style>

View File

@ -1,53 +0,0 @@
<script>
import { View } from "svench";
import DetailSummary from "./DetailSummary.svelte";
</script>
<svelte:head>
<link href="https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css" rel="stylesheet">
</svelte:head>
<style>
div {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
width: 120px;
}
</style>
<View name="default">
<div>
<DetailSummary name="Category 1">
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
</DetailSummary>
<DetailSummary name="Category 2">
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
</DetailSummary>
</div>
</View>
<View name="thin">
<div>
<DetailSummary thin name="Category 1">
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
</DetailSummary>
<DetailSummary thin name="Category 2">
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
</DetailSummary>
</div>
</View>

View File

@ -0,0 +1,28 @@
<script>
import Detail from "../Typography/Detail.svelte"
export let title = null
</script>
<div>
{#if title}
<div class="title">
<Detail>{title}</Detail>
</div>
{/if}
<div class="list-items">
<slot />
</div>
</div>
<style>
.title {
margin-bottom: 6px;
}
.list-items {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>

View File

@ -0,0 +1,92 @@
<script>
import Body from "../Typography/Body.svelte"
import Icon from "../Icon/Icon.svelte"
import Label from "../Label/Label.svelte"
import Avatar from "../Avatar/Avatar.svelte"
export let icon = null
export let iconBackground = null
export let avatar = false
export let title = null
export let subtitle = null
$: initials = avatar ? title?.[0] : null
</script>
<div class="list-item">
<div class="left">
{#if icon}
<div class="icon" style="background: {iconBackground || `transparent`};">
<Icon name={icon} size="S" color={iconBackground ? "white" : null} />
</div>
{/if}
{#if avatar}
<Avatar {initials} />
{/if}
{#if title}
<Body>{title}</Body>
{/if}
{#if subtitle}
<Label>{subtitle}</Label>
{/if}
</div>
<div class="right">
<slot />
</div>
</div>
<style>
.list-item {
padding: 0 16px;
height: 56px;
background: var(--spectrum-alias-background-color-tertiary);
display: flex;
flex-direction: row;
justify-content: space-between;
border: 1px solid var(--spectrum-global-color-gray-300);
}
.list-item:not(:first-child) {
border-top: none;
}
.list-item:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.list-item:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.left,
.right {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-xl);
}
.left {
width: 0;
flex: 1 1 auto;
}
.right {
flex: 0 0 auto;
}
.list-item :global(.spectrum-Icon),
.list-item :global(.spectrum-Avatar) {
flex: 0 0 auto;
}
.list-item :global(.spectrum-Body) {
color: var(--spectrum-global-color-gray-900);
}
.list-item :global(.spectrum-Body) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon {
width: var(--spectrum-alias-avatar-size-400);
height: var(--spectrum-alias-avatar-size-400);
display: grid;
place-items: center;
border-radius: 50%;
}
</style>

View File

@ -37,6 +37,7 @@
export let autoSortColumns = true
export let compact = false
export let customPlaceholder = false
export let showHeaderBorder = true
export let placeholderText = "No rows found"
const dispatch = createEventDispatcher()
@ -286,6 +287,7 @@
<div class="spectrum-Table-head">
{#if showEditColumn}
<div
class:noBorderHeader={!showHeaderBorder}
class="spectrum-Table-headCell spectrum-Table-headCell--divider spectrum-Table-headCell--edit"
>
{#if allowSelectRows}
@ -301,6 +303,7 @@
{#each fields as field}
<div
class="spectrum-Table-headCell"
class:noBorderHeader={!showHeaderBorder}
class:spectrum-Table-headCell--alignCenter={schema[field]
.align === "Center"}
class:spectrum-Table-headCell--alignRight={schema[field].align ===
@ -348,6 +351,7 @@
<div class="spectrum-Table-row">
{#if showEditColumn}
<div
class:noBorderCheckbox={!showHeaderBorder}
class="spectrum-Table-cell spectrum-Table-cell--divider spectrum-Table-cell--edit"
on:click={e => {
toggleSelectRow(row)
@ -481,6 +485,18 @@
.spectrum-Table-headCell:last-of-type {
border-right: var(--table-border);
}
.noBorderHeader {
border-top: none !important;
border-right: none !important;
border-left: none !important;
}
.noBorderCheckbox {
border-top: none !important;
border-right: none !important;
}
.spectrum-Table-headCell--alignCenter {
justify-content: center;
}
@ -499,7 +515,7 @@
z-index: 3;
}
.spectrum-Table-headCell .title {
overflow: hidden;
overflow: visible;
text-overflow: ellipsis;
}
.spectrum-Table-headCell:hover .spectrum-Table-editIcon {
@ -562,7 +578,7 @@
gap: 4px;
border-bottom: 1px solid var(--spectrum-alias-border-color-mid);
background-color: var(--table-bg);
z-index: 1;
z-index: auto;
}
.spectrum-Table-cell--divider {
padding-right: var(--cell-padding);
@ -570,6 +586,7 @@
.spectrum-Table-cell--divider + .spectrum-Table-cell {
padding-left: var(--cell-padding);
}
.spectrum-Table-cell--edit {
position: sticky;
left: 0;

View File

@ -23,6 +23,8 @@ export { default as Icon, directions } from "./Icon/Icon.svelte"
export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte"
export { default as InputDropdown } from "./Form/InputDropdown.svelte"
export { default as PickerDropdown } from "./Form/PickerDropdown.svelte"
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
export { default as Popover } from "./Popover/Popover.svelte"
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"
@ -58,12 +60,15 @@ export { default as Pagination } from "./Pagination/Pagination.svelte"
export { default as Badge } from "./Badge/Badge.svelte"
export { default as StatusLight } from "./StatusLight/StatusLight.svelte"
export { default as ColorPicker } from "./ColorPicker/ColorPicker.svelte"
export { default as IconPicker } from "./IconPicker/IconPicker.svelte"
export { default as InlineAlert } from "./InlineAlert/InlineAlert.svelte"
export { default as Banner } from "./Banner/Banner.svelte"
export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte"
export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte"
export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte"
export { default as RichTextField } from "./Form/RichTextField.svelte"
export { default as List } from "./List/List.svelte"
export { default as ListItem } from "./List/ListItem.svelte"
export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
export { default as Slider } from "./Form/Slider.svelte"
@ -71,6 +76,7 @@ export { default as Slider } from "./Form/Slider.svelte"
// Renderers
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
export { default as CodeRenderer } from "./Table/CodeRenderer.svelte"
export { default as InternalRenderer } from "./Table/InternalRenderer.svelte"
// Typography
export { default as Body } from "./Typography/Body.svelte"

View File

@ -28,6 +28,46 @@
chalk "^2.0.0"
js-tokens "^4.0.0"
"@jridgewell/gen-mapping@^0.3.0":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9"
integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==
dependencies:
"@jridgewell/set-array" "^1.0.1"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/trace-mapping" "^0.3.9"
"@jridgewell/resolve-uri@^3.0.3":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
"@jridgewell/set-array@^1.0.1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
"@jridgewell/source-map@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb"
integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==
dependencies:
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@jridgewell/sourcemap-codec@^1.4.10":
version "1.4.14"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
"@jridgewell/trace-mapping@^0.3.9":
version "0.3.14"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed"
integrity sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==
dependencies:
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@rollup/plugin-commonjs@^16.0.0":
version "16.0.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-16.0.0.tgz#169004d56cd0f0a1d0f35915d31a036b0efe281f"
@ -340,6 +380,11 @@ acorn@^7.3.1:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
acorn@^8.5.0:
version "8.7.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
alphanum-sort@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
@ -447,9 +492,9 @@ browserslist@^4.0.0:
node-releases "^1.1.71"
buffer-from@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
builtin-modules@^3.1.0:
version "3.2.0"
@ -2372,16 +2417,15 @@ simple-swizzle@^0.2.2:
dependencies:
is-arrayish "^0.3.1"
"source-map-fast@npm:source-map@0.7.3", source-map@~0.7.2:
name source-map-fast
"source-map-fast@npm:source-map@0.7.3":
version "0.7.3"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
source-map-support@~0.5.19:
version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
source-map-support@~0.5.20:
version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
dependencies:
buffer-from "^1.0.0"
source-map "^0.6.0"
@ -2485,9 +2529,9 @@ svelte-portal@^1.0.0:
integrity sha512-nHf+DS/jZ6jjnZSleBMSaZua9JlG5rZv9lOGKgJuaZStfevtjIlUJrkLc3vbV8QdBvPPVmvcjTlazAzfKu0v3Q==
svelte@^3.38.2:
version "3.38.2"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.38.2.tgz#55e5c681f793ae349b5cc2fe58e5782af4275ef5"
integrity sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg==
version "3.49.0"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.49.0.tgz#5baee3c672306de1070c3b7888fc2204e36a4029"
integrity sha512-+lmjic1pApJWDfPCpUUTc1m8azDqYCG1JN9YEngrx/hUyIcFJo6VZhj0A1Ai0wqoHcEIuQy+e9tk+4uDgdtsFA==
svgo@^1.0.0:
version "1.3.2"
@ -2509,13 +2553,14 @@ svgo@^1.0.0:
util.promisify "~1.0.0"
terser@^5.0.0:
version "5.6.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.6.1.tgz#a48eeac5300c0a09b36854bf90d9c26fb201973c"
integrity sha512-yv9YLFQQ+3ZqgWCUk+pvNJwgUTdlIxUk1WTN+RnaFJe2L7ipG2csPT0ra2XRm7Cs8cxN7QXmK1rFzEwYEQkzXw==
version "5.14.2"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.2.tgz#9ac9f22b06994d736174f4091aa368db896f1c10"
integrity sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==
dependencies:
"@jridgewell/source-map" "^0.3.2"
acorn "^8.5.0"
commander "^2.20.0"
source-map "~0.7.2"
source-map-support "~0.5.19"
source-map-support "~0.5.20"
timsort@^0.3.0:
version "0.3.0"
@ -2580,7 +2625,7 @@ unquote@~1.1.1:
util-deprecate@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
util.promisify@~1.0.0:
version "1.0.1"

View File

@ -17,16 +17,15 @@ filterTests(['all'], () => {
it("should add form with multi select picker, containing 5 options", () => {
cy.navigateToFrontend()
// Add data provider
cy.get(interact.CATEGORY_DATA, { timeout: 500 }).click()
cy.get(interact.COMPONENT_DATA_PROVIDER).click()
cy.searchAndAddComponent("Data Provider")
cy.get(interact.DATASOURCE_PROP_CONTROL).click()
cy.get(interact.DROPDOWN).contains("Multi Data").click()
// Add Form with schema to match table
cy.addComponent("Form", "Form")
cy.searchAndAddComponent("Form")
cy.get(interact.DATASOURCE_PROP_CONTROL).click()
cy.get(interact.DROPDOWN).contains("Multi Data").click()
// Add multi-select picker to form
cy.addComponent("Form", "Multi-select Picker").then(componentId => {
cy.searchAndAddComponent("Multi-select Picker").then(componentId => {
cy.get(interact.DATASOURCE_FIELD_CONTROL).type("Test Data").type("{enter}")
cy.wait(1000)
cy.getComponent(componentId).contains("Choose some options").click()

View File

@ -10,15 +10,13 @@ filterTests(['all'], () => {
it("should add Radio Buttons options picker on form, add data, and confirm", () => {
cy.navigateToFrontend()
cy.wait(500)
cy.addComponent("Form", "Form")
cy.addComponent("Form", "Options Picker").then((componentId) => {
// Provide field setting
cy.searchAndAddComponent("Form")
cy.searchAndAddComponent("Options Picker").then((componentId) => {
// Provide field setting
cy.get(interact.DATASOURCE_FIELD_CONTROL).type("1")
// Open dropdown and select Radio buttons
cy.get(interact.OPTION_TYPE_PROP_CONTROL).click().then(() => {
cy.get(interact.SPECTRUM_POPOVER).contains('Radio buttons')
.wait(500)
.click()
})
const radioButtonsTotal = 3
@ -32,8 +30,8 @@ filterTests(['all'], () => {
const addRadioButtonData = (totalRadioButtons) => {
cy.get(interact.OPTION_SOURCE_PROP_CONROL).click().then(() => {
cy.get(interact.SPECTRUM_POPOVER).contains('Custom')
.wait(500)
.click()
.wait(1000)
})
cy.addCustomSourceOptions(totalRadioButtons)
}

View File

@ -19,9 +19,14 @@ filterTests(["smoke", "all"], () => {
cy.wait(500)
// Reset password
cy.get(".spectrum-ActionButton-label", { timeout: 2000 }).contains("Force password reset").click({ force: true })
cy.get(".title").within(() => {
cy.get(interact.SPECTRUM_ICON).click({ force: true })
})
cy.get(interact.SPECTRUM_MENU).within(() => {
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Force Password Reset").click({ force: true })
})
cy.get(".spectrum-Dialog-grid")
cy.get(interact.SPECTRUM_DIALOG_GRID)
.find(interact.SPECTRUM_TEXTFIELD_INPUT).invoke('val').as('pwd')
cy.get(interact.SPECTRUM_BUTTON).contains("Reset password").click({ force: true })
@ -39,23 +44,14 @@ filterTests(["smoke", "all"], () => {
cy.logoutNoAppGrid()
})
it("should verify Admin Portal", () => {
xit("should verify Admin Portal", () => {
cy.login()
cy.contains("Users").click()
cy.contains("bbuser").click()
// Enable Development & Administration access
cy.wait(500)
for (let i = 4; i < 6; i++) {
cy.get(interact.FIELD).eq(i).within(() => {
cy.get(interact.SPECTRUM_SWITCH_INPUT).click({ force: true })
cy.get(interact.SPECTRUM_SWITCH_INPUT).should('be.enabled')
})
}
// Configure user role
cy.setUserRole("bbuser", "Admin")
bbUserLogin()
// Verify available options for Admin portal
cy.get(".spectrum-SideNav")
cy.get(interact.SPECTRUM_SIDENAV)
.should('contain', 'Apps')
//.and('contain', 'Usage')
.and('contain', 'Users')
@ -72,13 +68,7 @@ filterTests(["smoke", "all"], () => {
it("should verify Development Portal", () => {
// Only Development access should be enabled
cy.login()
cy.contains("Users").click()
cy.contains("bbuser").click()
cy.wait(500)
cy.get(interact.FIELD).eq(5).within(() => {
cy.get(interact.SPECTRUM_SWITCH_INPUT).click({ force: true })
})
cy.setUserRole("bbuser", "Developer")
bbUserLogin()
// Verify available options for Admin portal
@ -99,13 +89,7 @@ filterTests(["smoke", "all"], () => {
it("should verify Standard Portal", () => {
// Development access should be disabled (Admin access is already disabled)
cy.login()
cy.contains("Users").click()
cy.contains("bbuser").click()
cy.wait(500)
cy.get(interact.FIELD).eq(4).within(() => {
cy.get(interact.SPECTRUM_SWITCH_INPUT).click({ force: true })
})
cy.setUserRole("bbuser", "App User")
bbUserLogin()
// Verify Standard Portal

View File

@ -15,25 +15,16 @@ filterTests(["smoke", "all"], () => {
cy.get(interact.SPECTRUM_TABLE).should("contain", "bbuser")
})
it("should confirm basic permission for a New User", () => {
// Basic permission = development & administraton disabled
it("should confirm App User role for a New User", () => {
cy.contains("bbuser").click()
// Confirm development and admin access are disabled
for (let i = 4; i < 6; i++) {
cy.wait(500)
cy.get(interact.FIELD).eq(i).within(() => {
//cy.get(interact.SPECTRUM_SWITCH_INPUT).should('be.disabled')
cy.get(".spectrum-Switch-switch").should('not.be.checked')
})
}
// Existing apps appear within the No Access table
cy.get(interact.SPECTRUM_TABLE, { timeout: 500 }).eq(1).should("not.contain", "No rows found")
// Configure roles table should not contain apps
cy.get(interact.SPECTRUM_TABLE).eq(0).contains("No rows found")
cy.get(".spectrum-Form-itemField").eq(2).should('contain', 'App User')
// User should not have app access
cy.get(interact.LIST_ITEMS, { timeout: 500 }).should("contain", "No apps")
})
if (Cypress.env("TEST_ENV")) {
it("should assign role types", () => {
xit("should assign role types", () => {
// 3 apps minimum required - to assign an app to each role type
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body")
@ -57,6 +48,7 @@ filterTests(["smoke", "all"], () => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000})
cy.get(interact.SPECTRUM_SIDENAV).contains("Users").click()
cy.get(interact.SPECTRUM_TABLE, { timeout: 1000 }).contains("bbuser").click()
cy.get(interact.SPECTRUM_HEADING).contains("bbuser", { timeout: 2000})
for (let i = 0; i < 3; i++) {
cy.get(interact.SPECTRUM_TABLE, { timeout: 3000})
.eq(1)
@ -95,7 +87,7 @@ filterTests(["smoke", "all"], () => {
})
})
it("should unassign role types", () => {
xit("should unassign role types", () => {
// Set each app within Configure roles table to 'No Access'
cy.get(interact.SPECTRUM_TABLE)
.eq(0)
@ -124,7 +116,7 @@ filterTests(["smoke", "all"], () => {
})
}
it("should enable Developer access and verify application access", () => {
xit("should enable Developer access and verify application access", () => {
// Enable Developer access
cy.get(interact.FIELD)
.eq(4)
@ -156,7 +148,7 @@ filterTests(["smoke", "all"], () => {
})
})
it("should disable Developer access and verify application access", () => {
xit("should disable Developer access and verify application access", () => {
// Disable Developer access
cy.get(interact.FIELD)
.eq(4)
@ -174,12 +166,12 @@ filterTests(["smoke", "all"], () => {
it("Should edit user details within user details page", () => {
// Add First name
cy.get(interact.FIELD, { timeout: 1000 }).eq(2).within(() => {
cy.get(interact.FIELD, { timeout: 1000 }).eq(0).within(() => {
cy.wait(500)
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).wait(500).clear().click().type("bb")
})
// Add Last name
cy.get(interact.FIELD, { timeout: 1000 }).eq(3).within(() => {
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
cy.wait(500)
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).click().wait(500).clear().type("test")
})
@ -188,16 +180,21 @@ filterTests(["smoke", "all"], () => {
cy.reload()
// Confirm details have been saved
cy.get(interact.FIELD, { timeout: 1000 }).eq(2).within(() => {
cy.get(interact.FIELD, { timeout: 1000 }).eq(0).within(() => {
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', "bb")
})
cy.get(interact.FIELD, { timeout: 1000 }).eq(3).within(() => {
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).should('have.value', "test")
})
})
it("should reset the users password", () => {
cy.get(interact.REGENERATE, { timeout: 500 }).contains("Force password reset").click({ force: true })
cy.get(".title").within(() => {
cy.get(interact.SPECTRUM_ICON).click({ force: true })
})
cy.get(interact.SPECTRUM_MENU).within(() => {
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Force Password Reset").click({ force: true })
})
// Reset password modal
cy.get(interact.SPECTRUM_DIALOG_GRID)

View File

@ -19,10 +19,10 @@ filterTests(["smoke", "all"], () => {
cy.contains("Users").click()
cy.contains("test@test.com").click()
cy.get(interact.FIELD, { timeout: 1000 }).eq(2).within(() => {
cy.get(interact.FIELD, { timeout: 1000 }).eq(0).within(() => {
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', fname)
})
cy.get(interact.FIELD).eq(3).within(() => {
cy.get(interact.FIELD).eq(1).within(() => {
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', lname)
})
})

View File

@ -205,7 +205,7 @@ filterTests(["all"], () => {
cy.navigateToFrontend()
cy.addComponent("Elements", "Headline").then(componentId => {
cy.searchAndAddComponent("Headline").then(componentId => {
cy.getComponent(componentId).should("exist")
})

View File

@ -10,15 +10,10 @@ filterTests(['smoke', 'all'], () => {
it("should disable the autogenerated screen options if no sources are available", () => {
cy.createApp("First Test App", false)
cy.closeModal();
cy.contains("Design").click()
cy.get(interact.LABEL_ADD_CIRCLE).click()
cy.get(interact.SPECTRUM_MODAL).within(() => {
cy.get(interact.ITEM_DISABLED).contains("Autogenerated screens")
cy.get(interact.CONFIRM_WRAP_SPE_BUTTON).should('be.disabled')
})
cy.navigateToAutogeneratedModal()
cy.get(interact.CONFIRM_WRAP_SPE_BUTTON).should('be.disabled')
cy.deleteAllApps()
});
@ -45,25 +40,25 @@ filterTests(['smoke', 'all'], () => {
// Create Autogenerated screens from the internal table
cy.createDatasourceScreen(["Cypress Tests"])
// Confirm screens have been auto generated
cy.get(interact.NAV_ITEMS_CONTAINER).contains("cypress-tests").click({ force: true })
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'cypress-tests/:id')
cy.get(interact.BODY).should('contain', "cypress-tests")
.and('contain', 'cypress-tests/:id')
.and('contain', 'cypress-tests/new/row')
})
it("should generate multiple internal table screens at once", () => {
// Create a second internal table
const initialTable = "Cypress Tests"
const secondTable = "Table Two"
// Create a second internal table
cy.createTable(secondTable)
// Create Autogenerated screens from the internal tables
cy.createDatasourceScreen([initialTable, secondTable])
// Confirm screens have been auto generated
cy.get(interact.NAV_ITEMS_CONTAINER).contains("cypress-tests").click({ force: true })
// Previously generated tables are suffixed with numbers - as expected
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'cypress-tests-2/:id')
cy.get(interact.BODY).should('contain', 'cypress-tests-2')
.and('contain', 'cypress-tests-2/:id')
.and('contain', 'cypress-tests-2/new/row')
cy.get(interact.NAV_ITEMS_CONTAINER).contains("table-two").click()
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'table-two/:id')
.and('contain', 'table-two')
.and('contain', 'table-two/:id')
.and('contain', 'table-two/new/row')
})
@ -73,17 +68,17 @@ filterTests(['smoke', 'all'], () => {
cy.createTable("Table Four")
cy.createDatasourceScreen(["Table Three", "Table Four"], "Admin")
cy.get(interact.NAV_ITEMS_CONTAINER).contains("table-three").click()
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'table-three/:id')
// Filter screens to Admin
cy.filterScreensAccessLevel('Admin')
cy.get(interact.BODY).should('contain', 'table-three')
.and('contain', 'table-three/:id')
.and('contain', 'table-three/new/row')
cy.get(interact.NAV_ITEMS_CONTAINER).contains("table-four").click()
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'table-four/:id')
.and('contain', 'table-four')
.and('contain', 'table-four/:id')
.and('contain', 'table-four/new/row')
//The access level should now be set to admin. Previous screens should be filtered.
cy.get(interact.NAV_ITEMS_CONTAINER).contains("table-two").should('not.exist')
cy.get(interact.NAV_ITEMS_CONTAINER).contains("cypress-tests").should('not.exist')
.and('not.contain', 'table-two')
.and('not.contain', 'cypress-tests')
})
if (Cypress.env("TEST_ENV")) {
@ -96,8 +91,8 @@ filterTests(['smoke', 'all'], () => {
// Create Autogenerated screens from a MySQL table - MySQL contains books table
cy.createDatasourceScreen(["books"])
cy.get(interact.NAV_ITEMS_CONTAINER).contains("books").click()
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'books/:id')
cy.get(interact.BODY).should('contain', 'books')
.and('contain', 'books/:id')
.and('contain', 'books/new/row')
})
}

View File

@ -13,7 +13,7 @@ filterTests(['smoke', 'all'], () => {
it("should show the new user UI/UX", () => {
cy.visit(`${Cypress.config().baseUrl}/builder/portal/apps/create`, { timeout: 5000 }) //added /portal/apps/create
cy.wait(1000)
cy.get(interact.CREATE_APP_BUTTON).contains('Start from scratch').should("exist")
cy.get(interact.CREATE_APP_BUTTON, { timeout: 10000 }).contains('Start from scratch').should("exist")
cy.get(interact.TEMPLATE_CATEGORY_FILTER).should("exist")
cy.get(interact.TEMPLATE_CATEGORY).should("exist")
@ -100,24 +100,18 @@ filterTests(['smoke', 'all'], () => {
})
it("should create the first application from scratch, using the users first name as the default app name", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 })
cy.updateUserInformation("Ted", "Userman")
cy.createApp("", false)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.applicationInAppTable("Teds app")
cy.deleteApp("Teds app")
//Accomodate names that end in 'S'
// Accomodate names that end in 'S'
cy.updateUserInformation("Chris", "Userman")
cy.createApp("", false)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.applicationInAppTable("Chris app")
cy.deleteApp("Chris app")

View File

@ -9,13 +9,13 @@ filterTests(['smoke', 'all'], () => {
})
it("should add a current user binding", () => {
cy.addComponent("Elements", "Paragraph").then(() => {
cy.searchAndAddComponent("Paragraph").then(() => {
addSettingBinding("text", "Current User._id")
})
})
it("should handle an invalid binding", () => {
cy.addComponent("Elements", "Paragraph").then(componentId => {
cy.searchAndAddComponent("Paragraph").then(componentId => {
// Cypress needs to escape curly brackets
cy.get("[data-cy=setting-text] input")
.type("{{}{{}{{} Current User._id {}}{}}")
@ -27,7 +27,7 @@ filterTests(['smoke', 'all'], () => {
xit("should add a URL param binding", () => {
const paramName = "foo"
cy.createScreen(`/test/:${paramName}`)
cy.addComponent("Elements", "Paragraph").then(componentId => {
cy.searchAndAddComponent("Paragraph").then(componentId => {
addSettingBinding("text", `URL.${paramName}`)
// The builder preview pages don't have a real URL, so all we can do
// is check that we were able to bind to the property, and that the
@ -37,7 +37,7 @@ filterTests(['smoke', 'all'], () => {
})
it("should add a binding with a handlebars helper", () => {
cy.addComponent("Elements", "Paragraph").then(componentId => {
cy.searchAndAddComponent("Paragraph").then(componentId => {
// Cypress needs to escape curly brackets
cy.get("[data-cy=setting-text] input")
.type("{{}{{} add 1 2 {}}{}}")

View File

@ -31,13 +31,13 @@ filterTests(["all"], () => {
}
it("should add a container", () => {
cy.addComponent("Layout", "Container").then(componentId => {
cy.searchAndAddComponent("Container").then(componentId => {
cy.getComponent(componentId).should("exist")
})
})
it("should add a headline", () => {
cy.addComponent("Elements", "Headline").then(componentId => {
cy.searchAndAddComponent("Headline").then(componentId => {
headlineId = componentId
cy.getComponent(headlineId).should("exist")
})
@ -63,11 +63,11 @@ filterTests(["all"], () => {
})
it("should create a form and reset to match schema", () => {
cy.addComponent("Form", "Form").then(() => {
cy.searchAndAddComponent("Form").then(() => {
cy.get("[data-cy=setting-dataSource]").contains("Custom").click()
cy.get(interact.DROPDOWN).contains("dog").click()
cy.wait(500)
cy.addComponent("Form", "Field Group").then(fieldGroupId => {
cy.searchAndAddComponent("Field Group").then(fieldGroupId => {
cy.contains("Update form fields").click()
cy.get(".spectrum-Modal")
.get(".confirm-wrap .spectrum-Button")
@ -88,7 +88,7 @@ filterTests(["all"], () => {
})
it("deletes a component", () => {
cy.addComponent("Elements", "Paragraph").then(componentId => {
cy.searchAndAddComponent("Paragraph").then(componentId => {
cy.get("[data-cy=setting-_instanceName] input").type(componentId).blur()
cy.get(
".nav-items-container .nav-item.selected .actions > div > .icon"
@ -104,7 +104,7 @@ filterTests(["all"], () => {
})
it("should clear the iframe place holder when a form field has been set", () => {
cy.addComponent("Form", "Form").then(formId => {
cy.searchAndAddComponent("Form").then(formId => {
//For deletion
cy.get("[data-cy=setting-_instanceName] input")
.clear()
@ -123,10 +123,7 @@ filterTests(["all"], () => {
const testFieldFocusOnCreate = componentLabel => {
cy.log("Adding: " + componentLabel)
return cy.addComponent("Form", componentLabel).then(componentId => {
cy.getComponent(componentId)
.find(".component-placeholder")
.should("exist")
return cy.searchAndAddComponent(componentLabel).then(componentId => {
cy.get("[data-cy=setting-field] button.spectrum-Picker").click()
//Click the first appropriate field. They are filtered by type
@ -157,7 +154,7 @@ filterTests(["all"], () => {
})
it("should populate the provider for charts with a data provider in its path", () => {
cy.addComponent("Data", "Data Provider").then(providerId => {
cy.searchAndAddComponent("Data Provider").then(providerId => {
//For deletion
cy.get("[data-cy=setting-_instanceName] input")
.clear()
@ -181,7 +178,7 @@ filterTests(["all"], () => {
const testFocusOnCreate = chartLabel => {
cy.log("Adding: " + chartLabel)
cy.addComponent("Chart", chartLabel).then(componentId => {
cy.searchAndAddComponent(chartLabel).then(componentId => {
cy.get(
"[data-cy=dataProvider-prop-control] .spectrum-Picker"
).should("not.have.class", "is-focused")
@ -207,7 +204,7 @@ filterTests(["all"], () => {
})
it("should replace the placeholder when a url is set on an image", () => {
cy.addComponent("Elements", "Image").then(imageId => {
cy.searchAndAddComponent("Image").then(imageId => {
cy.get("[data-cy=setting-_instanceName] input")
.clear()
.type(imageId)
@ -229,7 +226,7 @@ filterTests(["all"], () => {
})
it("should add a markdown component.", () => {
cy.addComponent("Elements", "Markdown Viewer").then(markdownId => {
cy.searchAndAddComponent("Markdown Viewer").then(markdownId => {
cy.get("[data-cy=setting-_instanceName] input")
.clear()
.type(markdownId)
@ -253,8 +250,7 @@ filterTests(["all"], () => {
})
it("should direct the user when adding an Icon component.", () => {
cy.addComponent("Elements", "Icon").then(iconId => {
cy.getComponent(iconId).find(".component-placeholder").should("exist")
cy.searchAndAddComponent("Icon").then(iconId => {
cy.get("[data-cy=setting-_instanceName] input")
.clear()
.type(iconId)

View File

@ -1,4 +1,5 @@
import filterTests from "../support/filterTests"
const interact = require('../support/interact')
filterTests(["smoke", "all"], () => {
context("Screen Tests", () => {
@ -10,32 +11,44 @@ filterTests(["smoke", "all"], () => {
it("Should successfully create a screen", () => {
cy.createScreen("test")
cy.get(".nav-items-container").within(() => {
cy.get(interact.BODY).within(() => {
cy.contains("/test").should("exist")
})
})
it("Should update the url", () => {
cy.createScreen("test with spaces")
cy.get(".nav-items-container").within(() => {
cy.get(interact.BODY).within(() => {
cy.contains("/test-with-spaces").should("exist")
})
})
it("Should create a blank screen with the selected access level", () => {
cy.createScreen("admin only", "Admin")
it("should delete all screens then create first screen via button", () => {
cy.deleteAllScreens()
cy.contains("Create first screen").click()
cy.get(interact.BODY, { timeout: 2000 }).should('contain', '/home')
})
cy.get(".nav-items-container").within(() => {
cy.contains("/admin-only").should("exist")
})
it("Should create and filter screens by access level", () => {
const accessLevels = ["Basic", "Admin", "Public", "Power"]
cy.createScreen("open to all", "Public")
for (const access of accessLevels){
// Create screen with specified access level
cy.createScreen(access, access)
// Filter by access level and confirm screen visible
cy.filterScreensAccessLevel(access)
cy.get(interact.BODY).within(() => {
cy.get(interact.NAV_ITEM).should('contain', access.toLowerCase())
})
}
cy.get(".nav-items-container").within(() => {
cy.contains("/open-to-all").should("exist")
//The access level should now be set to admin. Previous screens should be filtered.
cy.get(".nav-item").contains("/test-screen").should("not.exist")
})
// Filter by All screens - Confirm all screens visible
cy.filterScreensAccessLevel("All screens")
cy.get(interact.BODY).should('contain', accessLevels[0])
.and('contain', accessLevels[1])
.and('contain', accessLevels[2])
.and('contain', accessLevels[3])
})
})
})

View File

@ -199,15 +199,16 @@ filterTests(["all"], () => {
.within(() => {
cy.get("input").clear().type(queryRename)
})
// Save query
cy.get(".spectrum-Button").contains("Save Query").click({ force: true })
// Click on a nav item
cy.get(".nav-item").first().click()
// Confirm name change
cy.get(".nav-item").should("contain", queryRename)
})
it("should delete a query", () => {
// Get query nav item - QueryName
cy.get(".nav-item")
.contains(queryName)
.contains(queryRename)
.parent()
.within(() => {
cy.get(".spectrum-Icon").eq(1).click({ force: true })
@ -218,7 +219,7 @@ filterTests(["all"], () => {
.contains("Delete Query")
.click({ force: true })
// Confirm deletion
cy.get(".nav-item", { timeout: 1000 }).should("not.contain", queryName)
cy.get(".nav-item", { timeout: 1000 }).should("not.contain", queryRename)
})
}
})

View File

@ -108,7 +108,7 @@ filterTests(["all"], () => {
})
it("should delete a relationship", () => {
cy.get(".hierarchy-items-container").contains("PostgreSQL").click()
cy.get(".hierarchy-items-container").contains("PostgreSQL").click({ force: true })
cy.reload()
// Delete one relationship
cy.get(".spectrum-Table")
@ -150,13 +150,15 @@ filterTests(["all"], () => {
cy.get("@query").its("response.statusCode").should("eq", 200)
cy.get("@query").its("response.body").should("not.be.empty")
// Save query
cy.intercept("**/queries").as("saveQuery")
cy.get(".spectrum-Button").contains("Save Query").click({ force: true })
cy.wait("@saveQuery")
cy.get(".spectrum-Tabs-content", { timeout: 2000 }).should("contain", queryName)
})
it("should switch to schema with no tables", () => {
// Switch Schema - To one without any tables
cy.get(".hierarchy-items-container").contains("PostgreSQL").click()
cy.get(".hierarchy-items-container").contains("PostgreSQL").click({ force: true })
switchSchema("randomText")
// No tables displayed
@ -218,8 +220,9 @@ filterTests(["all"], () => {
it("should edit a query name", () => {
// Access query
cy.get(".hierarchy-items-container", { timeout: 2000 })
.contains(queryName + " (1)")
.click()
//.contains(queryName + " (1)")
.contains(queryName)
.click({ force: true })
// Rename query
cy.wait(1000)
@ -229,18 +232,16 @@ filterTests(["all"], () => {
cy.get("input").clear().type(queryRename)
})
// Run and Save query
cy.get(".spectrum-Button", { timeout: 2000 }).contains("Run Query").click({ force: true })
cy.wait(1000)
cy.get(".spectrum-Button", { timeout: 2000 }).contains("Save Query").click({ force: true })
cy.reload({ timeout: 5000 })
cy.get(".nav-item", { timeout: 2000 }).should("contain", queryRename)
// Click on a nav item and confirm name change
cy.get(".nav-item").first().click()
// Confirm name change
cy.get(".nav-item").should("contain", queryRename)
})
it("should delete a query", () => {
// Get query nav item - QueryName
cy.get(".nav-item")
.contains(queryName)
.contains(queryRename)
.parent()
.within(() => {
cy.get(".spectrum-Icon").eq(1).click({ force: true })
@ -252,7 +253,7 @@ filterTests(["all"], () => {
.click({ force: true })
// Confirm deletion
cy.reload({ timeout: 5000 })
cy.get(".nav-item", { timeout: 1000 }).should("not.contain", queryName)
cy.get(".nav-item", { timeout: 1000 }).should("not.contain", queryRename)
})
const switchSchema = schema => {

View File

@ -15,7 +15,7 @@ filterTests(['smoke', 'all'], () => {
})
cy.get(interact.SPECTRUM_MODAL).within(() => {
// Enter app name before revert
cy.get("input").type("Cypress Tests")
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).type("Cypress Tests")
cy.intercept('**/revert').as('revertApp')
// Click Revert
cy.get(interact.SPECTRUM_BUTTON).contains("Revert").click({ force: true })
@ -30,7 +30,7 @@ filterTests(['smoke', 'all'], () => {
cy.navigateToFrontend()
// Add initial component - Paragraph
cy.addComponent("Elements", "Paragraph")
cy.searchAndAddComponent("Paragraph")
// Publish app
cy.get(interact.SPECTRUM_BUTTON).contains("Publish").click({ force: true })
cy.get(interact.SPECTRUM_BUTTON_GROUP).within(() => {
@ -42,7 +42,7 @@ filterTests(['smoke', 'all'], () => {
})
// Add second component - Button
cy.addComponent("Elements", "Button")
cy.searchAndAddComponent("Button")
// Click Revert
cy.get(interact.TOP_RIGHT_NAV).within(() => {
cy.get(interact.AREA_LABEL_REVERT).click({ force: true })

View File

@ -4,31 +4,32 @@ Cypress.on("uncaught:exception", () => {
// ACCOUNTS & USERS
Cypress.Commands.add("login", (email, password) => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(2000)
cy.url().then(url => {
if (url.includes("builder/admin")) {
// create admin user
cy.get("input").first().type("test@test.com")
cy.get('input[type="password"]').first().type("test")
cy.get('input[type="password"]').eq(1).type("test")
cy.contains("Create super admin user").click({ force: true })
}
if (url.includes("builder/auth/login") || url.includes("builder/admin")) {
// login
cy.contains("Sign in to Budibase").then(() => {
if (email == null) {
cy.get("input").first().type("test@test.com")
cy.get('input[type="password"]').type("test")
} else {
cy.get("input").first().type(email)
cy.get('input[type="password"]').type(password)
}
cy.get("button").first().click({ force: true })
cy.wait(1000)
})
}
})
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 })
cy.url()
.should("include", "/builder/")
.then(url => {
if (url.includes("builder/admin")) {
// create admin user
cy.get("input").first().type("test@test.com")
cy.get('input[type="password"]').first().type("test")
cy.get('input[type="password"]').eq(1).type("test")
cy.contains("Create super admin user").click({ force: true })
}
if (url.includes("builder/auth") || url.includes("builder/admin")) {
// login
cy.contains("Sign in to Budibase").then(() => {
if (email == null) {
cy.get("input").first().type("test@test.com")
cy.get('input[type="password"]').type("test")
} else {
cy.get("input").first().type(email)
cy.get('input[type="password"]').type(password)
}
cy.get("button").first().click({ force: true })
cy.wait(1000)
})
}
})
})
Cypress.Commands.add("logOut", () => {
@ -50,23 +51,36 @@ Cypress.Commands.add("logoutNoAppGrid", () => {
cy.wait(2000)
})
Cypress.Commands.add("createUser", email => {
// quick hacky recorded way to create a user
Cypress.Commands.add("createUser", (email, permission) => {
cy.contains("Users").click()
cy.get(`[data-cy="add-user"]`).click()
cy.get(".spectrum-Dialog-grid").within(() => {
cy.get(".spectrum-Picker-label").click()
cy.get(
".spectrum-Menu-item:nth-child(2) > .spectrum-Menu-itemLabel"
).click()
// Enter email
cy.get(".spectrum-Textfield-input").clear().click().type(email)
// Onboarding type selector
cy.get(".spectrum-Textfield-input")
.eq(0)
.first()
.type(email, { force: true })
cy.get(".spectrum-Button--cta").click({ force: true })
// Select permission, if applicable
// Default is App User
if (permission != null) {
cy.get(".spectrum-Picker-label").click()
cy.get(".spectrum-Menu").within(() => {
cy.get(".spectrum-Menu-item")
.contains(permission)
.click({ force: true })
})
}
// Add user and wait for modal to change
cy.get(".spectrum-Button").contains("Add user").click({ force: true })
cy.get(".spectrum-ActionButton").contains("Add email").should("not.exist")
})
// Onboarding modal
cy.get(".spectrum-Dialog-grid").within(() => {
cy.get(".onboarding-type").eq(1).click()
cy.get(".spectrum-Button").contains("Done").click({ force: true })
cy.get(".spectrum-Button").contains("Cancel").should("not.exist")
})
// Accounts created modal - Click Done button
cy.get(".spectrum-Button").contains("Done").click({ force: true })
})
Cypress.Commands.add("deleteUser", email => {
@ -74,18 +88,13 @@ Cypress.Commands.add("deleteUser", email => {
cy.contains("Users", { timeout: 2000 }).click()
cy.contains(email).click()
// Click Delete user button
cy.get(".spectrum-Button")
.contains("Delete user")
.click({ force: true })
.then(() => {
// Confirm deletion within modal
cy.get(".spectrum-Dialog-grid", { timeout: 500 }).within(() => {
cy.get(".spectrum-Button")
.contains("Delete user")
.click({ force: true })
})
})
cy.get(".title").within(() => {
cy.get(".spectrum-Icon").click({ force: true })
})
cy.get(".spectrum-Menu").within(() => {
cy.get(".spectrum-Menu-item").contains("Delete").click({ force: true })
})
cy.get(".spectrum-Dialog-grid").contains("Delete user").click({ force: true })
})
Cypress.Commands.add("updateUserInformation", (firstName, lastName) => {
@ -120,9 +129,27 @@ Cypress.Commands.add("updateUserInformation", (firstName, lastName) => {
.blur()
}
cy.get("button").contains("Update information").click({ force: true })
cy.get(".spectrum-Dialog-grid").should("not.exist")
})
})
Cypress.Commands.add("setUserRole", (user, role) => {
cy.contains("Users").click()
cy.contains(user).click()
// Set Role
cy.wait(500)
cy.get(".spectrum-Form-itemField")
.eq(2)
.within(() => {
cy.get(".spectrum-Picker-label").click({ force: true })
})
cy.get(".spectrum-Menu").within(() => {
cy.get(".spectrum-Menu-itemLabel").contains(role).click({ force: true })
})
cy.get(".spectrum-Form-itemField").eq(2).should("contain", role)
})
// APPLICATIONS
Cypress.Commands.add("createTestApp", () => {
const appName = "Cypress Tests"
@ -139,7 +166,9 @@ Cypress.Commands.add("createApp", (name, addDefaultTable) => {
cy.get(`[data-cy="create-app-btn"]`, { timeout: 5000 }).click({ force: true })
// If apps already exist
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`, {
timeout: 5000,
})
.its("body")
.then(val => {
if (val.length > 0) {
@ -208,7 +237,7 @@ Cypress.Commands.add("deleteApp", name => {
cy.get(".app-overview-actions-icon").within(() => {
cy.get(".spectrum-Icon").click({ force: true })
})
cy.get(".spectrum-Menu").contains("Delete").click()
cy.get(".spectrum-Menu").contains("Delete").click({ force: true })
cy.get(".spectrum-Dialog-grid").within(() => {
cy.get("input").type(name)
})
@ -223,9 +252,11 @@ Cypress.Commands.add("deleteApp", name => {
})
Cypress.Commands.add("deleteAllApps", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 })
cy.wait(500)
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`, {
timeout: 5000,
})
.its("body")
.then(val => {
for (let i = 0; i < val.length; i++) {
@ -285,7 +316,7 @@ Cypress.Commands.add("updateAppName", (changedName, noName) => {
})
Cypress.Commands.add("publishApp", resolvedAppPath => {
//Assumes you have navigated to an application first
// Assumes you have navigated to an application first
cy.get(".toprightnav button.spectrum-Button")
.contains("Publish")
.click({ force: true })
@ -297,7 +328,7 @@ Cypress.Commands.add("publishApp", resolvedAppPath => {
cy.wait(1000)
})
//Verify that the app url is presented correctly to the user
// 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(() => {
@ -377,7 +408,7 @@ Cypress.Commands.add("searchForApplication", appName => {
// Assumes there are no others
Cypress.Commands.add("applicationInAppTable", appName => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 })
cy.get(".appTable", { timeout: 2000 }).within(() => {
cy.get(".appTable", { timeout: 5000 }).within(() => {
cy.get(".title").contains(appName).should("exist")
})
})
@ -418,7 +449,12 @@ Cypress.Commands.add("createTable", (tableName, initialTable) => {
cy.get("input", { timeout: 2000 }).first().type(tableName).blur()
cy.get(".spectrum-ButtonGroup").contains("Create").click()
})
cy.contains(tableName).should("be.visible")
// Ensure modal has closed and table is created
cy.get(".spectrum-Modal").should("not.exist")
cy.get(".spectrum-Tabs-content", { timeout: 1000 }).should(
"contain",
tableName
)
})
Cypress.Commands.add("createTestTableWithData", () => {
@ -487,26 +523,52 @@ Cypress.Commands.add("selectTable", tableName => {
})
Cypress.Commands.add("addCustomSourceOptions", totalOptions => {
cy.get(".spectrum-ActionButton")
.contains("Define Options")
.click()
.then(() => {
for (let i = 0; i < totalOptions; i++) {
// Add radio button options
cy.get(".spectrum-Button")
.contains("Add Option")
.click({ force: true })
.then(() => {
cy.get("[placeholder='Label']", { timeout: 500 }).eq(i).type(i)
cy.get("[placeholder='Value']").eq(i).type(i)
})
}
// Save options
cy.get(".spectrum-Button").contains("Save").click({ force: true })
})
cy.get('[data-cy="customOptions-prop-control"]').within(() => {
cy.get(".spectrum-ActionButton-label").click({ force: true })
})
for (let i = 0; i < totalOptions; i++) {
// Add radio button options
cy.get(".spectrum-Button-label", { timeout: 1000 })
.contains("Add Option")
.click({ force: true })
.then(() => {
cy.get("[placeholder='Label']", { timeout: 500 }).eq(i).type(i)
cy.get("[placeholder='Value']").eq(i).type(i)
})
}
// Save options
cy.get(".spectrum-Button").contains("Save").click({ force: true })
})
// DESIGN SECTION
Cypress.Commands.add("searchAndAddComponent", component => {
// Open component menu
cy.get(".icon-side-nav").within(() => {
cy.get(".icon-side-nav-item").eq(1).click()
})
cy.get(".add-component > .spectrum-Button")
.contains("Add component")
.click({ force: true })
cy.get(".container", { timeout: 1000 }).within(() => {
cy.get(".title").should("contain", "Add component")
// Search and add component
cy.get(".spectrum-Textfield-input").clear().type(component)
cy.get(".body").within(() => {
cy.get(".component")
.contains(new RegExp("^" + component + "$"), { timeout: 3000 })
.click({ force: true })
})
})
cy.wait(1000)
cy.location().then(loc => {
const params = loc.pathname.split("/")
const componentId = params[params.length - 1]
cy.getComponent(componentId, { timeout: 3000 }).should("exist")
return cy.wrap(componentId)
})
})
// DESIGN AREA
Cypress.Commands.add("addComponent", (category, component) => {
if (category) {
cy.get(`[data-cy="category-${category}"]`, { timeout: 3000 }).click({
@ -542,7 +604,7 @@ Cypress.Commands.add("getComponent", componentId => {
Cypress.Commands.add("createScreen", (route, accessLevelLabel) => {
// Blank Screen
cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click()
cy.get(".spectrum-Button").contains("Add screen").click({ force: true })
cy.get(".spectrum-Modal").within(() => {
cy.get("[data-cy='blank-screen']").click()
cy.get(".spectrum-Button").contains("Continue").click({ force: true })
@ -567,7 +629,7 @@ Cypress.Commands.add(
"createDatasourceScreen",
(datasourceNames, accessLevelLabel) => {
cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click()
cy.get(".spectrum-Button").contains("Add screen").click({ force: true })
cy.get(".spectrum-Modal").within(() => {
cy.get(".item").contains("Autogenerated screens").click()
cy.get(".spectrum-Button").contains("Continue").click({ force: true })
@ -622,13 +684,60 @@ Cypress.Commands.add(
}
)
Cypress.Commands.add("filterScreensAccessLevel", accessLevel => {
// Filters screens by access level dropdown
cy.get(".body").within(() => {
cy.get(".spectrum-Form-item").eq(1).click()
})
cy.get(".spectrum-Menu").within(() => {
cy.contains(accessLevel).click()
})
})
Cypress.Commands.add("deleteScreen", screen => {
// Navigates to Design section and deletes specified screen
cy.contains("Design").click()
cy.get(".body").within(() => {
cy.contains(screen)
.siblings(".actions")
.within(() => {
cy.get(".spectrum-Icon").click({ force: true })
})
})
cy.get(".spectrum-Menu > .spectrum-Menu-item > .spectrum-Menu-itemLabel")
.contains("Delete")
.click()
cy.get(
".spectrum-Dialog-grid > .spectrum-ButtonGroup > .confirm-wrap > .spectrum-Button"
).click({ force: true })
cy.get(".spectrum-Dialog-grid", { timeout: 10000 }).should("not.exist")
})
Cypress.Commands.add("deleteAllScreens", () => {
// Deletes all screens
cy.get(".body")
.find(".nav-item")
.its("length")
.then(len => {
for (let i = 0; i < len; i++) {
cy.get(".body > .nav-item")
.eq(0)
.invoke("text")
.then(text => {
cy.deleteScreen(text.trim())
})
}
})
})
// NAVIGATION
Cypress.Commands.add("navigateToFrontend", () => {
// Clicks on Design tab and then the Home nav item
cy.wait(500)
cy.contains("Design").click()
cy.get(".spectrum-Search", { timeout: 2000 }).type("/")
cy.get(".nav-item", { timeout: 2000 }).contains("home").click()
cy.get(".nav-item", { timeout: 2000 }).contains("home").click({ force: true })
})
Cypress.Commands.add("navigateToDataSection", () => {
@ -640,9 +749,11 @@ Cypress.Commands.add("navigateToDataSection", () => {
Cypress.Commands.add("navigateToAutogeneratedModal", () => {
// Screen name must already exist within data source
cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click()
cy.get(".spectrum-Button").contains("Add screen").click({ force: true })
cy.get(".spectrum-Modal").within(() => {
cy.get(".item").contains("Autogenerated screens").click()
cy.get(".item", { timeout: 2000 })
.contains("Autogenerated screens")
.click({ force: true })
cy.get(".spectrum-Button").contains("Continue").click({ force: true })
cy.wait(500)
})

View File

@ -12,7 +12,7 @@ export const APP_NAME_INPUT = "input" // we need to update this with atribute cy
export const SPECTRUM_BUTTON_GROUP = ".spectrum-ButtonGroup"
export const SPECTRUM_MODAL_INPUT = ".spectrum-Modal input"
//AddMultiOptionDatatype test
//AddMultiOptionDatatype
export const CATEGORY_DATA = '[data-cy="category-Data"]'
export const COMPONENT_DATA_PROVIDER = '[data-cy="component-Data Provider"]'
export const DATASOURCE_PROP_CONTROL = '[data-cy="dataSource-prop-control"]'
@ -51,7 +51,7 @@ export const LABEL_ADD_CIRCLE = "[aria-label=AddCircle]"
export const ITEM_DISABLED = ".item.disabled"
export const CONFIRM_WRAP_SPE_BUTTON = ".confirm-wrap .spectrum-Button"
export const DATA_SOURCE_ENTRY = ".data-source-entry"
export const NAV_ITEMS_CONTAINER = ".nav-items-container"
export const BODY = ".body"
//publishWorkFlow
export const DEPLOY_APP_MODAL = ".spectrum-Modal [data-cy=deploy-app-modal]"
@ -108,6 +108,9 @@ export const CONTAINER = ".container"
export const REGENERATE = ".regenerate"
export const SPECTRUM_DIALOG_CONTENT = ".spectrum-Dialog-content"
export const SPECTRUM_ICON = ".spectrum-Icon"
export const SPECTRUM_HEADING = ".spectrum-Heading"
export const SPECTRUM_FORM_ITEMFIELD = ".spectrum-Form-itemField"
export const LIST_ITEMS = ".list-items"
//createView
export const SPECTRUM_MENU_ITEM_LABEL = ".spectrum-Menu-itemLabel"

View File

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

View File

@ -68,7 +68,19 @@ const automationActions = store => ({
return state
})
},
duplicate: async automation => {
const response = await API.createAutomation({
...automation,
name: `${automation.name} - copy`,
_id: undefined,
_ref: undefined,
})
store.update(state => {
state.automations = [...state.automations, response.automation]
store.actions.select(response.automation)
return state
})
},
save: async automation => {
const response = await API.updateAutomation(automation)
store.update(state => {

View File

@ -32,7 +32,8 @@
if (!results) {
return {}
}
if (results.outputs?.status?.toLowerCase() === "stopped") {
const lcStatus = results.outputs?.status?.toLowerCase()
if (lcStatus === "stopped" || lcStatus === "stopped_error") {
return { yellow: true, message: "Stopped" }
} else if (results.outputs?.success || isTrigger) {
return { positive: true, message: "Success" }

View File

@ -19,12 +19,23 @@
notifications.error("Error deleting automation")
}
}
async function duplicateAutomation() {
try {
await automationStore.actions.duplicate(automation)
notifications.success("Automation has been duplicated successfully")
$goto(`./${$automationStore.selectedAutomation.automation._id}`)
} catch (error) {
notifications.error("Error duplicating automation")
}
}
</script>
<ActionMenu>
<div slot="control" class="icon">
<Icon s hoverable name="MoreSmallList" />
</div>
<MenuItem icon="Duplicate" on:click={duplicateAutomation}>Duplicate</MenuItem>
<MenuItem icon="Edit" on:click={updateAutomationDialog.show}>Edit</MenuItem>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
</ActionMenu>

View File

@ -260,6 +260,7 @@
{bindings}
allowJS={false}
updateOnChange={false}
drawerLeft="260px"
/>
{/if}
{:else if value.customType === "query"}
@ -357,6 +358,7 @@
{bindings}
updateOnChange={false}
placeholder={value.customType === "queryLimit" ? queryLimit : ""}
drawerLeft="260px"
/>
</div>
{/if}

View File

@ -211,7 +211,6 @@
bindings={getAuthBindings()}
on:change={e => {
form.bearer.token = e.detail
console.log(e.detail)
onFieldChange()
}}
on:blur={() => {

View File

@ -0,0 +1,24 @@
<script>
import { Select } from "@budibase/bbui"
import { roles } from "stores/backend"
import { RoleUtils } from "@budibase/frontend-core"
export let value
export let error
export let placeholder = null
export let autoWidth = false
export let quiet = false
</script>
<Select
{autoWidth}
{quiet}
bind:value
on:change
options={$roles}
getOptionLabel={role => role.name}
getOptionValue={role => role._id}
getOptionColour={role => RoleUtils.getRoleColour(role._id)}
{placeholder}
{error}
/>

View File

@ -18,6 +18,7 @@
export let fillWidth
export let allowJS = true
export let updateOnChange = true
export let drawerLeft
const dispatch = createEventDispatcher()
let bindingDrawer
@ -53,7 +54,7 @@
</div>
{/if}
</div>
<Drawer {fillWidth} bind:this={bindingDrawer} {title}>
<Drawer {fillWidth} bind:this={bindingDrawer} {title} left={drawerLeft}>
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>

View File

@ -6,8 +6,8 @@
Button,
Layout,
DrawerContent,
ActionMenu,
MenuItem,
ActionButton,
Search,
} from "@budibase/bbui"
import { getAvailableActions } from "./index"
import { generate } from "shortid"
@ -22,8 +22,24 @@
export let actions
export let bindings = []
$: showAvailableActions = !actions?.length
let actionQuery
$: parsedQuery =
typeof actionQuery === "string" ? actionQuery.toLowerCase().trim() : ""
let selectedAction = actions?.length ? actions[0] : null
$: mappedActionTypes = actionTypes.reduce((acc, action) => {
let parsedName = action.name.toLowerCase().trim()
if (parsedQuery.length && parsedName.indexOf(parsedQuery) < 0) {
return acc
}
acc[action.type] = acc[action.type] || []
acc[action.type].push(action)
return acc
}, {})
// These are ephemeral bindings which only exist while executing actions
$: buttonContextBindings = getButtonContextBindings(
$currentAsset,
@ -61,7 +77,12 @@
actions = actions
}
const addAction = actionType => () => {
const toggleActionList = () => {
actionQuery = null
showAvailableActions = !showAvailableActions
}
const addAction = actionType => {
const newAction = {
parameters: {},
[EVENT_TYPE_KEY]: actionType.name,
@ -78,6 +99,11 @@
selectedAction = action
}
const onAddAction = actionType => {
addAction(actionType)
toggleActionList()
}
function handleDndConsider(e) {
actions = e.detail.items
}
@ -88,7 +114,39 @@
<DrawerContent>
<Layout noPadding gap="S" slot="sidebar">
{#if actions && actions.length > 0}
{#if showAvailableActions || !actions?.length}
<div class="actions-list">
{#if actions?.length > 0}
<div>
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={toggleActionList}
>
Back
</ActionButton>
</div>
{/if}
<div class="search-wrap">
<Search placeholder="Search" bind:value={actionQuery} />
</div>
{#each Object.entries(mappedActionTypes) as [categoryId, category], idx}
<div class="heading" class:top-entry={idx === 0}>{categoryId}</div>
<ul>
{#each category as actionType}
<li on:click={onAddAction(actionType)}>
<span class="action-name">{actionType.name}</span>
</li>
{/each}
</ul>
{/each}
</div>
{/if}
{#if actions && actions.length > 0 && !showAvailableActions}
<div>
<Button secondary on:click={toggleActionList}>Add Action</Button>
</div>
<div
class="actions"
use:dndzone={{
@ -120,17 +178,9 @@
{/each}
</div>
{/if}
<ActionMenu>
<Button slot="control" secondary>Add Action</Button>
{#each actionTypes as actionType}
<MenuItem on:click={addAction(actionType)}>
{actionType.name}
</MenuItem>
{/each}
</ActionMenu>
</Layout>
<Layout noPadding>
{#if selectedActionComponent}
{#if selectedActionComponent && !showAvailableActions}
{#key selectedAction.id}
<div class="selected-action-container">
<svelte:component
@ -152,13 +202,10 @@
align-items: stretch;
gap: var(--spacing-s);
}
.action-header {
color: var(--spectrum-global-color-gray-700);
flex: 1 1 auto;
}
.action-container {
background-color: var(--background);
padding: var(--spacing-s) var(--spacing-m);
@ -182,4 +229,55 @@
.action-container.selected .action-header {
color: var(--spectrum-global-color-gray-900);
}
.actions-list > * {
padding-bottom: var(--spectrum-global-dimension-static-size-200);
}
.actions-list .heading {
padding-bottom: var(--spectrum-global-dimension-static-size-100);
padding-top: var(--spectrum-global-dimension-static-size-50);
}
.actions-list .heading.top-entry {
padding-top: 0px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.action-name {
font-weight: 600;
text-transform: capitalize;
}
.heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
}
</style>

View File

@ -69,9 +69,16 @@
notifications.error("Error creating automation")
}
}
$: actionCount = value?.length
$: actionText = `${actionCount || "No"} action${
actionCount !== 1 ? "s" : ""
} set`
</script>
<div class="action-count">{actionText}</div>
<ActionButton on:click={openDrawer}>Define actions</ActionButton>
<Drawer bind:this={drawer} title={"Actions"}>
<svelte:fragment slot="description">
Define what actions to run.
@ -85,3 +92,10 @@
{key}
/>
</Drawer>
<style>
.action-count {
padding-bottom: var(--spacing-s);
font-weight: 600;
}
</style>

View File

@ -1,5 +1,5 @@
<script>
import { Select, Label, Checkbox } from "@budibase/bbui"
import { Select, Label } from "@budibase/bbui"
import { currentAsset, store } from "builderStore"
import { getActionProviderComponents } from "builderStore/dataBinding"
@ -21,10 +21,6 @@
getOptionValue={x => x._id}
/>
<div />
<Checkbox
text="Validate only current step"
bind:value={parameters.onlyCurrentStep}
/>
</div>
<style>

View File

@ -2,6 +2,7 @@
"actions": [
{
"name": "Save Row",
"type": "data",
"component": "SaveRow",
"context": [
{
@ -12,6 +13,7 @@
},
{
"name": "Duplicate Row",
"type": "data",
"component": "DuplicateRow",
"context": [
{
@ -22,14 +24,17 @@
},
{
"name": "Delete Row",
"type": "data",
"component": "DeleteRow"
},
{
"name": "Navigate To",
"type": "application",
"component": "NavigateTo"
},
{
"name": "Execute Query",
"type": "data",
"component": "ExecuteQuery",
"context": [
{
@ -40,43 +45,53 @@
},
{
"name": "Trigger Automation",
"type": "application",
"component": "TriggerAutomation"
},
{
"name": "Update Field Value",
"type": "form",
"component": "UpdateFieldValue"
},
{
"name": "Validate Form",
"type": "form",
"component": "ValidateForm"
},
{
"name": "Change Form Step",
"type": "form",
"component": "ChangeFormStep"
},
{
"name": "Clear Form",
"type": "form",
"component": "ClearForm"
},
{
"name": "Log Out",
"type": "application",
"component": "LogOut"
},
{
"name": "Close Screen Modal",
"type": "application",
"component": "CloseScreenModal"
},
{
"name": "Refresh Data Provider",
"type": "data",
"component": "RefreshDataProvider"
},
{
"name": "Update State",
"type": "data",
"component": "UpdateState",
"dependsOnFeature": "state"
},
{
"name": "Upload File to S3",
"type": "data",
"component": "S3Upload",
"context": [
{
@ -87,12 +102,14 @@
},
{
"name": "Export Data",
"type": "data",
"component": "ExportData"
},
{
"name": "Continue if / Stop if",
"type": "logic",
"component": "ContinueIf",
"dependsOnFeature": "continueIfAction"
}
]
}
}

View File

@ -25,6 +25,7 @@
export let otherSources
export let showAllQueries
export let bindings = []
export let showDataProviders = true
const dispatch = createEventDispatcher()
const arrayTypes = ["attachment", "array"]
@ -258,7 +259,7 @@
{/each}
</ul>
{/if}
{#if dataProviders?.length}
{#if showDataProviders && dataProviders?.length}
<Divider size="S" />
<div class="title">
<Heading size="XS">Data Providers</Heading>

View File

@ -4,4 +4,10 @@
const otherSources = [{ name: "Custom", label: "Custom" }]
</script>
<DataSourceSelect on:change {...$$props} showAllQueries={true} {otherSources} />
<DataSourceSelect
on:change
{...$$props}
showAllQueries={true}
showDataProviders={false}
{otherSources}
/>

View File

@ -32,6 +32,7 @@
export let menuItems
export let showMenu = false
export let bindings = []
export let bindingDrawerLeft
let fields = Object.entries(object || {}).map(([name, value]) => ({
name,
@ -119,6 +120,7 @@
value={field.value}
allowJS={false}
fillWidth={true}
drawerLeft={bindingDrawerLeft}
/>
{:else}
<Input

View File

@ -1,5 +1,5 @@
<script>
import { Layout, Icon, ActionButton } from "@budibase/bbui"
import { Layout, Icon, ActionButton, InlineAlert } from "@budibase/bbui"
import StatusRenderer from "./StatusRenderer.svelte"
import DateTimeRenderer from "components/common/renderers/DateTimeRenderer.svelte"
import TestDisplay from "components/automation/AutomationBuilder/TestDisplay.svelte"
@ -9,6 +9,7 @@
export let history
export let appId
export let close
const STOPPED_ERROR = "stopped_error"
$: exists = $automationStore.automations?.find(
auto => auto._id === history?.automationId
@ -32,6 +33,15 @@
<Icon name="JourneyVoyager" />
<div>{history.automationName}</div>
</div>
{#if history.status === STOPPED_ERROR}
<div class="cron-error">
<InlineAlert
type="error"
header="CRON automation disabled"
message="Fix the error and re-publish your app to re-activate."
/>
</div>
{/if}
<div>
{#if exists}
<ActionButton
@ -87,4 +97,10 @@
grid-template-columns: 1fr auto;
gap: var(--spacing-s);
}
.cron-error {
display: flex;
width: 100%;
justify-content: center;
}
</style>

View File

@ -3,7 +3,8 @@
export let value
$: isError = !value || value.toLowerCase() === "error"
$: isStopped = value?.toLowerCase() === "stopped"
$: isStoppedError = value?.toLowerCase() === "stopped_error"
$: isStopped = value?.toLowerCase() === "stopped" || isStoppedError
$: status = getStatus(isError, isStopped)
function getStatus(error, stopped) {

View File

@ -0,0 +1,75 @@
<script>
import { ActionButton, Icon, Search, Divider, Detail } from "@budibase/bbui"
export let searchTerm = ""
export let selected
export let filtered = []
export let addAll
export let select
export let title
export let key
</script>
<div style="padding: var(--spacing-m)">
<Search placeholder="Search" bind:value={searchTerm} />
<div class="header sub-header">
<div>
<Detail
>{filtered.length} {title}{filtered.length === 1 ? "" : "s"}</Detail
>
</div>
<div>
<ActionButton on:click={addAll} emphasized size="S">Add all</ActionButton>
</div>
</div>
<Divider noMargin />
<div>
{#each filtered as item}
<div
on:click={() => {
select(item._id)
}}
style="padding-bottom: var(--spacing-m)"
class="selection"
>
<div>
{item[key]}
</div>
{#if selected.includes(item._id)}
<div>
<Icon
color="var(--spectrum-global-color-blue-600);"
name="Checkmark"
/>
</div>
{/if}
</div>
{/each}
</div>
</div>
<style>
.header {
align-items: center;
padding: var(--spacing-m) 0 var(--spacing-m) 0;
display: flex;
justify-content: space-between;
}
.selection {
align-items: end;
display: flex;
justify-content: space-between;
cursor: pointer;
}
.selection > :first-child {
padding-top: var(--spacing-m);
}
.sub-header {
display: flex;
justify-content: space-between;
}
</style>

View File

@ -111,7 +111,6 @@
await admin.init()
// Create user
await API.updateOwnMetadata({ roleId: $values.roleId })
await auth.setInitInfo({})
// Create a default home screen if no template was selected

View File

@ -150,12 +150,31 @@ export function flipHeaderState(headersActivity) {
return enabled
}
export const parseToCsv = (headers, rows) => {
let csv = headers?.map(key => `"${key}"`)?.join(",") || ""
for (let row of rows) {
csv = `${csv}\n${headers
.map(header => {
let val = row[header]
val =
typeof val === "object" && !(val instanceof Date)
? `"${JSON.stringify(val).replace(/"/g, "'")}"`
: `"${val}"`
return val.trim()
})
.join(",")}`
}
return csv
}
export default {
breakQueryString,
buildQueryString,
fieldsToSchema,
flipHeaderState,
keyValueToQueryParameters,
parseToCsv,
queryParametersToKeyValue,
schemaToFields,
}

View File

@ -3,6 +3,7 @@ import { get } from "svelte/store"
export const FEATURE_FLAGS = {
LICENSING: "LICENSING",
USER_GROUPS: "USER_GROUPS",
}
export const isEnabled = featureFlag => {

View File

@ -440,6 +440,7 @@
...dynamicRequestBindings,
...dataSourceStaticBindings,
]}
bindingDrawerLeft="260px"
/>
</Tab>
<Tab title="Params">
@ -448,6 +449,7 @@
name="param"
headings
bindings={mergedBindings}
bindingDrawerLeft="260px"
/>
</Tab>
<Tab title="Headers">
@ -458,6 +460,7 @@
name="header"
headings
bindings={mergedBindings}
bindingDrawerLeft="260px"
/>
</Tab>
<Tab title="Body">

View File

@ -186,7 +186,7 @@
$goto("./navigation")
}
} else if (type === "request-add-component") {
$goto("./components/new")
$goto(`./components/${$selectedComponent?._id}/new`)
} else if (type === "highlight-setting") {
store.actions.settings.highlight(data.setting)

View File

@ -9,7 +9,7 @@
import { setContext } from "svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import { DropPosition } from "./dndStore"
import { Button } from "@budibase/bbui"
import { notifications, Button } from "@budibase/bbui"
let scrollRef
@ -56,6 +56,15 @@
})
}
const onDrop = async () => {
try {
await dndStore.actions.drop()
} catch (error) {
console.error(error)
notifications.error("Error saving component")
}
}
// Set scroll context so components can invoke scrolling when selected
setContext("scroll", {
scrollTo,
@ -81,6 +90,7 @@
opened
scrollable
icon="WebPage"
on:drop={onDrop}
>
<ScreenslotDropdownMenu component={$selectedScreen?.props} />
</NavItem>

View File

@ -28,12 +28,15 @@
}
drawer.hide()
}
$: conditionCount = componentInstance?._conditions?.length
$: conditionText = `${conditionCount || "No"} condition${
conditionCount !== 1 ? "s" : ""
} set`
</script>
<DetailSummary
name={`Conditions${componentInstance?._conditions ? " *" : ""}`}
collapsible={false}
>
<DetailSummary name={"Conditions"} collapsible={false}>
<div class="conditionCount">{conditionText}</div>
<div>
<ActionButton on:click={openDrawer}>Configure conditions</ActionButton>
</div>
@ -45,3 +48,10 @@
<Button cta slot="buttons" on:click={() => save()}>Save</Button>
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} {bindings} />
</Drawer>
<style>
.conditionCount {
font-weight: 600;
margin-top: -5px;
}
</style>

View File

@ -184,6 +184,7 @@
<div class="category-label">{category.name}</div>
{#each category.children as component}
<div
data-cy={`component-${component.name}`}
class="component"
class:selected={selectedIndex ===
orderMap[component.component]}

View File

@ -50,7 +50,6 @@
await store.actions.screens.save(duplicateScreen)
} catch (error) {
notifications.error("Error duplicating screen")
console.log(error)
}
}

View File

@ -13,7 +13,7 @@
notifications,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { apps, organisation, auth } from "stores/portal"
import { apps, organisation, auth, groups } from "stores/portal"
import { goto } from "@roxi/routify"
import { AppStatus } from "constants"
import { gradient } from "actions"
@ -30,20 +30,43 @@
try {
await organisation.init()
await apps.load()
await groups.actions.init()
} catch (error) {
notifications.error("Error loading apps")
}
loaded = true
})
const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED
$: userGroups = $groups.filter(group =>
group.users.find(user => user._id === $auth.user?._id)
)
let userApps = []
$: publishedApps = $apps.filter(publishedAppsOnly)
$: userApps = $auth.user?.builder?.global
? publishedApps
: publishedApps.filter(app =>
Object.keys($auth.user?.roles).includes(app.prodId)
)
$: {
if (!Object.keys($auth.user?.roles).length && $auth.user?.userGroups) {
userApps =
$auth.user?.builder?.global || $auth.user?.admin?.global
? publishedApps
: publishedApps.filter(app => {
return userGroups.find(group => {
return Object.keys(group.roles)
.map(role => apps.extractAppId(role))
.includes(app.appId)
})
})
} else {
userApps =
$auth.user?.builder?.global || $auth.user?.admin?.global
? publishedApps
: publishedApps.filter(app =>
Object.keys($auth.user?.roles)
.map(x => apps.extractAppId(x))
.includes(app.appId)
)
}
}
function getUrl(app) {
if (app.url) {

View File

@ -45,6 +45,7 @@
},
])
}
if (admin) {
menu = menu.concat([
{
@ -65,6 +66,15 @@
},
])
if (isEnabled(FEATURE_FLAGS.USER_GROUPS)) {
let item = {
title: "User Groups",
href: "/builder/portal/manage/groups",
}
menu.splice(2, 0, item)
}
if (!$adminStore.cloud) {
menu = menu.concat([
{

View File

@ -0,0 +1,43 @@
<script>
import { PickerDropdown, notifications } from "@budibase/bbui"
import { groups } from "stores/portal"
import { onMount, createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
$: optionSections = {
groups: {
data: $groups,
getLabel: group => group.name,
getValue: group => group._id,
getIcon: group => group.icon,
getColour: group => group.color,
},
}
$: appData = [{ id: "", role: "" }]
$: onChange = selected => {
const { detail } = selected
if (!detail) return
const groupSelected = $groups.find(x => x._id === detail)
const appIds = groupSelected?.apps.map(x => x.appId) || null
dispatch("change", appIds)
}
onMount(async () => {
try {
await groups.actions.init()
} catch (error) {
notifications.error("Error")
}
})
</script>
<PickerDropdown
autocomplete
primaryOptions={optionSections}
placeholder={"Filter by access"}
on:pickprimary={onChange}
/>

View File

@ -20,12 +20,14 @@
import { store, automationStore } from "builderStore"
import { API } from "api"
import { onMount } from "svelte"
import { apps, auth, admin, templates } from "stores/portal"
import { apps, auth, admin, templates, groups } from "stores/portal"
import download from "downloadjs"
import { goto } from "@roxi/routify"
import AppRow from "components/start/AppRow.svelte"
import { AppStatus } from "constants"
import Logo from "assets/bb-space-man.svg"
import AccessFilter from "./_components/AcessFilter.svelte"
import { Constants } from "@budibase/frontend-core"
let sortBy = "name"
let template
@ -39,6 +41,7 @@
let cloud = $admin.cloud
let creatingFromTemplate = false
let automationErrors
let accessFilterList = null
const resolveWelcomeMessage = (auth, apps) => {
const userWelcome = auth?.user?.firstName
@ -56,14 +59,20 @@
: "Start from scratch"
$: enrichedApps = enrichApps($apps, $auth.user, sortBy)
$: filteredApps = enrichedApps.filter(app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
$: filteredApps = enrichedApps.filter(
app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) &&
(accessFilterList !== null ? accessFilterList.includes(app?.appId) : true)
)
$: lockedApps = filteredApps.filter(app => app?.lockedYou || app?.lockedOther)
$: unlocked = lockedApps?.length === 0
$: automationErrors = getAutomationErrors(enrichedApps)
$: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS
)
const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({
...app,
@ -202,6 +211,10 @@
$goto(`../../app/${app.devId}`)
}
const accessFilterAction = accessFilter => {
accessFilterList = accessFilter.detail
}
function createAppFromTemplateUrl(templateKey) {
// validate the template key just to make sure
const templateParts = templateKey.split("/")
@ -347,6 +360,9 @@
</Button>
{/if}
<div class="filter">
{#if hasGroupsLicense && $groups.length}
<AccessFilter on:change={accessFilterAction} />
{/if}
<Select
quiet
autoWidth

View File

@ -9,10 +9,15 @@
$redirect("../")
}
}
$: wide =
$page.path.includes("email/:template") ||
($page.path.includes("users") && !$page.path.includes(":userId")) ||
($page.path.includes("groups") && !$page.path.includes(":groupId"))
</script>
{#if $auth.isAdmin}
<Page maxWidth="90ch" wide={$page.path.includes("email/:template")}>
<Page maxWidth="90ch" {wide}>
<slot />
</Page>
{/if}

View File

@ -0,0 +1,260 @@
<script>
import { goto } from "@roxi/routify"
import {
ActionButton,
Button,
Layout,
Heading,
Body,
Icon,
Popover,
notifications,
List,
ListItem,
StatusLight,
} from "@budibase/bbui"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import { createPaginationStore } from "helpers/pagination"
import { users, apps, groups } from "stores/portal"
import { onMount } from "svelte"
import { RoleUtils } from "@budibase/frontend-core"
import { roles } from "stores/backend"
export let groupId
let popoverAnchor
let popover
let searchTerm = ""
let selectedUsers = []
let prevSearch = undefined
let pageInfo = createPaginationStore()
let loaded = false
$: page = $pageInfo.page
$: fetchUsers(page, searchTerm)
$: group = $groups.find(x => x._id === groupId)
async function addAll() {
selectedUsers = [...selectedUsers, ...filtered.map(u => u._id)]
let reducedUserObjects = filtered.map(u => {
return {
_id: u._id,
email: u.email,
}
})
group.users = [...reducedUserObjects, ...group.users]
await groups.actions.save(group)
$users.data.forEach(async user => {
let userToEdit = await users.get(user._id)
let userGroups = userToEdit.userGroups || []
userGroups.push(groupId)
await users.save({
...userToEdit,
userGroups,
})
})
}
async function selectUser(id) {
let selectedUser = selectedUsers.includes(id)
if (selectedUser) {
selectedUsers = selectedUsers.filter(id => id !== selectedUser)
let newUsers = group.users.filter(user => user._id !== id)
group.users = newUsers
} else {
let enrichedUser = $users.data
.filter(user => user._id === id)
.map(u => {
return {
_id: u._id,
email: u.email,
}
})[0]
selectedUsers = [...selectedUsers, id]
group.users.push(enrichedUser)
}
await groups.actions.save(group)
let user = await users.get(id)
let userGroups = user.userGroups || []
userGroups.push(groupId)
await users.save({
...user,
userGroups,
})
}
$: filtered =
$users.data?.filter(x => !group?.users.map(y => y._id).includes(x._id)) ||
[]
$: groupApps = $apps.filter(x => group.apps.includes(x.appId))
async function removeUser(id) {
let newUsers = group.users.filter(user => user._id !== id)
group.users = newUsers
let user = await users.get(id)
await users.save({
...user,
userGroups: [],
})
await groups.actions.save(group)
}
async function fetchUsers(page, search) {
if ($pageInfo.loading) {
return
}
// need to remove the page if they've started searching
if (search && !prevSearch) {
pageInfo.reset()
page = undefined
}
prevSearch = search
try {
pageInfo.loading()
await users.search({ page, email: search })
pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")
}
}
const getRoleLabel = appId => {
const roleId = group?.roles?.[`app_${appId}`]
const role = $roles.find(x => x._id === roleId)
return role?.name || "Custom role"
}
onMount(async () => {
try {
await Promise.all([groups.actions.init(), apps.load(), roles.fetch()])
loaded = true
} catch (error) {
notifications.error("Error fetching user group data")
}
})
</script>
{#if loaded}
<Layout noPadding>
<div>
<ActionButton
on:click={() => $goto("../groups")}
size="S"
icon="ArrowLeft"
>
Back
</ActionButton>
</div>
<div class="header">
<div class="title">
<div style="background: {group?.color};" class="circle">
<div>
<Icon size="M" name={group?.icon} />
</div>
</div>
<div class="text-padding">
<Heading>{group?.name}</Heading>
</div>
</div>
<div bind:this={popoverAnchor}>
<Button on:click={popover.show()} icon="UserAdd" cta>Add user</Button>
</div>
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<UserGroupPicker
key={"email"}
title={"User"}
bind:searchTerm
bind:selected={selectedUsers}
bind:filtered
{addAll}
select={selectUser}
/>
</Popover>
</div>
<List>
{#if group?.users.length}
{#each group.users as user}
<ListItem title={user?.email} avatar
><Icon
on:click={() => removeUser(user?._id)}
hoverable
size="L"
name="Close"
/></ListItem
>
{/each}
{:else}
<ListItem icon="UserGroup" title="You have no users in this team" />
{/if}
</List>
<div
style="flex-direction: column; margin-top: var(--spacing-m)"
class="title"
>
<Heading weight="light" size="XS">Apps</Heading>
<div style="margin-top: var(--spacing-xs)">
<Body size="S"
>Manage apps that this User group has been assigned to</Body
>
</div>
</div>
<List>
{#if groupApps.length}
{#each groupApps as app}
<ListItem
title={app.name}
icon={app?.icon?.name || "Apps"}
iconBackground={app?.icon?.color || ""}
>
<div class="title ">
<StatusLight
square
color={RoleUtils.getRoleColour(group.roles[`app_${app.appId}`])}
>
{getRoleLabel(app.appId)}
</StatusLight>
</div>
</ListItem>
{/each}
{:else}
<ListItem icon="UserGroup" title="No apps" />
{/if}
</List>
</Layout>
{/if}
<style>
.text-padding {
margin-left: var(--spacing-l);
}
.header {
display: flex;
justify-content: space-between;
}
.title {
display: flex;
}
.circle {
border-radius: 50%;
height: 30px;
color: white;
font-weight: bold;
display: inline-block;
font-size: 1.2em;
width: 30px;
}
.circle > div {
padding: calc(1.5 * var(--spacing-xs)) var(--spacing-xs);
}
</style>

View File

@ -0,0 +1,58 @@
<script>
import {
ColorPicker,
Body,
ModalContent,
Input,
IconPicker,
} from "@budibase/bbui"
export let group
export let saveGroup
</script>
<ModalContent
onConfirm={() => saveGroup(group)}
size="M"
title="Create User Group"
confirmText="Save"
>
<Input bind:value={group.name} label="Team name" />
<div class="modal-format">
<div class="modal-inner">
<Body size="XS">Icon</Body>
<div class="modal-spacing">
<IconPicker
bind:value={group.icon}
on:change={e => (group.icon = e.detail)}
/>
</div>
</div>
<div class="modal-inner">
<Body size="XS">Color</Body>
<div class="modal-spacing">
<ColorPicker
bind:value={group.color}
on:change={e => (group.color = e.detail)}
/>
</div>
</div>
</div>
</ModalContent>
<style>
.modal-format {
display: flex;
justify-content: space-between;
width: 40%;
}
.modal-inner {
display: flex;
align-items: center;
}
.modal-spacing {
margin-left: var(--spacing-l);
}
</style>

View File

@ -0,0 +1,129 @@
<script>
import {
Button,
Icon,
Body,
ActionMenu,
MenuItem,
Modal,
} from "@budibase/bbui"
import { goto } from "@roxi/routify"
import CreateEditGroupModal from "./CreateEditGroupModal.svelte"
export let group
export let deleteGroup
export let saveGroup
let modal
function editGroup() {
modal.show()
}
</script>
<div class="title">
<div class="name" style="display: flex; margin-left: var(--spacing-xl)">
<div style="background: {group.color};" class="circle">
<div>
<Icon size="M" name={group.icon} />
</div>
</div>
<div class="name" data-cy="app-name-link">
<Body size="S">{group.name}</Body>
</div>
</div>
</div>
<div class="desktop tableElement">
<Icon name="User" />
<div style="margin-left: var(--spacing-l">
{parseInt(group?.users?.length) || 0} user{parseInt(
group?.users?.length
) === 1
? ""
: "s"}
</div>
</div>
<div class="desktop tableElement">
<Icon name="WebPage" />
<div style="margin-left: var(--spacing-l)">
{parseInt(group?.apps?.length) || 0} app{parseInt(group?.apps?.length) === 1
? ""
: "s"}
</div>
</div>
<div>
<div class="group-row-actions">
<div>
<Button on:click={() => $goto(`./${group._id}`)} size="S" cta
>Manage</Button
>
</div>
<div>
<ActionMenu align="right">
<span slot="control">
<Icon hoverable name="More" />
</span>
<MenuItem on:click={() => deleteGroup(group)} icon="Delete"
>Delete</MenuItem
>
<MenuItem on:click={() => editGroup(group)} icon="Edit">Edit</MenuItem>
</ActionMenu>
</div>
</div>
</div>
<Modal bind:this={modal}>
<CreateEditGroupModal {group} {saveGroup} />
</Modal>
<style>
.group-row-actions {
display: flex;
float: right;
margin-right: var(--spacing-xl);
grid-template-columns: 75px 75px;
grid-gap: var(--spacing-xl);
}
.name {
grid-gap: var(--spacing-xl);
grid-template-columns: 75px 75px;
align-items: center;
}
.circle {
border-radius: 50%;
height: 30px;
color: white;
font-weight: bold;
display: inline-block;
font-size: 1.2em;
width: 30px;
}
.tableElement {
display: flex;
}
.circle > div {
padding: calc(1.5 * var(--spacing-xs)) var(--spacing-xs);
}
.name {
text-decoration: none;
overflow: hidden;
}
.name :global(.spectrum-Heading) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: calc(1.5 * var(--spacing-xl));
}
.title :global(h1:hover) {
color: var(--spectrum-global-color-blue-600);
cursor: pointer;
transition: color 130ms ease;
}
@media (max-width: 640px) {
.desktop {
display: none !important;
}
}
</style>

View File

@ -0,0 +1,3 @@
<div style="float: right;">
<slot />
</div>

View File

@ -0,0 +1,153 @@
<script>
import {
Layout,
Heading,
Body,
Button,
Modal,
Tag,
Tags,
notifications,
} from "@budibase/bbui"
import { groups, auth } from "stores/portal"
import { onMount } from "svelte"
import { Constants } from "@budibase/frontend-core"
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
import UserGroupsRow from "./_components/UserGroupsRow.svelte"
import { cloneDeep } from "lodash/fp"
const DefaultGroup = {
name: "",
icon: "UserGroup",
color: "var(--spectrum-global-color-blue-600)",
users: [],
apps: [],
roles: {},
}
let modal
let group = cloneDeep(DefaultGroup)
$: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS
)
async function deleteGroup(group) {
try {
groups.actions.delete(group)
} catch (error) {
notifications.error(`Failed to delete group`)
}
}
async function saveGroup(group) {
try {
await groups.actions.save(group)
} catch (error) {
notifications.error(`Failed to save group`)
}
}
const showCreateGroupModal = () => {
group = cloneDeep(DefaultGroup)
modal?.show()
}
onMount(async () => {
try {
if (hasGroupsLicense) {
await groups.actions.init()
}
} catch (error) {
notifications.error("Error getting User groups")
}
})
</script>
<Layout noPadding>
<Layout gap="XS" noPadding>
<div style="display: flex;">
<Heading size="M">User groups</Heading>
{#if !hasGroupsLicense}
<Tags>
<div class="tags">
<div class="tag">
<Tag icon="LockClosed">Pro plan</Tag>
</div>
</div>
</Tags>
{/if}
</div>
<Body>Easily assign and manage your users access with User Groups</Body>
</Layout>
<div class="align-buttons">
<Button
newStyles
icon={hasGroupsLicense ? "UserGroup" : ""}
cta={hasGroupsLicense}
on:click={hasGroupsLicense
? showCreateGroupModal
: window.open("https://budibase.com/pricing/", "_blank")}
>
{hasGroupsLicense ? "Create user group" : "Upgrade Account"}
</Button>
{#if !hasGroupsLicense}
<Button
newStyles
secondary
on:click={() => {
window.open("https://budibase.com/pricing/", "_blank")
}}>View Plans</Button
>
{/if}
</div>
{#if hasGroupsLicense && $groups.length}
<div class="groupTable">
{#each $groups as group}
<div>
<UserGroupsRow {saveGroup} {deleteGroup} {group} />
</div>
{/each}
</div>
{/if}
</Layout>
<Modal bind:this={modal}>
<CreateEditGroupModal bind:group {saveGroup} />
</Modal>
<style>
.align-buttons {
display: flex;
column-gap: var(--spacing-xl);
}
.tag {
margin-top: var(--spacing-xs);
margin-left: var(--spacing-m);
}
.groupTable {
display: grid;
grid-template-rows: auto;
align-items: center;
border-bottom: 1px solid var(--spectrum-alias-border-color-mid);
border-left: 1px solid var(--spectrum-alias-border-color-mid);
background: var(--spectrum-global-color-gray-50);
}
.groupTable :global(> div) {
background: var(--bg-color);
height: 55px;
display: grid;
align-items: center;
grid-gap: var(--spacing-xl);
grid-template-columns: 2fr 2fr 2fr auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 var(--spacing-s);
border-top: 1px solid var(--spectrum-alias-border-color-mid);
border-right: 1px solid var(--spectrum-alias-border-color-mid);
}
</style>

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